aboutsummaryrefslogtreecommitdiffstats
path: root/packages/trpc/routers
diff options
context:
space:
mode:
Diffstat (limited to 'packages/trpc/routers')
-rw-r--r--packages/trpc/routers/_app.ts15
-rw-r--r--packages/trpc/routers/admin.ts77
-rw-r--r--packages/trpc/routers/apiKeys.ts61
-rw-r--r--packages/trpc/routers/bookmarks.test.ts200
-rw-r--r--packages/trpc/routers/bookmarks.ts454
-rw-r--r--packages/trpc/routers/lists.ts173
-rw-r--r--packages/trpc/routers/users.test.ts99
-rw-r--r--packages/trpc/routers/users.ts93
8 files changed, 1172 insertions, 0 deletions
diff --git a/packages/trpc/routers/_app.ts b/packages/trpc/routers/_app.ts
new file mode 100644
index 00000000..6e5dd91d
--- /dev/null
+++ b/packages/trpc/routers/_app.ts
@@ -0,0 +1,15 @@
+import { router } from "../index";
+import { adminAppRouter } from "./admin";
+import { apiKeysAppRouter } from "./apiKeys";
+import { bookmarksAppRouter } from "./bookmarks";
+import { listsAppRouter } from "./lists";
+import { usersAppRouter } from "./users";
+export const appRouter = router({
+ bookmarks: bookmarksAppRouter,
+ apiKeys: apiKeysAppRouter,
+ users: usersAppRouter,
+ lists: listsAppRouter,
+ admin: adminAppRouter,
+});
+// export type definition of API
+export type AppRouter = typeof appRouter;
diff --git a/packages/trpc/routers/admin.ts b/packages/trpc/routers/admin.ts
new file mode 100644
index 00000000..8a7b592d
--- /dev/null
+++ b/packages/trpc/routers/admin.ts
@@ -0,0 +1,77 @@
+import { adminProcedure, router } from "../index";
+import { z } from "zod";
+import { count } from "drizzle-orm";
+import { bookmarks, users } from "@hoarder/db/schema";
+import {
+ LinkCrawlerQueue,
+ OpenAIQueue,
+ SearchIndexingQueue,
+} from "@hoarder/shared/queues";
+
+export const adminAppRouter = router({
+ stats: adminProcedure
+ .output(
+ z.object({
+ numUsers: z.number(),
+ numBookmarks: z.number(),
+ pendingCrawls: z.number(),
+ pendingIndexing: z.number(),
+ pendingOpenai: z.number(),
+ }),
+ )
+ .query(async ({ ctx }) => {
+ const [
+ [{ value: numUsers }],
+ [{ value: numBookmarks }],
+ pendingCrawls,
+ pendingIndexing,
+ pendingOpenai,
+ ] = await Promise.all([
+ ctx.db.select({ value: count() }).from(users),
+ ctx.db.select({ value: count() }).from(bookmarks),
+ LinkCrawlerQueue.getWaitingCount(),
+ SearchIndexingQueue.getWaitingCount(),
+ OpenAIQueue.getWaitingCount(),
+ ]);
+
+ return {
+ numUsers,
+ numBookmarks,
+ pendingCrawls,
+ pendingIndexing,
+ pendingOpenai,
+ };
+ }),
+ recrawlAllLinks: adminProcedure.mutation(async ({ ctx }) => {
+ const bookmarkIds = await ctx.db.query.bookmarkLinks.findMany({
+ columns: {
+ id: true,
+ },
+ });
+
+ await Promise.all(
+ bookmarkIds.map((b) =>
+ LinkCrawlerQueue.add("crawl", {
+ bookmarkId: b.id,
+ }),
+ ),
+ );
+ }),
+
+ reindexAllBookmarks: adminProcedure.mutation(async ({ ctx }) => {
+ const bookmarkIds = await ctx.db.query.bookmarks.findMany({
+ columns: {
+ id: true,
+ },
+ });
+
+ await Promise.all(
+ bookmarkIds.map((b) =>
+ SearchIndexingQueue.add("search_indexing", {
+ bookmarkId: b.id,
+ type: "index",
+ }),
+ ),
+ );
+ }),
+});
diff --git a/packages/trpc/routers/apiKeys.ts b/packages/trpc/routers/apiKeys.ts
new file mode 100644
index 00000000..d13f87fb
--- /dev/null
+++ b/packages/trpc/routers/apiKeys.ts
@@ -0,0 +1,61 @@
+import { generateApiKey } from "../auth";
+import { authedProcedure, router } from "../index";
+import { z } from "zod";
+import { apiKeys } from "@hoarder/db/schema";
+import { eq, and } from "drizzle-orm";
+
+export const apiKeysAppRouter = router({
+ create: authedProcedure
+ .input(
+ z.object({
+ name: z.string(),
+ }),
+ )
+ .output(
+ z.object({
+ id: z.string(),
+ name: z.string(),
+ key: z.string(),
+ createdAt: z.date(),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ return await generateApiKey(input.name, ctx.user.id);
+ }),
+ revoke: authedProcedure
+ .input(
+ z.object({
+ id: z.string(),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ await ctx.db
+ .delete(apiKeys)
+ .where(and(eq(apiKeys.id, input.id), eq(apiKeys.userId, ctx.user.id)));
+ }),
+ list: authedProcedure
+ .output(
+ z.object({
+ keys: z.array(
+ z.object({
+ id: z.string(),
+ name: z.string(),
+ createdAt: z.date(),
+ keyId: z.string(),
+ }),
+ ),
+ }),
+ )
+ .query(async ({ ctx }) => {
+ const resp = await ctx.db.query.apiKeys.findMany({
+ where: eq(apiKeys.userId, ctx.user.id),
+ columns: {
+ id: true,
+ name: true,
+ createdAt: true,
+ keyId: true,
+ },
+ });
+ return { keys: resp };
+ }),
+});
diff --git a/packages/trpc/routers/bookmarks.test.ts b/packages/trpc/routers/bookmarks.test.ts
new file mode 100644
index 00000000..724a9998
--- /dev/null
+++ b/packages/trpc/routers/bookmarks.test.ts
@@ -0,0 +1,200 @@
+import { CustomTestContext, defaultBeforeEach } from "../testUtils";
+import { expect, describe, test, beforeEach, assert } from "vitest";
+
+beforeEach<CustomTestContext>(defaultBeforeEach(true));
+
+describe("Bookmark Routes", () => {
+ test<CustomTestContext>("create bookmark", async ({ apiCallers }) => {
+ const api = apiCallers[0].bookmarks;
+ const bookmark = await api.createBookmark({
+ url: "https://google.com",
+ type: "link",
+ });
+
+ const res = await api.getBookmark({ bookmarkId: bookmark.id });
+ assert(res.content.type == "link");
+ expect(res.content.url).toEqual("https://google.com");
+ expect(res.favourited).toEqual(false);
+ expect(res.archived).toEqual(false);
+ expect(res.content.type).toEqual("link");
+ });
+
+ test<CustomTestContext>("delete bookmark", async ({ apiCallers }) => {
+ const api = apiCallers[0].bookmarks;
+
+ // Create the bookmark
+ const bookmark = await api.createBookmark({
+ url: "https://google.com",
+ type: "link",
+ });
+
+ // It should exist
+ await api.getBookmark({ bookmarkId: bookmark.id });
+
+ // Delete it
+ await api.deleteBookmark({ bookmarkId: bookmark.id });
+
+ // It shouldn't be there anymore
+ await expect(() =>
+ api.getBookmark({ bookmarkId: bookmark.id }),
+ ).rejects.toThrow(/Bookmark not found/);
+ });
+
+ test<CustomTestContext>("update bookmark", async ({ apiCallers }) => {
+ const api = apiCallers[0].bookmarks;
+
+ // Create the bookmark
+ const bookmark = await api.createBookmark({
+ url: "https://google.com",
+ type: "link",
+ });
+
+ await api.updateBookmark({
+ bookmarkId: bookmark.id,
+ archived: true,
+ favourited: true,
+ });
+
+ const res = await api.getBookmark({ bookmarkId: bookmark.id });
+ expect(res.archived).toBeTruthy();
+ expect(res.favourited).toBeTruthy();
+ });
+
+ test<CustomTestContext>("list bookmarks", async ({ apiCallers }) => {
+ const api = apiCallers[0].bookmarks;
+ const emptyBookmarks = await api.getBookmarks({});
+ expect(emptyBookmarks.bookmarks.length).toEqual(0);
+
+ const bookmark1 = await api.createBookmark({
+ url: "https://google.com",
+ type: "link",
+ });
+
+ const bookmark2 = await api.createBookmark({
+ url: "https://google2.com",
+ type: "link",
+ });
+
+ {
+ const bookmarks = await api.getBookmarks({});
+ expect(bookmarks.bookmarks.length).toEqual(2);
+ }
+
+ // Archive and favourite bookmark1
+ await api.updateBookmark({
+ bookmarkId: bookmark1.id,
+ archived: true,
+ favourited: true,
+ });
+
+ {
+ const bookmarks = await api.getBookmarks({ archived: false });
+ expect(bookmarks.bookmarks.length).toEqual(1);
+ expect(bookmarks.bookmarks[0].id).toEqual(bookmark2.id);
+ }
+
+ {
+ const bookmarks = await api.getBookmarks({ favourited: true });
+ expect(bookmarks.bookmarks.length).toEqual(1);
+ expect(bookmarks.bookmarks[0].id).toEqual(bookmark1.id);
+ }
+
+ {
+ const bookmarks = await api.getBookmarks({ archived: true });
+ expect(bookmarks.bookmarks.length).toEqual(1);
+ expect(bookmarks.bookmarks[0].id).toEqual(bookmark1.id);
+ }
+
+ {
+ const bookmarks = await api.getBookmarks({ ids: [bookmark1.id] });
+ expect(bookmarks.bookmarks.length).toEqual(1);
+ expect(bookmarks.bookmarks[0].id).toEqual(bookmark1.id);
+ }
+ });
+
+ test<CustomTestContext>("update tags", async ({ apiCallers }) => {
+ const api = apiCallers[0].bookmarks;
+ let bookmark = await api.createBookmark({
+ url: "https://google.com",
+ type: "link",
+ });
+
+ await api.updateTags({
+ bookmarkId: bookmark.id,
+ attach: [{ tag: "tag1" }, { tag: "tag2" }],
+ detach: [],
+ });
+
+ bookmark = await api.getBookmark({ bookmarkId: bookmark.id });
+ expect(bookmark.tags.map((t) => t.name).sort()).toEqual(["tag1", "tag2"]);
+
+ const tag1Id = bookmark.tags.filter((t) => t.name == "tag1")[0].id;
+
+ await api.updateTags({
+ bookmarkId: bookmark.id,
+ attach: [{ tag: "tag3" }],
+ detach: [{ tagId: tag1Id }],
+ });
+
+ bookmark = await api.getBookmark({ bookmarkId: bookmark.id });
+ expect(bookmark.tags.map((t) => t.name).sort()).toEqual(["tag2", "tag3"]);
+ });
+
+ test<CustomTestContext>("update bookmark text", async ({ apiCallers }) => {
+ const api = apiCallers[0].bookmarks;
+ let bookmark = await api.createBookmark({
+ text: "HELLO WORLD",
+ type: "text",
+ });
+
+ await api.updateBookmarkText({
+ bookmarkId: bookmark.id,
+ text: "WORLD HELLO",
+ });
+
+ bookmark = await api.getBookmark({ bookmarkId: bookmark.id });
+ assert(bookmark.content.type == "text");
+ expect(bookmark.content.text).toEqual("WORLD HELLO");
+ });
+
+ test<CustomTestContext>("privacy", async ({ apiCallers }) => {
+ const user1Bookmark = await apiCallers[0].bookmarks.createBookmark({
+ type: "link",
+ url: "https://google.com",
+ });
+ const user2Bookmark = await apiCallers[1].bookmarks.createBookmark({
+ type: "link",
+ url: "https://google.com",
+ });
+
+ // All interactions with the wrong user should fail
+ await expect(() =>
+ apiCallers[0].bookmarks.deleteBookmark({ bookmarkId: user2Bookmark.id }),
+ ).rejects.toThrow(/User is not allowed to access resource/);
+ await expect(() =>
+ apiCallers[0].bookmarks.getBookmark({ bookmarkId: user2Bookmark.id }),
+ ).rejects.toThrow(/User is not allowed to access resource/);
+ await expect(() =>
+ apiCallers[0].bookmarks.updateBookmark({ bookmarkId: user2Bookmark.id }),
+ ).rejects.toThrow(/User is not allowed to access resource/);
+ await expect(() =>
+ apiCallers[0].bookmarks.updateTags({
+ bookmarkId: user2Bookmark.id,
+ attach: [],
+ detach: [],
+ }),
+ ).rejects.toThrow(/User is not allowed to access resource/);
+
+ // Get bookmarks should only show the correct one
+ expect(
+ (await apiCallers[0].bookmarks.getBookmarks({})).bookmarks.map(
+ (b) => b.id,
+ ),
+ ).toEqual([user1Bookmark.id]);
+ expect(
+ (await apiCallers[1].bookmarks.getBookmarks({})).bookmarks.map(
+ (b) => b.id,
+ ),
+ ).toEqual([user2Bookmark.id]);
+ });
+});
diff --git a/packages/trpc/routers/bookmarks.ts b/packages/trpc/routers/bookmarks.ts
new file mode 100644
index 00000000..ea7ffef8
--- /dev/null
+++ b/packages/trpc/routers/bookmarks.ts
@@ -0,0 +1,454 @@
+import { z } from "zod";
+import { Context, authedProcedure, router } from "../index";
+import { getSearchIdxClient } from "@hoarder/shared/search";
+import {
+ ZBookmark,
+ ZBookmarkContent,
+ zBareBookmarkSchema,
+ zBookmarkSchema,
+ zGetBookmarksRequestSchema,
+ zGetBookmarksResponseSchema,
+ zNewBookmarkRequestSchema,
+ zUpdateBookmarksRequestSchema,
+} from "../types/bookmarks";
+import {
+ bookmarkLinks,
+ bookmarkTags,
+ bookmarkTexts,
+ bookmarks,
+ tagsOnBookmarks,
+} from "@hoarder/db/schema";
+import {
+ LinkCrawlerQueue,
+ OpenAIQueue,
+ SearchIndexingQueue,
+} from "@hoarder/shared/queues";
+import { TRPCError, experimental_trpcMiddleware } from "@trpc/server";
+import { and, desc, eq, inArray } from "drizzle-orm";
+import { ZBookmarkTags } from "../types/tags";
+
+import { db as DONT_USE_db } from "@hoarder/db";
+
+const ensureBookmarkOwnership = experimental_trpcMiddleware<{
+ ctx: Context;
+ input: { bookmarkId: string };
+}>().create(async (opts) => {
+ const bookmark = await opts.ctx.db.query.bookmarks.findFirst({
+ where: eq(bookmarks.id, opts.input.bookmarkId),
+ columns: {
+ userId: true,
+ },
+ });
+ if (!opts.ctx.user) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "User is not authorized",
+ });
+ }
+ if (!bookmark) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Bookmark not found",
+ });
+ }
+ if (bookmark.userId != opts.ctx.user.id) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "User is not allowed to access resource",
+ });
+ }
+
+ return opts.next();
+});
+
+async function dummyDrizzleReturnType() {
+ const x = await DONT_USE_db.query.bookmarks.findFirst({
+ with: {
+ tagsOnBookmarks: {
+ with: {
+ tag: true,
+ },
+ },
+ link: true,
+ text: true,
+ },
+ });
+ if (!x) {
+ throw new Error();
+ }
+ return x;
+}
+
+function toZodSchema(
+ bookmark: Awaited<ReturnType<typeof dummyDrizzleReturnType>>,
+): ZBookmark {
+ const { tagsOnBookmarks, link, text, ...rest } = bookmark;
+
+ let content: ZBookmarkContent;
+ if (link) {
+ content = { type: "link", ...link };
+ } else if (text) {
+ content = { type: "text", text: text.text || "" };
+ } else {
+ throw new Error("Unknown content type");
+ }
+
+ return {
+ tags: tagsOnBookmarks.map((t) => ({
+ attachedBy: t.attachedBy,
+ ...t.tag,
+ })),
+ content,
+ ...rest,
+ };
+}
+
+export const bookmarksAppRouter = router({
+ createBookmark: authedProcedure
+ .input(zNewBookmarkRequestSchema)
+ .output(zBookmarkSchema)
+ .mutation(async ({ input, ctx }) => {
+ const bookmark = await ctx.db.transaction(
+ async (tx): Promise<ZBookmark> => {
+ const bookmark = (
+ await tx
+ .insert(bookmarks)
+ .values({
+ userId: ctx.user.id,
+ })
+ .returning()
+ )[0];
+
+ let content: ZBookmarkContent;
+
+ switch (input.type) {
+ case "link": {
+ const link = (
+ await tx
+ .insert(bookmarkLinks)
+ .values({
+ id: bookmark.id,
+ url: input.url,
+ })
+ .returning()
+ )[0];
+ content = {
+ type: "link",
+ ...link,
+ };
+ break;
+ }
+ case "text": {
+ const text = (
+ await tx
+ .insert(bookmarkTexts)
+ .values({ id: bookmark.id, text: input.text })
+ .returning()
+ )[0];
+ content = {
+ type: "text",
+ text: text.text || "",
+ };
+ break;
+ }
+ }
+
+ return {
+ tags: [] as ZBookmarkTags[],
+ content,
+ ...bookmark,
+ };
+ },
+ );
+
+ // Enqueue crawling request
+ switch (bookmark.content.type) {
+ case "link": {
+ // The crawling job triggers openai when it's done
+ await LinkCrawlerQueue.add("crawl", {
+ bookmarkId: bookmark.id,
+ });
+ break;
+ }
+ case "text": {
+ await OpenAIQueue.add("openai", {
+ bookmarkId: bookmark.id,
+ });
+ break;
+ }
+ }
+ SearchIndexingQueue.add("search_indexing", {
+ bookmarkId: bookmark.id,
+ type: "index",
+ });
+ return bookmark;
+ }),
+
+ updateBookmark: authedProcedure
+ .input(zUpdateBookmarksRequestSchema)
+ .output(zBareBookmarkSchema)
+ .use(ensureBookmarkOwnership)
+ .mutation(async ({ input, ctx }) => {
+ const res = await ctx.db
+ .update(bookmarks)
+ .set({
+ archived: input.archived,
+ favourited: input.favourited,
+ })
+ .where(
+ and(
+ eq(bookmarks.userId, ctx.user.id),
+ eq(bookmarks.id, input.bookmarkId),
+ ),
+ )
+ .returning();
+ if (res.length == 0) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Bookmark not found",
+ });
+ }
+ return res[0];
+ }),
+
+ updateBookmarkText: authedProcedure
+ .input(
+ z.object({
+ bookmarkId: z.string(),
+ text: z.string().max(2000),
+ }),
+ )
+ .use(ensureBookmarkOwnership)
+ .mutation(async ({ input, ctx }) => {
+ const res = await ctx.db
+ .update(bookmarkTexts)
+ .set({
+ text: input.text,
+ })
+ .where(and(eq(bookmarkTexts.id, input.bookmarkId)))
+ .returning();
+ if (res.length == 0) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Bookmark not found",
+ });
+ }
+ SearchIndexingQueue.add("search_indexing", {
+ bookmarkId: input.bookmarkId,
+ type: "index",
+ });
+ }),
+
+ deleteBookmark: authedProcedure
+ .input(z.object({ bookmarkId: z.string() }))
+ .use(ensureBookmarkOwnership)
+ .mutation(async ({ input, ctx }) => {
+ await ctx.db
+ .delete(bookmarks)
+ .where(
+ and(
+ eq(bookmarks.userId, ctx.user.id),
+ eq(bookmarks.id, input.bookmarkId),
+ ),
+ );
+ SearchIndexingQueue.add("search_indexing", {
+ bookmarkId: input.bookmarkId,
+ type: "delete",
+ });
+ }),
+ recrawlBookmark: authedProcedure
+ .input(z.object({ bookmarkId: z.string() }))
+ .use(ensureBookmarkOwnership)
+ .mutation(async ({ input }) => {
+ await LinkCrawlerQueue.add("crawl", {
+ bookmarkId: input.bookmarkId,
+ });
+ }),
+ getBookmark: authedProcedure
+ .input(
+ z.object({
+ bookmarkId: z.string(),
+ }),
+ )
+ .output(zBookmarkSchema)
+ .use(ensureBookmarkOwnership)
+ .query(async ({ input, ctx }) => {
+ const bookmark = await ctx.db.query.bookmarks.findFirst({
+ where: and(
+ eq(bookmarks.userId, ctx.user.id),
+ eq(bookmarks.id, input.bookmarkId),
+ ),
+ with: {
+ tagsOnBookmarks: {
+ with: {
+ tag: true,
+ },
+ },
+ link: true,
+ text: true,
+ },
+ });
+ if (!bookmark) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Bookmark not found",
+ });
+ }
+
+ return toZodSchema(bookmark);
+ }),
+ searchBookmarks: authedProcedure
+ .input(
+ z.object({
+ text: z.string(),
+ }),
+ )
+ .output(zGetBookmarksResponseSchema)
+ .query(async ({ input, ctx }) => {
+ const client = await getSearchIdxClient();
+ if (!client) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Search functionality is not configured",
+ });
+ }
+ const resp = await client.search(input.text, {
+ filter: [`userId = '${ctx.user.id}'`],
+ });
+
+ if (resp.hits.length == 0) {
+ return { bookmarks: [] };
+ }
+ const results = await ctx.db.query.bookmarks.findMany({
+ where: and(
+ eq(bookmarks.userId, ctx.user.id),
+ inArray(
+ bookmarks.id,
+ resp.hits.map((h) => h.id),
+ ),
+ ),
+ with: {
+ tagsOnBookmarks: {
+ with: {
+ tag: true,
+ },
+ },
+ link: true,
+ text: true,
+ },
+ });
+
+ return { bookmarks: results.map(toZodSchema) };
+ }),
+ getBookmarks: authedProcedure
+ .input(zGetBookmarksRequestSchema)
+ .output(zGetBookmarksResponseSchema)
+ .query(async ({ input, ctx }) => {
+ if (input.ids && input.ids.length == 0) {
+ return { bookmarks: [] };
+ }
+ const results = await ctx.db.query.bookmarks.findMany({
+ where: and(
+ eq(bookmarks.userId, ctx.user.id),
+ input.archived !== undefined
+ ? eq(bookmarks.archived, input.archived)
+ : undefined,
+ input.favourited !== undefined
+ ? eq(bookmarks.favourited, input.favourited)
+ : undefined,
+ input.ids ? inArray(bookmarks.id, input.ids) : undefined,
+ ),
+ orderBy: [desc(bookmarks.createdAt)],
+ with: {
+ tagsOnBookmarks: {
+ with: {
+ tag: true,
+ },
+ },
+ link: true,
+ text: true,
+ },
+ });
+
+ return { bookmarks: results.map(toZodSchema) };
+ }),
+
+ updateTags: authedProcedure
+ .input(
+ z.object({
+ bookmarkId: z.string(),
+ attach: z.array(
+ z.object({
+ tagId: z.string().optional(), // If the tag already exists and we know its id
+ tag: z.string(),
+ }),
+ ),
+ // Detach by tag ids
+ detach: z.array(z.object({ tagId: z.string() })),
+ }),
+ )
+ .use(ensureBookmarkOwnership)
+ .mutation(async ({ input, ctx }) => {
+ await ctx.db.transaction(async (tx) => {
+ // Detaches
+ if (input.detach.length > 0) {
+ await tx.delete(tagsOnBookmarks).where(
+ and(
+ eq(tagsOnBookmarks.bookmarkId, input.bookmarkId),
+ inArray(
+ tagsOnBookmarks.tagId,
+ input.detach.map((t) => t.tagId),
+ ),
+ ),
+ );
+ }
+
+ if (input.attach.length == 0) {
+ return;
+ }
+
+ // New Tags
+ const toBeCreatedTags = input.attach
+ .filter((i) => i.tagId === undefined)
+ .map((i) => ({
+ name: i.tag,
+ userId: ctx.user.id,
+ }));
+
+ if (toBeCreatedTags.length > 0) {
+ await tx
+ .insert(bookmarkTags)
+ .values(toBeCreatedTags)
+ .onConflictDoNothing()
+ .returning();
+ }
+
+ const allIds = (
+ await tx.query.bookmarkTags.findMany({
+ where: and(
+ eq(bookmarkTags.userId, ctx.user.id),
+ inArray(
+ bookmarkTags.name,
+ input.attach.map((t) => t.tag),
+ ),
+ ),
+ columns: {
+ id: true,
+ },
+ })
+ ).map((t) => t.id);
+
+ await tx
+ .insert(tagsOnBookmarks)
+ .values(
+ allIds.map((i) => ({
+ tagId: i as string,
+ bookmarkId: input.bookmarkId,
+ attachedBy: "human" as const,
+ userId: ctx.user.id,
+ })),
+ )
+ .onConflictDoNothing();
+ });
+ }),
+});
diff --git a/packages/trpc/routers/lists.ts b/packages/trpc/routers/lists.ts
new file mode 100644
index 00000000..fa97929d
--- /dev/null
+++ b/packages/trpc/routers/lists.ts
@@ -0,0 +1,173 @@
+import { Context, authedProcedure, router } from "../index";
+import { SqliteError } from "@hoarder/db";
+import { z } from "zod";
+import { TRPCError, experimental_trpcMiddleware } from "@trpc/server";
+import { bookmarkLists, bookmarksInLists } from "@hoarder/db/schema";
+import { and, eq } from "drizzle-orm";
+import { zBookmarkListSchema } from "../types/lists";
+
+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(
+ z.object({
+ name: z.string().min(1).max(20),
+ icon: z.string(),
+ }),
+ )
+ .output(zBookmarkListSchema)
+ .mutation(async ({ input, ctx }) => {
+ try {
+ const result = await ctx.db
+ .insert(bookmarkLists)
+ .values({
+ name: input.name,
+ icon: input.icon,
+ userId: ctx.user.id,
+ })
+ .returning();
+ return result[0];
+ } catch (e) {
+ if (e instanceof SqliteError) {
+ if (e.code == "SQLITE_CONSTRAINT_UNIQUE") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "List already exists",
+ });
+ }
+ }
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Something went wrong",
+ });
+ }
+ }),
+ 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)
+ .mutation(async ({ input, ctx }) => {
+ 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 already in the list",
+ });
+ }
+ }
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Something went wrong",
+ });
+ }
+ }),
+ get: authedProcedure
+ .input(
+ z.object({
+ listId: z.string(),
+ }),
+ )
+ .output(
+ zBookmarkListSchema.merge(
+ z.object({
+ bookmarks: z.array(z.string()),
+ }),
+ ),
+ )
+ .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),
+ ),
+ with: {
+ bookmarksInLists: true,
+ },
+ });
+ if (!res) {
+ throw new TRPCError({ code: "NOT_FOUND" });
+ }
+
+ return {
+ id: res.id,
+ name: res.name,
+ icon: res.icon,
+ bookmarks: res.bookmarksInLists.map((b) => b.bookmarkId),
+ };
+ }),
+ 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 };
+ }),
+});
diff --git a/packages/trpc/routers/users.test.ts b/packages/trpc/routers/users.test.ts
new file mode 100644
index 00000000..87814407
--- /dev/null
+++ b/packages/trpc/routers/users.test.ts
@@ -0,0 +1,99 @@
+import {
+ CustomTestContext,
+ defaultBeforeEach,
+ getApiCaller,
+} from "../testUtils";
+import { expect, describe, test, beforeEach, assert } from "vitest";
+
+beforeEach<CustomTestContext>(defaultBeforeEach(false));
+
+describe("User Routes", () => {
+ test<CustomTestContext>("create user", async ({ unauthedAPICaller }) => {
+ const user = await unauthedAPICaller.users.create({
+ name: "Test User",
+ email: "test123@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+
+ expect(user.name).toEqual("Test User");
+ expect(user.email).toEqual("test123@test.com");
+ });
+
+ test<CustomTestContext>("first user is admin", async ({
+ unauthedAPICaller,
+ }) => {
+ const user1 = await unauthedAPICaller.users.create({
+ name: "Test User",
+ email: "test123@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+
+ const user2 = await unauthedAPICaller.users.create({
+ name: "Test User",
+ email: "test124@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+
+ expect(user1.role).toEqual("admin");
+ expect(user2.role).toEqual("user");
+ });
+
+ test<CustomTestContext>("unique emails", async ({ unauthedAPICaller }) => {
+ await unauthedAPICaller.users.create({
+ name: "Test User",
+ email: "test123@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+
+ await expect(() =>
+ unauthedAPICaller.users.create({
+ name: "Test User",
+ email: "test123@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ }),
+ ).rejects.toThrow(/Email is already taken/);
+ });
+
+ test<CustomTestContext>("privacy checks", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ const adminUser = await unauthedAPICaller.users.create({
+ name: "Test User",
+ email: "test123@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+ const [user1, user2] = await Promise.all(
+ ["test1234@test.com", "test12345@test.com"].map((e) =>
+ unauthedAPICaller.users.create({
+ name: "Test User",
+ email: e,
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ }),
+ ),
+ );
+
+ assert(adminUser.role == "admin");
+ assert(user1.role == "user");
+ assert(user2.role == "user");
+
+ const user2Caller = getApiCaller(db, user2.id);
+
+ // A normal user can't delete other users
+ await expect(() =>
+ user2Caller.users.delete({
+ userId: user1.id,
+ }),
+ ).rejects.toThrow(/FORBIDDEN/);
+
+ // A normal user can't list all users
+ await expect(() => user2Caller.users.list()).rejects.toThrow(/FORBIDDEN/);
+ });
+});
diff --git a/packages/trpc/routers/users.ts b/packages/trpc/routers/users.ts
new file mode 100644
index 00000000..b5334f99
--- /dev/null
+++ b/packages/trpc/routers/users.ts
@@ -0,0 +1,93 @@
+import { zSignUpSchema } from "../types/users";
+import { adminProcedure, publicProcedure, router } from "../index";
+import { SqliteError } from "@hoarder/db";
+import { z } from "zod";
+import { hashPassword } from "../auth";
+import { TRPCError } from "@trpc/server";
+import { users } from "@hoarder/db/schema";
+import { count, eq } from "drizzle-orm";
+
+export const usersAppRouter = router({
+ create: publicProcedure
+ .input(zSignUpSchema)
+ .output(
+ z.object({
+ id: z.string(),
+ name: z.string(),
+ email: z.string(),
+ role: z.enum(["user", "admin"]).nullable(),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ // TODO: This is racy, but that's probably fine.
+ const [{ count: userCount }] = await ctx.db
+ .select({ count: count() })
+ .from(users);
+ try {
+ const result = await ctx.db
+ .insert(users)
+ .values({
+ name: input.name,
+ email: input.email,
+ password: await hashPassword(input.password),
+ role: userCount == 0 ? "admin" : "user",
+ })
+ .returning({
+ id: users.id,
+ name: users.name,
+ email: users.email,
+ role: users.role,
+ });
+ return result[0];
+ } catch (e) {
+ if (e instanceof SqliteError) {
+ if (e.code == "SQLITE_CONSTRAINT_UNIQUE") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Email is already taken",
+ });
+ }
+ }
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Something went wrong",
+ });
+ }
+ }),
+ list: adminProcedure
+ .output(
+ z.object({
+ users: z.array(
+ z.object({
+ id: z.string(),
+ name: z.string(),
+ email: z.string(),
+ role: z.enum(["user", "admin"]).nullable(),
+ }),
+ ),
+ }),
+ )
+ .query(async ({ ctx }) => {
+ const users = await ctx.db.query.users.findMany({
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ role: true,
+ },
+ });
+ return { users };
+ }),
+ delete: adminProcedure
+ .input(
+ z.object({
+ userId: z.string(),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ const res = await ctx.db.delete(users).where(eq(users.id, input.userId));
+ if (res.changes == 0) {
+ throw new TRPCError({ code: "NOT_FOUND" });
+ }
+ }),
+});