import { z } from "zod"; import { and, eq, desc, exists } from "drizzle-orm"; import { userFavorites, objects, workspaces, workspaceMembers, } from "@tasks/database/schema"; import { router, protectedProcedure } from "@/server/trpc"; import { TRPCError } from "@trpc/server"; /** * Confirm the caller can see the given object. Cross-workspace favorites * shouldn't expose object ids the user has no business reading. */ async function assertCallerCanSeeObject( db: typeof import("@tasks/database").db, objectId: string, userId: string, ): Promise { const [row] = await db .select({ id: objects.id, workspaceId: objects.workspaceId, ownerUserId: workspaces.ownerUserId, }) .from(objects) .innerJoin(workspaces, eq(objects.workspaceId, workspaces.id)) .where(eq(objects.id, objectId)) .limit(1); if (!row) { throw new TRPCError({ code: "NOT_FOUND", message: "Object not found" }); } if (row.ownerUserId === userId) return; const [member] = await db .select({ id: workspaceMembers.id }) .from(workspaceMembers) .where( and( eq(workspaceMembers.workspaceId, row.workspaceId), eq(workspaceMembers.userId, userId), ), ) .limit(1); if (!member) { throw new TRPCError({ code: "NOT_FOUND", message: "Object not found" }); } } export const favoritesRouter = router({ list: protectedProcedure.query(async ({ ctx }) => { const rows = await ctx.db .select({ id: userFavorites.id, objectId: userFavorites.objectId, createdAt: userFavorites.createdAt, objectTitle: objects.title, objectType: objects.type, objectIcon: objects.icon, workspaceId: objects.workspaceId, }) .from(userFavorites) .innerJoin(objects, eq(userFavorites.objectId, objects.id)) .where(eq(userFavorites.userId, ctx.session.user.id)) .orderBy(desc(userFavorites.createdAt)); return rows; }), toggle: protectedProcedure .input(z.object({ objectId: z.string().uuid() })) .mutation(async ({ ctx, input }) => { await assertCallerCanSeeObject(ctx.db, input.objectId, ctx.session.user.id); const existing = await ctx.db .select({ id: userFavorites.id }) .from(userFavorites) .where( and( eq(userFavorites.userId, ctx.session.user.id), eq(userFavorites.objectId, input.objectId), ), ) .limit(1); if (existing.length > 0) { await ctx.db.delete(userFavorites).where(eq(userFavorites.id, existing[0].id)); return { favorited: false }; } await ctx.db.insert(userFavorites).values({ userId: ctx.session.user.id, objectId: input.objectId, }); return { favorited: true }; }), isFavorited: protectedProcedure .input(z.object({ objectId: z.string().uuid() })) .query(async ({ ctx, input }) => { const rows = await ctx.db .select({ id: userFavorites.id }) .from(userFavorites) .where( and( eq(userFavorites.userId, ctx.session.user.id), eq(userFavorites.objectId, input.objectId), ), ) .limit(1); return { favorited: rows.length > 0 }; }), });