lifetracker/packages/trpc/routers/categories.ts

305 lines
9.7 KiB
TypeScript

import { experimental_trpcMiddleware, TRPCError } from "@trpc/server";
import { and, desc, eq, inArray, notExists } from "drizzle-orm";
import { z } from "zod";
import { SqliteError } from "@lifetracker/db";
import { categories, colors } from "@lifetracker/db/schema";
import {
ZCategories,
zCategorySchema,
ZCreateCategories,
zCreateCategorySchema,
zGetCategoryResponseSchema,
zUpdateCategoryRequestSchema
} from "@lifetracker/shared/types/categories";
import type { Context } from "../index";
import { authedProcedure, router } from "../index";
import { zColorSchema } from "@lifetracker/shared/types/colors";
function conditionFromInput(input: { categoryCode: string }, userId: string) {
return and(eq(categories.code, input.categoryCode), eq(categories.userId, userId));
}
async function createCategory(
input: z.infer<typeof zCategorySchema>,
ctx: Context,
) {
return ctx.db.transaction(async (trx) => {
console.log("Creating a category", input);
try {
const result = await trx
.insert(categories)
.values({
name: input.name,
code: input.code,
description: input.description,
colorId: input.color.id,
userId: ctx.user!.id,
parentId: input.parentId ?? undefined
})
.returning({
id: categories.id,
name: categories.name,
code: categories.code,
description: categories.description,
colorId: categories.colorId
});
const { colorId, ...newCategory } = result[0];
return {
color: input.color,
...newCategory
};
} catch (e) {
console.log(e);
if (e instanceof SqliteError) {
if (e.code == "SQLITE_CONSTRAINT_UNIQUE") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "There's already a category with this code",
});
}
}
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Something went wrong",
});
}
});
}
export const categoriesAppRouter = router({
categoryStats: authedProcedure
.output(
z.record(
z.string(),
z.object({
numEntries: z.number(),
}),
),
)
.query(async ({ ctx }) => {
const [categoryIds] = await Promise.all([
ctx.db.select({ id: categories.id }).from(categories)
]);
const results: Record<
string,
{ numEntries: number }
> = {};
for (const category of categoryIds) {
results[category.id] = {
numEntries: 3330,
};
}
return results;
}),
list: authedProcedure
.output(
z.object({
categories: z.array(
z.object({
id: z.string(),
code: z.number(),
name: z.string(),
description: z.string().optional(),
color: zColorSchema,
}),
),
}),
)
.query(async ({ ctx }) => {
const dbCategories = await ctx.db
.select({
id: categories.id,
code: categories.code,
name: categories.name,
description: categories.description,
color: {
name: colors.name,
hexcode: colors.hexcode,
inverse: colors.inverse
}
})
.from(categories)
.leftJoin(colors, eq(categories.colorId, colors.id))
;
return {
categories: dbCategories.map(({ color, ...category }) => ({
...category,
color,
})),
};
}),
get: authedProcedure
.input(
z.object({
categoryCode: z.number(),
}),
)
.output(zCategorySchema)
.query(async ({ input, ctx }) => {
const res = await ctx.db
.select({
id: categories.id,
code: categories.code,
name: categories.name,
description: categories.description,
colorId: categories.colorId,
parentId: categories.parentId,
userId: categories.userId,
})
.from(categories)
.where(
and(
conditionFromInput(input, ctx.user.id),
eq(categories.userId, ctx.user.id),
),
);
if (res.length == 0) {
throw new TRPCError({ code: "NOT_FOUND" });
}
const numEntriesWithCategory = res.reduce<
Record<ZCategorizedByEnum, number>
>(
(acc, curr) => {
if (curr.categorizedBy) {
acc[curr.categorizedBy]++;
}
return acc;
},
{ ai: 0, human: 0 },
);
const [color] = await ctx.db.select().from(colors).where(
and(
and(eq(colors.id, res[0].colorId), eq(colors.userId, res[0].userId)),
eq(res[0].userId, ctx.user.id),
)
);
const categoryId: string = res[0].id;
const { id, ...categoryProps } = res[0];
const category: ZCategories = {
color: color,
...categoryProps
};
if (category.parentId == null) {
category.parentId = categoryId;
}
console.log(category);
return category;
}),
create: authedProcedure
.input(zCategorySchema)
.output(
z.object({
id: z.string(),
code: z.number(),
name: z.string(),
color: zColorSchema,
description: z.string().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
return createCategory(input, ctx);
}),
update: authedProcedure
.input(zUpdateCategoryRequestSchema)
.output(
z.object({
id: z.string(),
name: z.string(),
userId: z.string(),
createdAt: z.date(),
}),
)
.mutation(async ({ input, ctx }) => {
try {
const res = await ctx.db
.update(bookmarkTags)
.set({
name: input.name,
})
.where(
and(
eq(bookmarkTags.id, input.tagId),
eq(bookmarkTags.userId, ctx.user.id),
),
)
.returning();
if (res.length == 0) {
throw new TRPCError({ code: "NOT_FOUND" });
}
try {
const affectedBookmarks = await ctx.db.query.tagsOnBookmarks.findMany(
{
where: eq(tagsOnBookmarks.tagId, input.tagId),
columns: {
bookmarkId: true,
},
},
);
await Promise.all(
affectedBookmarks
.map((b) => b.bookmarkId)
.map((id) => triggerSearchReindex(id)),
);
} catch (e) {
console.error("Something ELSE Went Wrong", e);
}
return res[0];
} catch (e) {
if (e instanceof SqliteError) {
if (e.code == "SQLITE_CONSTRAINT_UNIQUE") {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Label name already exists.",
});
}
}
throw e;
}
}),
delete: authedProcedure
.input(
z.object({
categoryCode: z.number(),
}),
)
.mutation(async ({ input, ctx }) => {
// const affectedBookmarks = await ctx.db
// .select({
// bookmarkId: tagsOnBookmarks.bookmarkId,
// })
// .from(tagsOnBookmarks)
// .where(
// and(
// eq(tagsOnBookmarks.tagId, input.tagId),
// // Tag ownership is checked in the ensureTagOwnership middleware
// // eq(bookmarkTags.userId, ctx.user.id),
// ),
// );
const res = await ctx.db
.delete(categories)
.where(conditionFromInput(input, ctx.user.id));
if (res.changes == 0) {
throw new TRPCError({ code: "NOT_FOUND" });
}
}),
});