web: added the base for the web project

This commit is contained in:
Carl-Gerhard Lindesvärd
2023-10-26 20:53:11 +02:00
parent 15e29edaa7
commit 8a87fff689
107 changed files with 3607 additions and 512 deletions

View File

@@ -1,7 +1,11 @@
import { exampleRouter } from "@/server/api/routers/example";
import { createTRPCRouter } from "@/server/api/trpc";
import { chartMetaRouter } from "./routers/chartMeta";
import { chartRouter } from "./routers/chart";
import { reportRouter } from "./routers/report";
import { organizationRouter } from "./routers/organization";
import { userRouter } from "./routers/user";
import { projectRouter } from "./routers/project";
import { clientRouter } from "./routers/client";
import { dashboardRouter } from "./routers/dashboard";
/**
* This is the primary router for your server.
@@ -9,9 +13,13 @@ import { reportRouter } from "./routers/report";
* All routers added in /api/routers should be manually added here.
*/
export const appRouter = createTRPCRouter({
example: exampleRouter,
chartMeta: chartMetaRouter,
chart: chartRouter,
report: reportRouter,
dashboard: dashboardRouter,
organization: organizationRouter,
user: userRouter,
project: projectRouter,
client: clientRouter,
});
// export type definition of API

View File

@@ -1,12 +1,131 @@
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { db } from "@/server/db";
import { map, path, pipe, sort, uniq } from "ramda";
import { pipe, sort, uniq } from "ramda";
import { toDots } from "@/utils/object";
import { Prisma } from "@prisma/client";
import { zChartInput } from "@/utils/validation";
import { type IChartBreakdown, type IChartEvent } from "@/types";
import { type IChartInput, type IChartEvent } from "@/types";
export const config = {
api: {
responseLimit: false,
},
};
export const chartRouter = createTRPCRouter({
events: protectedProcedure
.query(async () => {
const events = await db.event.findMany({
take: 500,
distinct: ["name"],
});
return events;
}),
properties: protectedProcedure
.input(z.object({ event: z.string() }).optional())
.query(async ({ input }) => {
const events = await db.event.findMany({
take: 500,
where: {
...(input?.event
? {
name: input.event,
}
: {}),
},
});
const properties = events
.reduce((acc, event) => {
const properties = event as Record<string, unknown>;
const dotNotation = toDots(properties);
return [...acc, ...Object.keys(dotNotation)];
}, [] as string[])
.map((item) => item.replace(/\.([0-9]+)\./g, ".*."))
.map((item) => item.replace(/\.([0-9]+)/g, "[*]"));
return pipe(
sort<string>((a, b) => a.length - b.length),
uniq,
)(properties);
}),
values: protectedProcedure
.input(z.object({ event: z.string(), property: z.string() }))
.query(async ({ input }) => {
if (isJsonPath(input.property)) {
const events = await db.$queryRawUnsafe<{ value: string }[]>(
`SELECT ${selectJsonPath(
input.property,
)} AS value from events WHERE name = '${
input.event
}' AND "createdAt" >= NOW() - INTERVAL '30 days'`,
);
return {
values: uniq(events.map((item) => item.value)),
};
} else {
const events = await db.event.findMany({
where: {
name: input.event,
[input.property]: {
not: null,
},
createdAt: {
gte: new Date(new Date().getTime() - 1000 * 60 * 60 * 24 * 30),
},
},
distinct: input.property as any,
select: {
[input.property]: true,
},
});
return {
values: uniq(events.map((item) => item[input.property]!)),
};
}
}),
chart: protectedProcedure
.input(zChartInput)
.query(async ({ input: { events, ...input } }) => {
const startDate = input.startDate ?? new Date();
const endDate = input.endDate ?? new Date();
const series: Awaited<ReturnType<typeof getChartData>> = [];
for (const event of events) {
series.push(
...(await getChartData({
...input,
event,
startDate,
endDate,
})),
);
}
return {
series: series.sort((a, b) => {
const sumA = a.data.reduce((acc, item) => acc + item.count, 0);
const sumB = b.data.reduce((acc, item) => acc + item.count, 0);
return sumB - sumA;
}),
};
}),
});
function selectJsonPath(property: string) {
const jsonPath = property
.replace(/^properties\./, "")
.replace(/\.\*\./g, ".**.");
return `jsonb_path_query(properties, '$.${jsonPath}')`;
}
function isJsonPath(property: string) {
return property.startsWith("properties");
}
type ResultItem = {
label: string | null;
@@ -20,14 +139,14 @@ function propertyNameToSql(name: string) {
.split(".")
.map((item, index) => (index === 0 ? item : `'${item}'`))
.join("->");
const findLastOf = "->"
const findLastOf = "->";
const lastArrow = str.lastIndexOf(findLastOf);
if(lastArrow === -1) {
if (lastArrow === -1) {
return str;
}
}
const first = str.slice(0, lastArrow);
const last = str.slice(lastArrow + findLastOf.length);
return `${first}->>${last}`
return `${first}->>${last}`;
}
return name;
@@ -41,12 +160,6 @@ function getTotalCount(arr: ResultItem[]) {
return arr.reduce((acc, item) => acc + item.count, 0);
}
export const config = {
api: {
responseLimit: false,
},
};
async function getChartData({
chartType,
event,
@@ -55,18 +168,19 @@ async function getChartData({
startDate,
endDate,
}: {
chartType: string;
event: IChartEvent;
breakdowns: IChartBreakdown[];
interval: string;
startDate: Date;
endDate: Date;
}) {
const select = [`count(*)::int as count`];
} & Omit<IChartInput, 'events'>) {
const select = [];
const where = [];
const groupBy = [];
const orderBy = [];
if (event.segment === "event") {
select.push(`count(*)::int as count`);
} else {
select.push(`count(DISTINCT profile_id)::int as count`);
}
switch (chartType) {
case "bar": {
orderBy.push("count DESC");
@@ -86,10 +200,43 @@ async function getChartData({
if (filters.length > 0) {
filters.forEach((filter) => {
const { name, value } = filter;
if (name.includes(".")) {
where.push(`${propertyNameToSql(name)} = '${value}'`);
} else {
where.push(`${name} = '${value}'`);
switch (filter.operator) {
case "is": {
if (name.includes(".*.") || name.endsWith("[*]")) {
where.push(
`properties @? '$.${name
.replace(/^properties\./, "")
.replace(/\.\*\./g, "[*].")} ? (${value
.map((val) => `@ == "${val}"`)
.join(" || ")})'`,
);
} else {
where.push(
`${propertyNameToSql(name)} in (${value
.map((val) => `'${val}'`)
.join(", ")})`,
);
}
break;
}
case "isNot": {
if (name.includes(".*.") || name.endsWith("[*]")) {
where.push(
`properties @? '$.${name
.replace(/^properties\./, "")
.replace(/\.\*\./g, "[*].")} ? (${value
.map((val) => `@ != "${val}"`)
.join(" && ")})'`,
);
} else if (name.includes(".")) {
where.push(
`${propertyNameToSql(name)} not in (${value
.map((val) => `'${val}'`)
.join(", ")})`,
);
}
break;
}
}
});
}
@@ -98,7 +245,11 @@ async function getChartData({
if (breakdowns.length) {
const breakdown = breakdowns[0];
if (breakdown) {
select.push(`${propertyNameToSql(breakdown.name)} as label`);
if (isJsonPath(breakdown.name)) {
select.push(`${selectJsonPath(breakdown.name)} as label`);
} else {
select.push(`${breakdown.name} as label`);
}
groupBy.push(`label`);
}
} else {
@@ -123,7 +274,7 @@ async function getChartData({
ORDER BY ${orderBy.join(", ")}
`;
const result = await db.$queryRawUnsafe<ResultItem[]>(sql);
const result = await db.$queryRawUnsafe<ResultItem[]>(sql);
// group by sql label
const series = result.reduce(
@@ -166,108 +317,6 @@ async function getChartData({
});
}
export const chartMetaRouter = createTRPCRouter({
events: protectedProcedure
// .input(z.object())
.query(async ({ input }) => {
const events = await db.event.findMany({
take: 500,
distinct: ["name"],
});
return events;
}),
properties: protectedProcedure
.input(z.object({ event: z.string() }).optional())
.query(async ({ input }) => {
const events = await db.event.findMany({
take: 500,
where: {
...(input?.event
? {
name: input.event,
}
: {}),
},
});
const properties = events.reduce((acc, event) => {
const properties = event as Record<string, unknown>;
const dotNotation = toDots(properties);
return [...acc, ...Object.keys(dotNotation)];
}, [] as string[]);
return pipe(
sort<string>((a, b) => a.length - b.length),
uniq,
)(properties);
}),
values: protectedProcedure
.input(z.object({ event: z.string(), property: z.string() }))
.query(async ({ input }) => {
const events = await db.event.findMany({
where: {
name: input.event,
properties: {
path: input.property.split(".").slice(1),
not: Prisma.DbNull,
},
createdAt: {
// Take last 30 days
gte: new Date(new Date().getTime() - 1000 * 60 * 60 * 24 * 30),
},
},
});
const values = uniq(
map(path(input.property.split(".")), events),
) as string[];
return {
types: uniq(
values.map((value) =>
Array.isArray(value) ? "array" : typeof value,
),
),
values,
};
}),
chart: protectedProcedure
.input(zChartInput)
.query(
async ({
input: { chartType, events, breakdowns, interval, ...input },
}) => {
const startDate = input.startDate ?? new Date();
const endDate = input.endDate ?? new Date();
const series: Awaited<ReturnType<typeof getChartData>> = [];
for (const event of events) {
series.push(
...(await getChartData({
chartType,
event,
breakdowns,
interval,
startDate,
endDate,
})),
);
}
return {
series: series.sort((a, b) => {
const sumA = a.data.reduce((acc, item) => acc + item.count, 0);
const sumB = b.data.reduce((acc, item) => acc + item.count, 0);
return sumB - sumA;
}),
};
},
),
});
function fillEmptySpotsInTimeline(
items: ResultItem[],
interval: string,
@@ -277,14 +326,14 @@ function fillEmptySpotsInTimeline(
const result = [];
const clonedStartDate = new Date(startDate);
const clonedEndDate = new Date(endDate);
if(interval === 'hour') {
if (interval === "hour") {
clonedStartDate.setMinutes(0, 0, 0);
clonedEndDate.setMinutes(0, 0, 0)
clonedEndDate.setMinutes(0, 0, 0);
} else {
clonedStartDate.setHours(2, 0, 0, 0);
clonedEndDate.setHours(2, 0, 0, 0);
}
while (clonedStartDate.getTime() <= clonedEndDate.getTime()) {
const getYear = (date: Date) => date.getFullYear();
const getMonth = (date: Date) => date.getMonth();

View File

@@ -0,0 +1,96 @@
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { db } from "@/server/db";
import { hashPassword } from "@/server/services/hash.service";
import { randomUUID } from "crypto";
import { getOrganizationBySlug } from "@/server/services/organization.service";
export const clientRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
organizationSlug: z.string(),
}),
)
.query(async ({ input }) => {
const organization = await getOrganizationBySlug(input.organizationSlug);
return db.client.findMany({
where: {
organization_id: organization.id,
},
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(),
}),
)
.mutation(({ input }) => {
return db.client.update({
where: {
id: input.id,
},
data: {
name: input.name,
},
});
}),
create: protectedProcedure
.input(
z.object({
name: z.string(),
projectId: z.string(),
organizationSlug: z.string(),
}),
)
.mutation(async ({ input }) => {
const organization = await getOrganizationBySlug(input.organizationSlug);
const secret = randomUUID();
const client = await db.client.create({
data: {
organization_id: organization.id,
project_id: input.projectId,
name: input.name,
secret: await hashPassword(secret),
},
});
return {
clientSecret: secret,
clientId: client.id,
};
}),
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,25 @@
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { db } from "@/server/db";
import { getProjectBySlug } from "@/server/services/project.service";
export const dashboardRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
organizationSlug: z.string(),
projectSlug: z.string(),
}),
)
.query(async ({ input: { projectSlug } }) => {
const project = await getProjectBySlug(projectSlug)
return db.dashboard.findMany({
where: {
project_id: project.id,
},
});
}),
});

View File

@@ -1,25 +0,0 @@
import { z } from "zod";
import {
createTRPCRouter,
protectedProcedure,
publicProcedure,
} from "@/server/api/trpc";
export const exampleRouter = createTRPCRouter({
hello: publicProcedure
.input(z.object({ text: z.string() }))
.query(({ input }) => {
return {
greeting: `Hello ${input.text}`,
};
}),
getAll: publicProcedure.query(() => {
return []
}),
getSecretMessage: protectedProcedure.query(() => {
return "you can now see this secret message!";
}),
});

View File

@@ -0,0 +1,45 @@
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { db } from "@/server/db";
import { getOrganizationBySlug } from "@/server/services/organization.service";
export const organizationRouter = createTRPCRouter({
first: protectedProcedure.query(({ ctx }) => {
return db.organization.findFirst({
where: {
users: {
some: {
id: ctx.session.user.id,
},
},
},
});
}),
get: protectedProcedure
.input(
z.object({
slug: z.string(),
}),
)
.query(({ input }) => {
return getOrganizationBySlug(input.slug)
}),
update: protectedProcedure
.input(
z.object({
name: z.string(),
slug: z.string(),
}),
)
.mutation(({ input }) => {
return db.organization.update({
where: {
slug: input.slug,
},
data: {
name: input.name,
},
});
}),
});

View File

@@ -0,0 +1,81 @@
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { db } from "@/server/db";
import { getOrganizationBySlug } from "@/server/services/organization.service";
export const projectRouter = createTRPCRouter({
list: protectedProcedure
.input(z.object({
organizationSlug: z.string()
}))
.query(async ({ input }) => {
const organization = await getOrganizationBySlug(input.organizationSlug)
return db.project.findMany({
where: {
organization_id: organization.id,
},
});
}),
get: protectedProcedure
.input(
z.object({
id: z.string(),
}),
)
.query(({ input }) => {
return db.project.findUniqueOrThrow({
where: {
id: input.id,
organization_id: "d433c614-69f9-443a-8961-92a662869929",
},
});
}),
update: protectedProcedure
.input(
z.object({
id: z.string(),
name: z.string(),
}),
)
.mutation(({ input }) => {
return db.project.update({
where: {
id: input.id,
organization_id: "d433c614-69f9-443a-8961-92a662869929",
},
data: {
name: input.name,
},
});
}),
create: protectedProcedure
.input(
z.object({
name: z.string(),
}),
)
.mutation(({ input }) => {
return db.project.create({
data: {
organization_id: "d433c614-69f9-443a-8961-92a662869929",
name: input.name,
},
});
}),
remove: protectedProcedure
.input(
z.object({
id: z.string(),
}),
)
.mutation(async ({ input }) => {
await db.project.delete({
where: {
id: input.id,
organization_id: "d433c614-69f9-443a-8961-92a662869929",
},
});
return true
}),
});

View File

@@ -10,6 +10,8 @@ import {
type IChartEvent,
} from "@/types";
import { type Report as DbReport } from "@prisma/client";
import { getProjectBySlug } from "@/server/services/project.service";
import { getDashboardBySlug } from "@/server/services/dashboard.service";
function transform(report: DbReport): IChartInput & { id: string } {
return {
@@ -40,22 +42,27 @@ export const reportRouter = createTRPCRouter({
})
.then(transform);
}),
getDashboard: protectedProcedure
list: protectedProcedure
.input(
z.object({
projectId: z.string(),
dashboardId: z.string(),
projectSlug: z.string(),
dashboardSlug: z.string(),
}),
)
.query(async ({ input: { projectId, dashboardId } }) => {
.query(async ({ input: { projectSlug, dashboardSlug } }) => {
const project = await getProjectBySlug(projectSlug);
const dashboard = await getDashboardBySlug(dashboardSlug);
const reports = await db.report.findMany({
where: {
project_id: projectId,
dashboard_id: dashboardId,
project_id: project.id,
dashboard_id: dashboard.id,
},
});
return reports.map(transform);
return {
reports: reports.map(transform),
dashboard,
}
}),
save: protectedProcedure
.input(

View File

@@ -0,0 +1,67 @@
import { z } from "zod";
import {
createTRPCRouter,
protectedProcedure,
} from "@/server/api/trpc";
import { db } from "@/server/db";
import { hashPassword } from "@/server/services/hash.service";
export const userRouter = createTRPCRouter({
current: protectedProcedure.query(({ ctx }) => {
return db.user.findUniqueOrThrow({
where: {
id: ctx.session.user.id
}
})
}),
update: protectedProcedure
.input(
z.object({
name: z.string(),
email: z.string(),
}),
)
.mutation(({ input, ctx }) => {
return db.user.update({
where: {
id: ctx.session.user.id
},
data: {
name: input.name,
email: input.email,
}
})
}),
changePassword: protectedProcedure
.input(
z.object({
password: z.string(),
oldPassword: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
const user = await db.user.findUniqueOrThrow({
where: {
id: ctx.session.user.id
}
})
if(user.password !== input.oldPassword) {
throw new Error('Old password is incorrect')
}
if(user.password === input.password) {
throw new Error('New password cannot be the same as old password')
}
return db.user.update({
where: {
id: ctx.session.user.id
},
data: {
password: await hashPassword(input.password),
}
})
}),
});

View File

@@ -53,7 +53,6 @@ const createInnerTRPCContext = (opts: CreateContextOptions) => {
*/
export const createTRPCContext = async (opts: CreateNextContextOptions) => {
const { req, res } = opts;
// Get the session from the server using the getServerSession wrapper function
const session = await getServerAuthSession({ req, res });