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, categoryId?: string }, userId: string) { const queryType = input.categoryCode ? eq(categories.code, input.categoryCode) : eq(categories.id, input.categoryId); return and(queryType, eq(categories.userId, userId)); } async function createCategory( input: z.infer, ctx: Context, ) { return ctx.db.transaction(async (trx) => { 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) { 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 >( (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; } 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 }) => { const colorIdQuery = await ctx.db.select({ id: colors.id }).from(colors).where( and( eq(colors.name, input.color.name), eq(colors.userId, ctx.user.id), ) ); const res = await ctx.db .update(categories) .set({ name: input.name, description: input.description, code: input.code, colorId: colorIdQuery[0].id, }) .where( and( conditionFromInput({ categoryId: input.categoryId }, ctx.user.id), eq(categories.userId, ctx.user.id), ), ).returning(); return res[0]; }), 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" }); } }), });