temp move

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-03-19 23:22:59 +01:00
parent 303c4060f3
commit e4643ce63e
249 changed files with 9 additions and 2 deletions

View File

@@ -1,37 +0,0 @@
import { createTRPCRouter } from '@/server/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,550 +0,0 @@
import { round } from '@/utils/math';
import { subDays } from 'date-fns';
import * as mathjs from 'mathjs';
import { repeat, reverse, sort } from 'ramda';
import { alphabetIds, NOT_SET_VALUE } from '@openpanel/constants';
import {
chQuery,
convertClickhouseDateToJs,
createSqlBuilder,
formatClickhouseDate,
getChartSql,
getEventFiltersWhereClause,
} 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(),
};
}
export async function getFunnelData({ projectId, ...payload }: IChartInput) {
const { startDate, endDate } = getChartStartEndDate(payload);
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 = '${event.name}'`;
return getWhere().replace('WHERE ', '');
});
const innerSql = `SELECT
session_id,
windowFunnel(6048000000000000,'strict_increase')(toUnixTimestamp(created_at), ${funnels.join(', ')}) AS level
FROM events
WHERE (project_id = '${projectId}' AND created_at >= '${formatClickhouseDate(startDate)}') AND (created_at <= '${formatClickhouseDate(endDate)}')
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 = '${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 };
return [
...acc,
{
event: payload.events[item.level - 1]!,
before: prev.count,
current: item.count,
dropoff: {
count: prev.count - item.count,
percent: 100 - (item.count / prev.count) * 100,
},
percent: (item.count / totalSessions) * 100,
prevPercent: (prev.count / totalSessions) * 100,
},
];
},
[] as {
event: IChartEvent;
before: number;
current: number;
dropoff: {
count: number;
percent: number;
};
percent: number;
prevPercent: number;
}[]
);
return {
totalSessions,
steps,
};
}
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,331 +0,0 @@
import {
createTRPCRouter,
protectedProcedure,
publicProcedure,
} from '@/server/api/trpc';
import { average, max, min, round, sum } from '@/utils/math';
import { flatten, map, pipe, prop, sort, uniq } from 'ramda';
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,
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 = '${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 = '${event}' AND ` : ''
} project_id = '${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 = '${projectId}'`;
if (event !== '*') {
sb.where.event = `name = '${event}'`;
}
if (property.startsWith('properties.')) {
sb.select.values = `distinct mapValues(mapExtractKeyLike(properties, '${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 }) => {
return getFunnelData(input);
}),
// 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,
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,97 +0,0 @@
import { randomUUID } from 'crypto';
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { z } from 'zod';
import { hashPassword, stripTrailingSlash } from '@openpanel/common';
import { db, transformClient } from '@openpanel/db';
export const clientRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
organizationId: z.string(),
})
)
.query(async ({ input: { organizationId } }) => {
return db.client.findMany({
where: {
organization_slug: organizationId,
},
include: {
project: true,
},
});
}),
get: protectedProcedure
.input(
z.object({
id: z.string(),
})
)
.query(({ input }) => {
return db.client.findUniqueOrThrow({
where: {
id: input.id,
},
});
}),
update: protectedProcedure
.input(
z.object({
id: z.string(),
name: z.string(),
cors: z.string(),
})
)
.mutation(({ input }) => {
return db.client.update({
where: {
id: input.id,
},
data: {
name: input.name,
cors: input.cors,
},
});
}),
create: protectedProcedure
.input(
z.object({
name: z.string(),
projectId: z.string(),
organizationId: z.string(),
cors: z.string().nullable(),
})
)
.mutation(async ({ input }) => {
const secret = randomUUID();
const client = await db.client.create({
data: {
organization_slug: input.organizationId,
project_id: input.projectId,
name: input.name,
secret: input.cors ? null : await hashPassword(secret),
cors: input.cors ? stripTrailingSlash(input.cors) : '*',
},
});
return {
...transformClient(client),
secret: input.cors ? null : 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 '@/server/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),
project_id: projectId,
organization_slug: 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: {
dashboard_id: 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,29 +0,0 @@
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { z } from 'zod';
import { 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_project_id: {
name,
project_id: projectId,
},
},
create: { project_id: projectId, name, icon, color, conversion },
update: { icon, color, conversion },
});
}),
});

View File

@@ -1,64 +0,0 @@
import { randomUUID } from 'crypto';
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { clerkClient } from '@clerk/nextjs';
import { z } from 'zod';
import { hashPassword, stripTrailingSlash } from '@openpanel/common';
import {
db,
transformClient,
transformOrganization,
transformProject,
} from '@openpanel/db';
export const onboardingRouter = createTRPCRouter({
organziation: protectedProcedure
.input(
z.object({
organization: z.string(),
project: z.string(),
cors: z.string().nullable(),
})
)
.mutation(async ({ input, ctx }) => {
const org = await clerkClient.organizations.createOrganization({
name: input.organization,
createdBy: ctx.session.userId,
});
if (org.slug) {
const project = await db.project.create({
data: {
name: input.project,
organization_slug: org.slug,
},
});
const secret = randomUUID();
const client = await db.client.create({
data: {
name: `${project.name} Client`,
organization_slug: org.slug,
project_id: project.id,
cors: input.cors ? stripTrailingSlash(input.cors) : '*',
secret: input.cors ? null : await hashPassword(secret),
},
});
return {
client: transformClient({
...client,
secret: input.cors ? null : secret,
}),
project: transformProject(project),
organization: transformOrganization(org),
};
}
return {
client: null,
project: null,
organization: org,
};
}),
});

View File

@@ -1,49 +0,0 @@
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { clerkClient } from '@clerk/nextjs';
import { z } from 'zod';
import { 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,
});
}),
});

View File

@@ -1,65 +0,0 @@
import {
createTRPCRouter,
protectedProcedure,
publicProcedure,
} from '@/server/api/trpc';
import { flatten, map, pipe, prop, sort, uniq } from 'ramda';
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 = '${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 = '${projectId}'`;
if (property.startsWith('properties.')) {
sb.select.values = `distinct mapValues(mapExtractKeyLike(properties, '${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 '@/server/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({
organizationId: z.string().nullable(),
})
)
.query(async ({ input: { organizationId } }) => {
if (organizationId === null) return [];
return getProjectsByOrganizationSlug(organizationId);
}),
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),
organizationId: z.string(),
})
)
.mutation(async ({ input }) => {
return db.project.create({
data: {
id: await getId('project', input.name),
organization_slug: input.organizationId,
name: input.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 '@/server/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,
project_id: 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: {
project_id: projectId,
date: {
gte: new Date(startDate),
lte: new Date(endDate),
},
},
});
}),
});

View File

@@ -1,73 +0,0 @@
import { createTRPCRouter, protectedProcedure } from '@/server/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: {
project_id: dashboard.project_id,
dashboard_id: dashboardId,
name: report.name,
events: report.events,
interval: report.interval,
breakdowns: report.breakdowns,
chart_type: report.chartType,
line_type: 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,
chart_type: report.chartType,
line_type: 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 '@/server/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: {
project_id: input.projectId,
},
create: {
id: uid.rnd(),
organization_slug: input.organizationId,
project_id: 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 '@/server/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,49 +0,0 @@
import { clerkClient } from '@clerk/nextjs';
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 {
const user = await clerkClient.users.getUser(ctx.session.userId);
return next({
ctx: {
session: { ...ctx.session, user },
},
});
} 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,24 +0,0 @@
import { notFound } from 'next/navigation';
import { getOrganizationBySlug, getProjectById } from '@openpanel/db';
export async function getExists(organizationSlug: string, projectId?: string) {
const promises: Promise<any>[] = [getOrganizationBySlug(organizationSlug)];
if (projectId) {
promises.push(getProjectById(projectId));
}
const results = await Promise.all(promises);
if (results.some((res) => !res)) {
return notFound();
}
return {
organization: results[0] as Awaited<
ReturnType<typeof getOrganizationBySlug>
>,
project: results[1] as Awaited<ReturnType<typeof getProjectById>>,
};
}