import { experimental_trpcMiddleware, TRPCError } from "@trpc/server"; import { and, desc, eq, lt, lte, or } from "drizzle-orm"; import { z } from "zod"; import { highlights } from "@hoarder/db/schema"; import { DEFAULT_NUM_HIGHLIGHTS_PER_PAGE, zGetAllHighlightsResponseSchema, zHighlightSchema, zNewHighlightSchema, zUpdateHighlightSchema, } from "@hoarder/shared/types/highlights"; import { zCursorV2 } from "@hoarder/shared/types/pagination"; import { authedProcedure, Context, router } from "../index"; import { ensureBookmarkOwnership } from "./bookmarks"; const ensureHighlightOwnership = experimental_trpcMiddleware<{ ctx: Context; input: { highlightId: string }; }>().create(async (opts) => { const highlight = await opts.ctx.db.query.highlights.findFirst({ where: eq(highlights.id, opts.input.highlightId), columns: { userId: true, }, }); if (!opts.ctx.user) { throw new TRPCError({ code: "UNAUTHORIZED", message: "User is not authorized", }); } if (!highlight) { throw new TRPCError({ code: "NOT_FOUND", message: "Bookmark not found", }); } if (highlight.userId != opts.ctx.user.id) { throw new TRPCError({ code: "FORBIDDEN", message: "User is not allowed to access resource", }); } return opts.next(); }); export const highlightsAppRouter = router({ create: authedProcedure .input(zNewHighlightSchema) .output(zHighlightSchema) .use(ensureBookmarkOwnership) .mutation(async ({ input, ctx }) => { const [result] = await ctx.db .insert(highlights) .values({ bookmarkId: input.bookmarkId, startOffset: input.startOffset, endOffset: input.endOffset, color: input.color, text: input.text, note: input.note, userId: ctx.user.id, }) .returning(); return result; }), getForBookmark: authedProcedure .input(z.object({ bookmarkId: z.string() })) .output(z.object({ highlights: z.array(zHighlightSchema) })) .use(ensureBookmarkOwnership) .query(async ({ input, ctx }) => { const results = await ctx.db.query.highlights.findMany({ where: and( eq(highlights.bookmarkId, input.bookmarkId), eq(highlights.userId, ctx.user.id), ), orderBy: [desc(highlights.createdAt), desc(highlights.id)], }); return { highlights: results }; }), get: authedProcedure .input(z.object({ highlightId: z.string() })) .output(zHighlightSchema) .use(ensureHighlightOwnership) .query(async ({ input, ctx }) => { const result = await ctx.db.query.highlights.findFirst({ where: and( eq(highlights.id, input.highlightId), eq(highlights.userId, ctx.user.id), ), }); if (!result) { throw new TRPCError({ code: "NOT_FOUND" }); } return result; }), getAll: authedProcedure .input( z.object({ cursor: zCursorV2.nullish(), limit: z.number().optional().default(DEFAULT_NUM_HIGHLIGHTS_PER_PAGE), }), ) .output(zGetAllHighlightsResponseSchema) .query(async ({ input, ctx }) => { const results = await ctx.db.query.highlights.findMany({ where: and( eq(highlights.userId, ctx.user.id), input.cursor ? or( lt(highlights.createdAt, input.cursor.createdAt), and( eq(highlights.createdAt, input.cursor.createdAt), lte(highlights.id, input.cursor.id), ), ) : undefined, ), limit: input.limit + 1, orderBy: [desc(highlights.createdAt), desc(highlights.id)], }); let nextCursor: z.infer | null = null; if (results.length > input.limit) { const nextItem = results.pop()!; nextCursor = { id: nextItem.id, createdAt: nextItem.createdAt, }; } return { highlights: results, nextCursor, }; }), delete: authedProcedure .input(z.object({ highlightId: z.string() })) .output(zHighlightSchema) .use(ensureHighlightOwnership) .mutation(async ({ input, ctx }) => { const result = await ctx.db .delete(highlights) .where( and( eq(highlights.id, input.highlightId), eq(highlights.userId, ctx.user.id), ), ) .returning(); if (result.length == 0) { throw new TRPCError({ code: "NOT_FOUND", }); } return result[0]; }), update: authedProcedure .input(zUpdateHighlightSchema) .output(zHighlightSchema) .use(ensureHighlightOwnership) .mutation(async ({ input, ctx }) => { const result = await ctx.db .update(highlights) .set({ color: input.color, }) .where( and( eq(highlights.id, input.highlightId), eq(highlights.userId, ctx.user.id), ), ) .returning(); if (result.length == 0) { throw new TRPCError({ code: "NOT_FOUND", }); } return result[0]; }), });