aboutsummaryrefslogtreecommitdiffstats
path: root/packages/trpc
diff options
context:
space:
mode:
authorMohamedBassem <me@mbassem.com>2024-05-06 18:05:27 +0100
committerMohamedBassem <me@mbassem.com>2024-05-06 18:05:27 +0100
commit32b5a025568dcc5788a8a2afc19bf07264e01a63 (patch)
tree7ad808c667148154c9244cb3def56315da89ef52 /packages/trpc
parent02ef4bfc89e66fdf6593dd744aef53adee57b861 (diff)
downloadkarakeep-32b5a025568dcc5788a8a2afc19bf07264e01a63.tar.zst
feature: Dedup links on creation. Fixes #49
Diffstat (limited to 'packages/trpc')
-rw-r--r--packages/trpc/index.ts5
-rw-r--r--packages/trpc/routers/bookmarks.test.ts12
-rw-r--r--packages/trpc/routers/bookmarks.ts218
3 files changed, 134 insertions, 101 deletions
diff --git a/packages/trpc/index.ts b/packages/trpc/index.ts
index 4055fa5d..5f351a8e 100644
--- a/packages/trpc/index.ts
+++ b/packages/trpc/index.ts
@@ -17,6 +17,11 @@ export interface Context {
db: typeof db;
}
+export interface AuthedContext {
+ user: User;
+ db: typeof db;
+}
+
// Avoid exporting the entire t-object
// since it's not very descriptive.
// For instance, the use of a t variable
diff --git a/packages/trpc/routers/bookmarks.test.ts b/packages/trpc/routers/bookmarks.test.ts
index 603c18fd..1bcd3279 100644
--- a/packages/trpc/routers/bookmarks.test.ts
+++ b/packages/trpc/routers/bookmarks.test.ts
@@ -116,18 +116,18 @@ describe("Bookmark Routes", () => {
test<CustomTestContext>("update tags", async ({ apiCallers }) => {
const api = apiCallers[0].bookmarks;
- let bookmark = await api.createBookmark({
+ const createdBookmark = await api.createBookmark({
url: "https://google.com",
type: "link",
});
await api.updateTags({
- bookmarkId: bookmark.id,
+ bookmarkId: createdBookmark.id,
attach: [{ tagName: "tag1" }, { tagName: "tag2" }],
detach: [],
});
- bookmark = await api.getBookmark({ bookmarkId: bookmark.id });
+ let bookmark = await api.getBookmark({ bookmarkId: createdBookmark.id });
expect(bookmark.tags.map((t) => t.name).sort()).toEqual(["tag1", "tag2"]);
const tag1Id = bookmark.tags.filter((t) => t.name == "tag1")[0].id;
@@ -157,17 +157,17 @@ describe("Bookmark Routes", () => {
test<CustomTestContext>("update bookmark text", async ({ apiCallers }) => {
const api = apiCallers[0].bookmarks;
- let bookmark = await api.createBookmark({
+ const createdBookmark = await api.createBookmark({
text: "HELLO WORLD",
type: "text",
});
await api.updateBookmarkText({
- bookmarkId: bookmark.id,
+ bookmarkId: createdBookmark.id,
text: "WORLD HELLO",
});
- bookmark = await api.getBookmark({ bookmarkId: bookmark.id });
+ const bookmark = await api.getBookmark({ bookmarkId: createdBookmark.id });
assert(bookmark.content.type == "text");
expect(bookmark.content.text).toEqual("WORLD HELLO");
});
diff --git a/packages/trpc/routers/bookmarks.ts b/packages/trpc/routers/bookmarks.ts
index 1e154e7b..a7db564b 100644
--- a/packages/trpc/routers/bookmarks.ts
+++ b/packages/trpc/routers/bookmarks.ts
@@ -35,7 +35,7 @@ import {
zUpdateBookmarksRequestSchema,
} from "@hoarder/shared/types/bookmarks";
-import type { Context } from "../index";
+import type { AuthedContext, Context } from "../index";
import { authedProcedure, router } from "../index";
export const ensureBookmarkOwnership = experimental_trpcMiddleware<{
@@ -70,6 +70,45 @@ export const ensureBookmarkOwnership = experimental_trpcMiddleware<{
return opts.next();
});
+async function getBookmark(ctx: AuthedContext, bookmarkId: string) {
+ const bookmark = await ctx.db.query.bookmarks.findFirst({
+ where: and(eq(bookmarks.userId, ctx.user.id), eq(bookmarks.id, bookmarkId)),
+ with: {
+ tagsOnBookmarks: {
+ with: {
+ tag: true,
+ },
+ },
+ link: true,
+ text: true,
+ asset: true,
+ },
+ });
+ if (!bookmark) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Bookmark not found",
+ });
+ }
+
+ return toZodSchema(bookmark);
+}
+
+async function attemptToDedupLink(ctx: AuthedContext, url: string) {
+ const result = await ctx.db
+ .select({
+ id: bookmarkLinks.id,
+ })
+ .from(bookmarkLinks)
+ .leftJoin(bookmarks, eq(bookmarks.id, bookmarkLinks.id))
+ .where(and(eq(bookmarkLinks.url, url), eq(bookmarks.userId, ctx.user.id)));
+
+ if (result.length == 0) {
+ return null;
+ }
+ return getBookmark(ctx, result[0].id);
+}
+
async function dummyDrizzleReturnType() {
const x = await DONT_USE_db.query.bookmarks.findFirst({
with: {
@@ -147,82 +186,94 @@ function toZodSchema(bookmark: BookmarkQueryReturnType): ZBookmark {
export const bookmarksAppRouter = router({
createBookmark: authedProcedure
.input(zNewBookmarkRequestSchema)
- .output(zBookmarkSchema)
+ .output(
+ zBookmarkSchema.merge(
+ z.object({
+ alreadyExists: z.boolean().optional().default(false),
+ }),
+ ),
+ )
.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.trim(),
- })
- .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;
- }
- case "asset": {
- const [asset] = await tx
- .insert(bookmarkAssets)
+ if (input.type == "link") {
+ // This doesn't 100% protect from duplicates because of races but it's more than enough for this usecase.
+ const alreadyExists = await attemptToDedupLink(ctx, input.url);
+ if (alreadyExists) {
+ return { ...alreadyExists, alreadyExists: true };
+ }
+ }
+ const bookmark = await ctx.db.transaction(async (tx) => {
+ 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,
- assetType: input.assetType,
- assetId: input.assetId,
- content: null,
- metadata: null,
- fileName: input.fileName ?? null,
+ url: input.url.trim(),
})
- .returning();
- content = {
- type: "asset",
- assetType: asset.assetType,
- assetId: asset.assetId,
- };
- break;
- }
- case "unknown": {
- throw new TRPCError({ code: "BAD_REQUEST" });
- }
+ .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;
+ }
+ case "asset": {
+ const [asset] = await tx
+ .insert(bookmarkAssets)
+ .values({
+ id: bookmark.id,
+ assetType: input.assetType,
+ assetId: input.assetId,
+ content: null,
+ metadata: null,
+ fileName: input.fileName ?? null,
+ })
+ .returning();
+ content = {
+ type: "asset",
+ assetType: asset.assetType,
+ assetId: asset.assetId,
+ };
+ break;
+ }
+ case "unknown": {
+ throw new TRPCError({ code: "BAD_REQUEST" });
}
+ }
- return {
- tags: [] as ZBookmarkTags[],
- content,
- ...bookmark,
- };
- },
- );
+ return {
+ alreadyExists: false,
+ tags: [] as ZBookmarkTags[],
+ content,
+ ...bookmark,
+ };
+ });
// Enqueue crawling request
switch (bookmark.content.type) {
@@ -360,30 +411,7 @@ export const bookmarksAppRouter = router({
.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,
- asset: true,
- },
- });
- if (!bookmark) {
- throw new TRPCError({
- code: "NOT_FOUND",
- message: "Bookmark not found",
- });
- }
-
- return toZodSchema(bookmark);
+ return await getBookmark(ctx, input.bookmarkId);
}),
searchBookmarks: authedProcedure
.input(