import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { and, eq, or } from "drizzle-orm"; import { objectRelations, objects } from "@tasks/database/schema"; import { router, workspaceProcedure } from "@/server/trpc"; /** * Confirm both endpoints of a relation live in the resolved workspace. Without * this guard, callers could relate cross-tenant objects to leak titles/types. */ async function assertObjectsInWorkspace( db: typeof import("@tasks/database").db, ids: string[], workspaceId: string, ): Promise { const rows = await db .select({ id: objects.id }) .from(objects) .where(and(eq(objects.workspaceId, workspaceId), or(...ids.map((id) => eq(objects.id, id))))); if (rows.length !== ids.length) { throw new TRPCError({ code: "NOT_FOUND", message: "Object not found" }); } } export const relationsRouter = router({ list: workspaceProcedure .input( z.object({ objectId: z.string().uuid(), direction: z.enum(["outgoing", "incoming", "both"]).optional(), }), ) .query(async ({ ctx, input }) => { await assertObjectsInWorkspace(ctx.db, [input.objectId], ctx.workspace.id); const dir = input.direction ?? "both"; const baseSelect = { id: objectRelations.id, relationType: objectRelations.relationType, sourceId: objectRelations.sourceId, targetId: objectRelations.targetId, createdAt: objectRelations.createdAt, relatedId: objects.id, relatedTitle: objects.title, relatedType: objects.type, relatedWorkspaceId: objects.workspaceId, }; const outgoing = dir === "incoming" ? [] : await ctx.db .select(baseSelect) .from(objectRelations) .innerJoin(objects, eq(objectRelations.targetId, objects.id)) .where( and( eq(objectRelations.sourceId, input.objectId), eq(objects.workspaceId, ctx.workspace.id), ), ); const incoming = dir === "outgoing" ? [] : await ctx.db .select(baseSelect) .from(objectRelations) .innerJoin(objects, eq(objectRelations.sourceId, objects.id)) .where( and( eq(objectRelations.targetId, input.objectId), eq(objects.workspaceId, ctx.workspace.id), ), ); const relations = [ ...outgoing.map((r) => ({ id: r.id, relationType: r.relationType, sourceId: r.sourceId, targetId: r.targetId, createdAt: r.createdAt, direction: "outgoing" as const, relatedObject: { id: r.relatedId, title: r.relatedTitle, type: r.relatedType, }, })), ...incoming.map((r) => ({ id: r.id, relationType: r.relationType, sourceId: r.sourceId, targetId: r.targetId, createdAt: r.createdAt, direction: "incoming" as const, relatedObject: { id: r.relatedId, title: r.relatedTitle, type: r.relatedType, }, })), ]; return { relations }; }), create: workspaceProcedure .input( z.object({ sourceId: z.string().uuid(), targetId: z.string().uuid(), relationType: z.string().min(1).max(50), }), ) .mutation(async ({ ctx, input }) => { if (input.sourceId === input.targetId) { throw new TRPCError({ code: "BAD_REQUEST", message: "Cannot relate an object to itself", }); } await assertObjectsInWorkspace( ctx.db, [input.sourceId, input.targetId], ctx.workspace.id, ); try { const [created] = await ctx.db .insert(objectRelations) .values({ sourceId: input.sourceId, targetId: input.targetId, relationType: input.relationType, }) .returning(); if (!created) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to create relation", }); } return created; } catch (cause) { const msg = cause instanceof Error ? cause.message : String(cause); if ( msg.includes("unique") || msg.includes("duplicate") || msg.includes("object_relations_source_target_type_unique") ) { throw new TRPCError({ code: "CONFLICT", message: "This relation already exists", cause, }); } throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to create relation", cause, }); } }), delete: workspaceProcedure .input(z.object({ id: z.string().uuid() })) .mutation(async ({ ctx, input }) => { // Confirm the relation's source object lives in this workspace before // deleting (cheap guard against cross-tenant ID guessing). const [rel] = await ctx.db .select({ id: objectRelations.id, sourceWorkspaceId: objects.workspaceId, }) .from(objectRelations) .innerJoin(objects, eq(objectRelations.sourceId, objects.id)) .where(eq(objectRelations.id, input.id)) .limit(1); if (!rel || rel.sourceWorkspaceId !== ctx.workspace.id) { throw new TRPCError({ code: "NOT_FOUND", message: "Relation not found" }); } const deleted = await ctx.db .delete(objectRelations) .where(eq(objectRelations.id, input.id)) .returning({ id: objectRelations.id }); if (deleted.length === 0) { throw new TRPCError({ code: "NOT_FOUND", message: "Relation not found" }); } return { ok: true as const, id: deleted[0]!.id }; }), });