rcgit

/ karakeep

plain blame

import assert from "node:assert";
import { experimental_trpcMiddleware, TRPCError } from "@trpc/server";
import { and, eq } from "drizzle-orm";
import invariant from "tiny-invariant";
import { z } from "zod";

import { SqliteError } from "@hoarder/db";
import { bookmarkLists, bookmarksInLists } from "@hoarder/db/schema";
import {
  zBookmarkListSchema,
  zEditBookmarkListSchemaWithValidation,
  zNewBookmarkListSchema,
} from "@hoarder/shared/types/lists";

import type { Context } from "../index";
import { authedProcedure, router } from "../index";
import { ensureBookmarkOwnership } from "./bookmarks";

export const ensureListOwnership = experimental_trpcMiddleware<{
  ctx: Context;
  input: { listId: string };
}>().create(async (opts) => {
  const list = await opts.ctx.db.query.bookmarkLists.findFirst({
    where: eq(bookmarkLists.id, opts.input.listId),
    columns: {
      userId: true,
    },
  });
  if (!opts.ctx.user) {
    throw new TRPCError({
      code: "UNAUTHORIZED",
      message: "User is not authorized",
    });
  }
  if (!list) {
    throw new TRPCError({
      code: "NOT_FOUND",
      message: "List not found",
    });
  }
  if (list.userId != opts.ctx.user.id) {
    throw new TRPCError({
      code: "FORBIDDEN",
      message: "User is not allowed to access resource",
    });
  }

  return opts.next();
});

export const listsAppRouter = router({
  create: authedProcedure
    .input(zNewBookmarkListSchema)
    .output(zBookmarkListSchema)
    .mutation(async ({ input, ctx }) => {
      const [result] = await ctx.db
        .insert(bookmarkLists)
        .values({
          name: input.name,
          icon: input.icon,
          userId: ctx.user.id,
          parentId: input.parentId,
          type: input.type,
          query: input.query,
        })
        .returning();
      return result;
    }),
  edit: authedProcedure
    .input(zEditBookmarkListSchemaWithValidation)
    .output(zBookmarkListSchema)
    .use(ensureListOwnership)
    .mutation(async ({ input, ctx }) => {
      if (input.query) {
        const list = await ctx.db.query.bookmarkLists.findFirst({
          where: and(
            eq(bookmarkLists.id, input.listId),
            eq(bookmarkLists.userId, ctx.user.id),
          ),
        });
        // List must exist given that we passed the ownership check
        invariant(list);
        if (list.type !== "smart") {
          throw new TRPCError({
            code: "BAD_REQUEST",
            message: "Manual lists cannot have a query",
          });
        }
      }
      const result = await ctx.db
        .update(bookmarkLists)
        .set({
          name: input.name,
          icon: input.icon,
          parentId: input.parentId,
          query: input.query,
        })
        .where(
          and(
            eq(bookmarkLists.id, input.listId),
            eq(bookmarkLists.userId, ctx.user.id),
          ),
        )
        .returning();
      if (result.length == 0) {
        throw new TRPCError({ code: "NOT_FOUND" });
      }
      return result[0];
    }),
  delete: authedProcedure
    .input(
      z.object({
        listId: z.string(),
      }),
    )
    .use(ensureListOwnership)
    .mutation(async ({ input, ctx }) => {
      const res = await ctx.db
        .delete(bookmarkLists)
        .where(
          and(
            eq(bookmarkLists.id, input.listId),
            eq(bookmarkLists.userId, ctx.user.id),
          ),
        );
      if (res.changes == 0) {
        throw new TRPCError({ code: "NOT_FOUND" });
      }
    }),
  addToList: authedProcedure
    .input(
      z.object({
        listId: z.string(),
        bookmarkId: z.string(),
      }),
    )
    .use(ensureListOwnership)
    .use(ensureBookmarkOwnership)
    .mutation(async ({ input, ctx }) => {
      const list = await ctx.db.query.bookmarkLists.findFirst({
        where: and(
          eq(bookmarkLists.id, input.listId),
          eq(bookmarkLists.userId, ctx.user.id),
        ),
      });
      invariant(list);
      if (list.type === "smart") {
        throw new TRPCError({
          code: "BAD_REQUEST",
          message: "Smart lists cannot be added to",
        });
      }
      try {
        await ctx.db.insert(bookmarksInLists).values({
          listId: input.listId,
          bookmarkId: input.bookmarkId,
        });
      } catch (e) {
        if (e instanceof SqliteError) {
          if (e.code == "SQLITE_CONSTRAINT_PRIMARYKEY") {
            throw new TRPCError({
              code: "BAD_REQUEST",
              message: `Bookmark ${input.bookmarkId} is already in the list ${input.listId}`,
            });
          }
        }
        throw new TRPCError({
          code: "INTERNAL_SERVER_ERROR",
          message: "Something went wrong",
        });
      }
    }),
  removeFromList: authedProcedure
    .input(
      z.object({
        listId: z.string(),
        bookmarkId: z.string(),
      }),
    )
    .use(ensureListOwnership)
    .use(ensureBookmarkOwnership)
    .mutation(async ({ input, ctx }) => {
      const deleted = await ctx.db
        .delete(bookmarksInLists)
        .where(
          and(
            eq(bookmarksInLists.listId, input.listId),
            eq(bookmarksInLists.bookmarkId, input.bookmarkId),
          ),
        );
      if (deleted.changes == 0) {
        throw new TRPCError({
          code: "BAD_REQUEST",
          message: `Bookmark ${input.bookmarkId} is already not in list ${input.listId}`,
        });
      }
    }),
  get: authedProcedure
    .input(
      z.object({
        listId: z.string(),
      }),
    )
    .output(zBookmarkListSchema)
    .use(ensureListOwnership)
    .query(async ({ input, ctx }) => {
      const res = await ctx.db.query.bookmarkLists.findFirst({
        where: and(
          eq(bookmarkLists.id, input.listId),
          eq(bookmarkLists.userId, ctx.user.id),
        ),
      });
      if (!res) {
        throw new TRPCError({ code: "NOT_FOUND" });
      }

      return {
        id: res.id,
        name: res.name,
        icon: res.icon,
        parentId: res.parentId,
        type: res.type,
        query: res.query,
      };
    }),
  list: authedProcedure
    .output(
      z.object({
        lists: z.array(zBookmarkListSchema),
      }),
    )
    .query(async ({ ctx }) => {
      const lists = await ctx.db.query.bookmarkLists.findMany({
        where: and(eq(bookmarkLists.userId, ctx.user.id)),
      });

      return { lists };
    }),
  getListsOfBookmark: authedProcedure
    .input(z.object({ bookmarkId: z.string() }))
    .output(
      z.object({
        lists: z.array(zBookmarkListSchema),
      }),
    )
    .use(ensureBookmarkOwnership)
    .query(async ({ input, ctx }) => {
      const lists = await ctx.db.query.bookmarksInLists.findMany({
        where: and(eq(bookmarksInLists.bookmarkId, input.bookmarkId)),
        with: {
          list: true,
        },
      });
      assert(lists.map((l) => l.list.userId).every((id) => id == ctx.user.id));

      return { lists: lists.map((l) => l.list) };
    }),
});