import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { and, asc, eq, getTableColumns, inArray, isNull, sql, } from "drizzle-orm"; import { objectTypes } from "@tasks/shared"; import { objectAssignees, objects, } from "@tasks/database/schema"; import { router, protectedProcedure } from "@/server/trpc"; const objectTypeSchema = z.enum(objectTypes); const TREE_TYPES = ["project", "group", "document", "whiteboard"] as const; export type ObjectTreeNode = { id: string; title: string; type: string; icon: string | null; parentId: string | null; childCount: number; children: ObjectTreeNode[]; }; export const objectsRouter = router({ list: protectedProcedure .input( z.object({ workspaceId: z.string().uuid(), parentId: z.string().uuid().nullable().optional(), type: objectTypeSchema.optional(), status: z.string().optional(), limit: z.number().int().positive().max(500).optional(), offset: z.number().int().nonnegative().optional(), }), ) .query(async ({ ctx, input }) => { const limit = input.limit ?? 50; const offset = input.offset ?? 0; const conditions = [ eq(objects.workspaceId, input.workspaceId), isNull(objects.archivedAt), ]; if (input.parentId === null) { conditions.push(isNull(objects.parentId)); } else if (input.parentId !== undefined) { conditions.push(eq(objects.parentId, input.parentId)); } if (input.type !== undefined) { conditions.push(eq(objects.type, input.type)); } if (input.status !== undefined) { conditions.push(eq(objects.status, input.status)); } const rows = await ctx.db .select({ ...getTableColumns(objects), assigneeCount: sql`( select count(*)::int from object_assignees where object_id = ${objects.id} )`.mapWith(Number), }) .from(objects) .where(and(...conditions)) .orderBy(asc(objects.sortOrder), asc(objects.id)) .limit(limit) .offset(offset); return { objects: rows }; }), getById: protectedProcedure .input(z.object({ id: z.string().uuid() })) .query(async ({ ctx, input }) => { const obj = await ctx.db.query.objects.findFirst({ where: eq(objects.id, input.id), with: { children: true, assignees: { with: { user: true, }, }, propertyValues: { with: { propertyDefinition: true, }, }, }, }); if (!obj) { throw new TRPCError({ code: "NOT_FOUND", message: "Object not found" }); } const children = [...obj.children].sort((a, b) => { if (a.sortOrder !== b.sortOrder) { return a.sortOrder - b.sortOrder; } return a.id.localeCompare(b.id); }); return { ...obj, children }; }), getTree: protectedProcedure .input( z.object({ workspaceId: z.string().uuid(), maxDepth: z.number().int().positive().max(100).optional(), }), ) .query(async ({ ctx, input }) => { const maxDepth = input.maxDepth ?? 50; const rows = await ctx.db .select() .from(objects) .where( and( eq(objects.workspaceId, input.workspaceId), inArray(objects.type, [...TREE_TYPES]), isNull(objects.archivedAt), ), ) .orderBy(asc(objects.sortOrder), asc(objects.id)); const ids = new Set(rows.map((r) => r.id)); const childCountMap = new Map(); for (const row of rows) { if (row.parentId) { childCountMap.set( row.parentId, (childCountMap.get(row.parentId) ?? 0) + 1, ); } } function buildTree(parentId: string | null, depth: number): ObjectTreeNode[] { if (depth > maxDepth) { return []; } const directChildren = rows.filter((r) => r.parentId === parentId); return directChildren.map((r) => ({ id: r.id, title: r.title, type: r.type, icon: r.icon, parentId: r.parentId, childCount: childCountMap.get(r.id) ?? 0, children: buildTree(r.id, depth + 1), })); } const roots = rows.filter( (r) => r.parentId === null || !ids.has(r.parentId), ); const tree: ObjectTreeNode[] = roots.map((r) => ({ id: r.id, title: r.title, type: r.type, icon: r.icon, parentId: r.parentId, childCount: childCountMap.get(r.id) ?? 0, children: buildTree(r.id, 1), })); return { tree }; }), create: protectedProcedure .input( z.object({ type: objectTypeSchema, title: z.string().min(1).max(500), parentId: z.string().uuid().nullable().optional(), workspaceId: z.string().uuid(), description: z.string().optional(), icon: z.string().optional(), status: z.string().optional(), templateId: z.string().uuid().nullable().optional(), }), ) .mutation(async ({ ctx, input }) => { const userId = ctx.session.user.id; if (!userId) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Missing user id", }); } const [created] = await ctx.db .insert(objects) .values({ type: input.type, title: input.title, parentId: input.parentId ?? null, workspaceId: input.workspaceId, description: input.description, icon: input.icon, status: input.status, templateId: input.templateId ?? null, createdBy: userId, }) .returning(); if (!created) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to create object", }); } return created; }), update: protectedProcedure .input( z.object({ id: z.string().uuid(), title: z.string().min(1).max(500).optional(), description: z.string().nullable().optional(), icon: z.string().nullable().optional(), status: z.string().nullable().optional(), coverImage: z.string().nullable().optional(), content: z.any().optional(), }), ) .mutation(async ({ ctx, input }) => { const { id, ...patch } = input; const updatedAt = new Date(); const [updated] = await ctx.db .update(objects) .set({ ...(patch.title !== undefined ? { title: patch.title } : {}), ...(patch.description !== undefined ? { description: patch.description } : {}), ...(patch.icon !== undefined ? { icon: patch.icon } : {}), ...(patch.status !== undefined ? { status: patch.status } : {}), ...(patch.coverImage !== undefined ? { coverImage: patch.coverImage } : {}), ...(patch.content !== undefined ? { content: patch.content } : {}), updatedAt, }) .where(eq(objects.id, id)) .returning(); if (!updated) { throw new TRPCError({ code: "NOT_FOUND", message: "Object not found" }); } return updated; }), archive: protectedProcedure .input(z.object({ id: z.string().uuid() })) .mutation(async ({ ctx, input }) => { const archivedAt = new Date(); const [row] = await ctx.db .update(objects) .set({ archivedAt, updatedAt: archivedAt }) .where(eq(objects.id, input.id)) .returning(); if (!row) { throw new TRPCError({ code: "NOT_FOUND", message: "Object not found" }); } return row; }), reorder: protectedProcedure .input( z.object({ id: z.string().uuid(), sortOrder: z.number().int(), newParentId: z.string().uuid().nullable().optional(), }), ) .mutation(async ({ ctx, input }) => { const updates: { sortOrder: number; updatedAt: Date; parentId?: string | null; } = { sortOrder: input.sortOrder, updatedAt: new Date(), }; if (input.newParentId !== undefined) { updates.parentId = input.newParentId; } const [row] = await ctx.db .update(objects) .set(updates) .where(eq(objects.id, input.id)) .returning(); if (!row) { throw new TRPCError({ code: "NOT_FOUND", message: "Object not found" }); } return row; }), assign: protectedProcedure .input( z.object({ objectId: z.string().uuid(), userId: z.string().uuid(), role: z.string().max(50).optional(), action: z.enum(["add", "remove"]), }), ) .mutation(async ({ ctx, input }) => { if (input.action === "remove") { const deleted = await ctx.db .delete(objectAssignees) .where( and( eq(objectAssignees.objectId, input.objectId), eq(objectAssignees.userId, input.userId), ), ) .returning({ id: objectAssignees.id }); if (deleted.length === 0) { throw new TRPCError({ code: "NOT_FOUND", message: "Assignee not found", }); } return { ok: true as const, action: "remove" as const }; } const role = input.role ?? "assignee"; await ctx.db .insert(objectAssignees) .values({ objectId: input.objectId, userId: input.userId, role, }) .onConflictDoUpdate({ target: [objectAssignees.objectId, objectAssignees.userId], set: { role }, }); return { ok: true as const, action: "add" as const }; }), });