diff options
Diffstat (limited to 'packages/trpc/routers')
| -rw-r--r-- | packages/trpc/routers/_app.ts | 15 | ||||
| -rw-r--r-- | packages/trpc/routers/admin.ts | 77 | ||||
| -rw-r--r-- | packages/trpc/routers/apiKeys.ts | 61 | ||||
| -rw-r--r-- | packages/trpc/routers/bookmarks.test.ts | 200 | ||||
| -rw-r--r-- | packages/trpc/routers/bookmarks.ts | 454 | ||||
| -rw-r--r-- | packages/trpc/routers/lists.ts | 173 | ||||
| -rw-r--r-- | packages/trpc/routers/users.test.ts | 99 | ||||
| -rw-r--r-- | packages/trpc/routers/users.ts | 93 |
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" }); + } + }), +}); |
