move sdk packages to its own folder and rename api & dashboard

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-03-11 13:15:44 +01:00
parent 1ca95442b9
commit 6d4f9010d4
318 changed files with 350 additions and 351 deletions

View File

@@ -0,0 +1,39 @@
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 { uiRouter } from './routers/ui';
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,
ui: uiRouter,
share: shareRouter,
onboarding: onboardingRouter,
reference: referenceRouter,
});
// export type definition of API
export type AppRouter = typeof appRouter;

View File

@@ -0,0 +1,356 @@
import { getDaysOldDate } from '@/utils/date';
import { round } from '@/utils/math';
import * as mathjs from 'mathjs';
import { sort } from 'ramda';
import { alphabetIds, NOT_SET_VALUE } from '@mixan/constants';
import { chQuery, convertClickhouseDateToJs, getChartSql } from '@mixan/db';
import type {
IChartEvent,
IChartInput,
IChartRange,
IGetChartDataInput,
IInterval,
} from '@mixan/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 = getDaysOldDate(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 = getDaysOldDate(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(
input: Pick<IChartInput, 'endDate' | 'startDate' | 'range'>
) {
return input.startDate && input.endDate
? { startDate: input.startDate, endDate: input.endDate }
: getDatesFromRange(input.range);
}

View File

@@ -0,0 +1,512 @@
import {
createTRPCRouter,
protectedProcedure,
publicProcedure,
} from '@/server/api/trpc';
import { average, max, min, round, sum } from '@/utils/math';
import { flatten, map, pipe, prop, repeat, reverse, sort, uniq } from 'ramda';
import { z } from 'zod';
import {
chQuery,
createSqlBuilder,
formatClickhouseDate,
getEventFiltersWhereClause,
} from '@mixan/db';
import { zChartInput } from '@mixan/validation';
import type { IChartEvent, IChartInput } from '@mixan/validation';
import {
getChartData,
getChartStartEndDate,
getDatesFromRange,
withFormula,
} from './chart.helpers';
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)}')`
),
]);
console.log('Funnel SQL: ', sql);
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,
};
}
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 = `${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 { startDate, endDate } = getChartStartEndDate(input);
let diff = 0;
switch (input.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;
}
}
const promises = [getSeriesFromEvents(input)];
if (input.previous) {
promises.push(
getSeriesFromEvents({
...input,
...{
startDate: new Date(
new Date(startDate).getTime() - diff
).toISOString(),
endDate: new Date(new Date(endDate).getTime() - diff).toISOString(),
},
})
);
}
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(
sum(final.series.map((item) => item.metrics.sum)),
sum(final.series.map((item) => item.metrics.previous.sum?.value ?? 0))
),
average: getPreviousMetric(
round(average(final.series.map((item) => item.metrics.average)), 2),
round(
average(
final.series.map(
(item) => item.metrics.previous.average?.value ?? 0
)
),
2
)
),
min: getPreviousMetric(
min(final.series.map((item) => item.metrics.min)),
min(final.series.map((item) => item.metrics.previous.min?.value ?? 0))
),
max: getPreviousMetric(
max(final.series.map((item) => item.metrics.max)),
max(final.series.map((item) => item.metrics.previous.max?.value ?? 0))
),
};
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];
}
});
// await new Promise((res) => {
// setTimeout(() => {
// res();
// }, 100);
// });
return final;
}),
});
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,
};
}
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

@@ -0,0 +1,124 @@
import { randomUUID } from 'crypto';
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { db } from '@/server/db';
import { z } from 'zod';
import { hashPassword } from '@mixan/common';
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(),
withCors: z.boolean().default(true),
})
)
.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.withCors ? null : await hashPassword(secret),
},
});
return {
clientSecret: input.withCors ? null : secret,
clientId: client.id,
cors: client.cors,
};
}),
create2: protectedProcedure
.input(
z.object({
name: z.string(),
projectId: z.string(),
organizationId: z.string(),
domain: z.string().nullish(),
})
)
.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.domain ? undefined : await hashPassword(secret),
cors: input.domain || undefined,
},
});
return {
clientSecret: input.domain ? null : secret,
clientId: client.id,
cors: client.cors,
};
}),
remove: protectedProcedure
.input(
z.object({
id: z.string(),
})
)
.mutation(async ({ input }) => {
await db.client.delete({
where: {
id: input.id,
},
});
return true;
}),
});

View File

@@ -0,0 +1,131 @@
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { db, getId } from '@/server/db';
import { PrismaError } from 'prisma-error-enum';
import { z } from 'zod';
import type { Prisma } from '@mixan/db';
export const dashboardRouter = createTRPCRouter({
get: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return db.dashboard.findUnique({
where: {
id: input.id,
},
});
}),
list: protectedProcedure
.input(
z
.object({
projectId: z.string(),
})
.or(
z.object({
organizationId: z.string(),
})
)
)
.query(async ({ input }) => {
if ('projectId' in input) {
return db.dashboard.findMany({
where: {
project_id: input.projectId,
},
orderBy: {
createdAt: 'desc',
},
include: {
project: true,
},
});
} else {
return db.dashboard.findMany({
where: {
project: {
organization_slug: input.organizationId,
},
},
include: {
project: true,
},
orderBy: {
createdAt: 'desc',
},
});
}
}),
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

@@ -0,0 +1,29 @@
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { z } from 'zod';
import { db } from '@mixan/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

@@ -0,0 +1,40 @@
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { clerkClient } from '@clerk/nextjs';
import { z } from 'zod';
import { db } from '@mixan/db';
export const onboardingRouter = createTRPCRouter({
organziation: protectedProcedure
.input(
z.object({
organization: z.string(),
project: z.string().optional(),
})
)
.mutation(async ({ input, ctx }) => {
const org = await clerkClient.organizations.createOrganization({
name: input.organization,
createdBy: ctx.session.userId,
});
if (org.slug && input.project) {
const project = await db.project.create({
data: {
name: input.project,
organization_slug: org.slug,
},
});
return {
project,
organization: org,
};
}
return {
project: null,
organization: org,
};
}),
});

View File

@@ -0,0 +1,50 @@
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { clerkClient } from '@clerk/nextjs';
import { z } from 'zod';
import { getOrganizationBySlug } from '@mixan/db';
import { zInviteUser } from '@mixan/validation';
export const organizationRouter = createTRPCRouter({
list: protectedProcedure.query(() => {
return clerkClient.organizations.getOrganizationList();
}),
// first: protectedProcedure.query(() => getCurrentOrganization()),
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

@@ -0,0 +1,123 @@
import {
createTRPCRouter,
protectedProcedure,
publicProcedure,
} from '@/server/api/trpc';
import { db } from '@/server/db';
import { flatten, map, pipe, prop, sort, uniq } from 'ramda';
import { z } from 'zod';
import { chQuery, createSqlBuilder } from '@mixan/db';
export const profileRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
query: z.string().nullable(),
projectId: z.string(),
take: z.number().default(100),
skip: z.number().default(0),
})
)
.query(async ({ input: { take, skip, projectId, query } }) => {
return db.profile.findMany({
take,
skip,
where: {
project_id: projectId,
...(query
? {
OR: [
{
first_name: {
contains: query,
mode: 'insensitive',
},
},
{
last_name: {
contains: query,
mode: 'insensitive',
},
},
{
email: {
contains: query,
mode: 'insensitive',
},
},
],
}
: {}),
},
orderBy: {
createdAt: 'desc',
},
});
}),
get: protectedProcedure
.input(
z.object({
id: z.string(),
})
)
.query(async ({ input: { id } }) => {
return db.profile.findUniqueOrThrow({
where: {
id,
},
});
}),
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

@@ -0,0 +1,82 @@
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { db, getId } from '@/server/db';
import { slug } from '@/utils/slug';
import { z } from 'zod';
export const projectRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
organizationId: z.string().nullable(),
})
)
.query(async ({ input: { organizationId } }) => {
if (organizationId === null) return [];
return db.project.findMany({
where: {
organization_slug: organizationId,
},
});
}),
get: protectedProcedure
.input(
z.object({
id: z.string(),
})
)
.query(({ input: { id } }) => {
return db.project.findUniqueOrThrow({
where: {
id,
},
});
}),
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

@@ -0,0 +1,54 @@
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { z } from 'zod';
import { db, getReferences } from '@mixan/db';
import { zCreateReference, zRange } from '@mixan/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: protectedProcedure
.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

@@ -0,0 +1,119 @@
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { db } from '@/server/db';
import { z } from 'zod';
import { transformReport } from '@mixan/db';
import { zChartInput } from '@mixan/validation';
export const reportRouter = createTRPCRouter({
get: protectedProcedure
.input(
z.object({
id: z.string(),
})
)
.query(({ input: { id } }) => {
return db.report
.findUniqueOrThrow({
where: {
id,
},
})
.then(transformReport);
}),
list: protectedProcedure
.input(
z.object({
projectId: z.string(),
dashboardId: z.string(),
})
)
.query(async ({ input: { projectId, dashboardId } }) => {
const [dashboard, reports] = await db.$transaction([
db.dashboard.findUniqueOrThrow({
where: {
id: dashboardId,
},
}),
db.report.findMany({
where: {
project_id: projectId,
dashboard_id: dashboardId,
},
orderBy: {
createdAt: 'desc',
},
}),
]);
return {
reports: reports.map(transformReport),
dashboard,
};
}),
save: 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

@@ -0,0 +1,30 @@
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { db } from '@/server/db';
import ShortUniqueId from 'short-unique-id';
import { zShareOverview } from '@mixan/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

@@ -0,0 +1,15 @@
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { z } from 'zod';
export const uiRouter = createTRPCRouter({
breadcrumbs: protectedProcedure
.input(
z.object({
url: z.string(),
})
)
.query(({ input: { url } }) => {
const parts = url.split('/').filter(Boolean);
return parts;
}),
});

View File

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

@@ -0,0 +1,49 @@
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);