From 88c73e212c4510ce41ad8c6557fa7d5c8f72d199 Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Mon, 17 Nov 2025 01:12:41 +0000 Subject: feat: Add collaborative lists (#2146) * feat: Add collaborative lists backend implementation This commit implements the core backend functionality for collaborative lists, allowing multiple users to share and interact with bookmark lists. Database changes: - Add listCollaborators table to track users with access to lists and their roles (viewer/editor) - Add addedBy field to bookmarksInLists to track who added bookmarks - Add relations for collaborative list functionality Access control updates: - Update List model to support role-based access (owner/editor/viewer) - Add methods to check and enforce permissions for list operations - Update Bookmark model to allow access through collaborative lists - Modify bookmark queries to include bookmarks from collaborative lists List collaboration features: - Add/remove/update collaborators - Get list of collaborators - Get lists shared with current user - Only manual lists can have collaborators tRPC procedures: - addCollaborator: Add a user as a collaborator to a list - removeCollaborator: Remove a collaborator from a list - updateCollaboratorRole: Change a collaborator's role - getCollaborators: Get all collaborators for a list - getSharedWithMe: Get all lists shared with the current user - cloneBookmark: Clone a bookmark to the current user's collection Implementation notes: - Editors can add/remove bookmarks from the list (must own the bookmark) - Viewers can only view bookmarks in the list - Only the list owner can manage collaborators and list metadata - Smart lists cannot have collaborators (only manual lists) - Users cannot edit bookmarks they don't own, even in shared lists * feat: Add collaborative lists frontend UI This commit implements the frontend user interface for collaborative lists, allowing users to view shared bookmarks and manage list collaborators. New pages: - /dashboard/shared: Shows bookmarks from lists shared with the user - Displays bookmarks from all collaborative lists - Uses SharedBookmarks component - Shows empty state when no lists are shared Navigation: - Added "Shared with you" link to sidebar with Users icon - Positioned after "Home" in main navigation - Available in both desktop and mobile sidebar Collaborator management: - ManageCollaboratorsModal component for managing list collaborators - Add collaborators by user ID with viewer/editor role - View current collaborators with their roles - Update collaborator roles inline - Remove collaborators - Shows empty state when no collaborators - Integrated into ListOptions dropdown menu - Accessible via "Manage Collaborators" menu item Components created: - SharedBookmarks.tsx: Server component fetching shared lists/bookmarks - ManageCollaboratorsModal.tsx: Client component with tRPC mutations - /dashboard/shared/page.tsx: Route for shared bookmarks page UI features: - Role selector for viewer/editor permissions - Real-time collaborator list updates - Toast notifications for success/error states - Loading states for async operations - Responsive design matching existing UI patterns Implementation notes: - Uses existing tRPC endpoints (getSharedWithMe, getCollaborators, etc.) - Follows established modal patterns from ShareListModal - Integrates seamlessly with existing list UI - Currently uses user ID for adding collaborators (email lookup TBD) * fix typecheck * add collaborator by email * add shared list in the sidebar * fix perm issue * hide UI components from non list owners * list leaving * fix shared bookmarks showing up in homepage * fix getBookmark access check * e2e tests * hide user specific fields from shared lists * simplify bookmark perm checks * disable editable fields in bookmark preview * hide lists if they don't have options * fix list ownership * fix highlights * move tests to trpc * fix alignment of leave list * make tag lists unclickable * allow editors to remove from list * add a badge for shared lists * remove bookmarks of user when they're removed from a list * fix tests * show owner in the manage collab modal * fix hasCollab * drop shared with you * i18n * beta badge * correctly invalidate caches on collab change * reduce unnecessary changes * Add ratelimits * stop manually removing bookmarks on remove * some fixes * fixes * remove unused function * improve tests --------- Co-authored-by: Claude --- packages/trpc/routers/bookmarks.ts | 298 +++++++------------------------------ 1 file changed, 52 insertions(+), 246 deletions(-) (limited to 'packages/trpc/routers/bookmarks.ts') diff --git a/packages/trpc/routers/bookmarks.ts b/packages/trpc/routers/bookmarks.ts index 72c6c1d1..389f026c 100644 --- a/packages/trpc/routers/bookmarks.ts +++ b/packages/trpc/routers/bookmarks.ts @@ -3,12 +3,8 @@ import { and, eq, gt, inArray, lt, or } from "drizzle-orm"; import invariant from "tiny-invariant"; import { z } from "zod"; -import type { - ZBookmark, - ZBookmarkContent, -} from "@karakeep/shared/types/bookmarks"; +import type { ZBookmarkContent } from "@karakeep/shared/types/bookmarks"; import type { ZBookmarkTags } from "@karakeep/shared/types/tags"; -import { db as DONT_USE_db } from "@karakeep/db"; import { assets, AssetTypes, @@ -25,15 +21,11 @@ import { LinkCrawlerQueue, OpenAIQueue, QuotaService, - SearchIndexingQueue, triggerRuleEngineOnEvent, triggerSearchReindex, triggerWebhook, } from "@karakeep/shared-server"; -import { - deleteAsset, - SUPPORTED_BOOKMARK_ASSET_TYPES, -} from "@karakeep/shared/assetdb"; +import { SUPPORTED_BOOKMARK_ASSET_TYPES } from "@karakeep/shared/assetdb"; import serverConfig from "@karakeep/shared/config"; import { InferenceClientFactory } from "@karakeep/shared/inference"; import { buildSummaryPrompt } from "@karakeep/shared/prompts"; @@ -54,74 +46,48 @@ import { } from "@karakeep/shared/types/bookmarks"; import { normalizeTagName } from "@karakeep/shared/utils/tag"; -import type { AuthedContext, Context } from "../index"; +import type { AuthedContext } from "../index"; import { authedProcedure, createRateLimitMiddleware, router } from "../index"; -import { mapDBAssetTypeToUserType } from "../lib/attachments"; import { getBookmarkIdsFromMatcher } from "../lib/search"; -import { Bookmark } from "../models/bookmarks"; +import { BareBookmark, Bookmark } from "../models/bookmarks"; import { ImportSession } from "../models/importSessions"; import { ensureAssetOwnership } from "./assets"; export const ensureBookmarkOwnership = experimental_trpcMiddleware<{ - ctx: Context; + ctx: AuthedContext; 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, + const bookmark = await BareBookmark.bareFromId( + opts.ctx, + opts.input.bookmarkId, + ); + bookmark.ensureOwnership(); + + return opts.next({ + ctx: { + ...opts.ctx, + bookmark, }, }); - 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 getBookmark( - ctx: AuthedContext, - bookmarkId: string, - includeContent: boolean, -) { - 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, - assets: true, +export const ensureBookmarkAccess = experimental_trpcMiddleware<{ + ctx: AuthedContext; + input: { bookmarkId: string }; +}>().create(async (opts) => { + // Throws if bookmark doesn't exist or user doesn't have access + const bookmark = await BareBookmark.bareFromId( + opts.ctx, + opts.input.bookmarkId, + ); + + return opts.next({ + ctx: { + ...opts.ctx, + bookmark, }, }); - if (!bookmark) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Bookmark not found", - }); - } - - return await toZodSchema(bookmark, includeContent); -} +}); async function attemptToDedupLink(ctx: AuthedContext, url: string) { const result = await ctx.db @@ -135,128 +101,9 @@ async function attemptToDedupLink(ctx: AuthedContext, url: string) { if (result.length == 0) { return null; } - return getBookmark(ctx, result[0].id, /* includeContent: */ false); -} - -async function dummyDrizzleReturnType() { - const x = await DONT_USE_db.query.bookmarks.findFirst({ - with: { - tagsOnBookmarks: { - with: { - tag: true, - }, - }, - link: true, - text: true, - asset: true, - assets: true, - }, - }); - if (!x) { - throw new Error(); - } - return x; -} - -type BookmarkQueryReturnType = Awaited< - ReturnType ->; - -async function cleanupAssetForBookmark( - bookmark: Pick, -) { - const assetIds: Set = new Set( - bookmark.assets.map((a) => a.id), - ); - // Todo: Remove when the bookmark asset is also in the assets table - if (bookmark.asset) { - assetIds.add(bookmark.asset.assetId); - } - await Promise.all( - Array.from(assetIds).map((assetId) => - deleteAsset({ userId: bookmark.userId, assetId }), - ), - ); -} - -async function toZodSchema( - bookmark: BookmarkQueryReturnType, - includeContent: boolean, -): Promise { - const { tagsOnBookmarks, link, text, asset, assets, ...rest } = bookmark; - - let content: ZBookmarkContent = { - type: BookmarkTypes.UNKNOWN, - }; - if (bookmark.link) { - content = { - type: BookmarkTypes.LINK, - screenshotAssetId: assets.find( - (a) => a.assetType == AssetTypes.LINK_SCREENSHOT, - )?.id, - fullPageArchiveAssetId: assets.find( - (a) => a.assetType == AssetTypes.LINK_FULL_PAGE_ARCHIVE, - )?.id, - precrawledArchiveAssetId: assets.find( - (a) => a.assetType == AssetTypes.LINK_PRECRAWLED_ARCHIVE, - )?.id, - imageAssetId: assets.find( - (a) => a.assetType == AssetTypes.LINK_BANNER_IMAGE, - )?.id, - videoAssetId: assets.find((a) => a.assetType == AssetTypes.LINK_VIDEO) - ?.id, - url: link.url, - title: link.title, - description: link.description, - imageUrl: link.imageUrl, - favicon: link.favicon, - htmlContent: includeContent - ? await Bookmark.getBookmarkHtmlContent(link, bookmark.userId) - : null, - crawledAt: link.crawledAt, - author: link.author, - publisher: link.publisher, - datePublished: link.datePublished, - dateModified: link.dateModified, - }; - } - if (bookmark.text) { - content = { - type: BookmarkTypes.TEXT, - // It's ok to include the text content as it's usually not big and is used to render the text bookmark card. - text: text.text ?? "", - sourceUrl: text.sourceUrl, - }; - } - if (bookmark.asset) { - content = { - type: BookmarkTypes.ASSET, - assetType: asset.assetType, - assetId: asset.assetId, - fileName: asset.fileName, - sourceUrl: asset.sourceUrl, - size: assets.find((a) => a.id == asset.assetId)?.size, - content: includeContent ? asset.content : null, - }; - } - - return { - tags: tagsOnBookmarks - .map((t) => ({ - attachedBy: t.attachedBy, - ...t.tag, - })) - .sort((a, b) => - a.attachedBy === "ai" ? 1 : b.attachedBy === "ai" ? -1 : 0, - ), - content, - assets: assets.map((a) => ({ - id: a.id, - assetType: mapDBAssetTypeToUserType(a.assetType), - fileName: a.fileName, - })), - ...rest, - }; + return ( + await Bookmark.fromId(ctx, result[0].id, /* includeContent: */ false) + ).asZBookmark(); } export const bookmarksAppRouter = router({ @@ -620,11 +467,13 @@ export const bookmarksAppRouter = router({ }); // Refetch the updated bookmark data to return the full object - const updatedBookmark = await getBookmark( - ctx, - input.bookmarkId, - /* includeContent: */ false, - ); + const updatedBookmark = ( + await Bookmark.fromId( + ctx, + input.bookmarkId, + /* includeContent: */ false, + ) + ).asZBookmark(); if (input.favourited === true || input.archived === true) { await triggerRuleEngineOnEvent( @@ -686,37 +535,8 @@ export const bookmarksAppRouter = router({ .input(z.object({ bookmarkId: z.string() })) .use(ensureBookmarkOwnership) .mutation(async ({ input, ctx }) => { - const bookmark = await ctx.db.query.bookmarks.findFirst({ - where: and( - eq(bookmarks.id, input.bookmarkId), - eq(bookmarks.userId, ctx.user.id), - ), - with: { - asset: true, - link: true, - assets: true, - }, - }); - const deleted = await ctx.db - .delete(bookmarks) - .where( - and( - eq(bookmarks.userId, ctx.user.id), - eq(bookmarks.id, input.bookmarkId), - ), - ); - await SearchIndexingQueue.enqueue({ - bookmarkId: input.bookmarkId, - type: "delete", - }); - await triggerWebhook(input.bookmarkId, "deleted", ctx.user.id); - if (deleted.changes > 0 && bookmark) { - await cleanupAssetForBookmark({ - asset: bookmark.asset, - userId: ctx.user.id, - assets: bookmark.assets, - }); - } + const bookmark = await Bookmark.fromId(ctx, input.bookmarkId, false); + await bookmark.delete(); }), recrawlBookmark: authedProcedure .use( @@ -754,9 +574,11 @@ export const bookmarksAppRouter = router({ }), ) .output(zBookmarkSchema) - .use(ensureBookmarkOwnership) + .use(ensureBookmarkAccess) .query(async ({ input, ctx }) => { - return await getBookmark(ctx, input.bookmarkId, input.includeContent); + return ( + await Bookmark.fromId(ctx, input.bookmarkId, input.includeContent) + ).asZBookmark(); }), searchBookmarks: authedProcedure .input(zSearchBookmarksRequestSchema) @@ -818,25 +640,11 @@ export const bookmarksAppRouter = router({ acc[r.id] = r.score || 0; return acc; }, {}); - 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, - asset: true, - assets: true, - }, + + const { bookmarks: results } = await Bookmark.loadMulti(ctx, { + ids: resp.hits.map((h) => h.id), + includeContent: input.includeContent, + sortOrder: "desc", // Doesn't matter, we're sorting again afterwards and the list contain all data }); switch (true) { @@ -852,9 +660,7 @@ export const bookmarksAppRouter = router({ } return { - bookmarks: await Promise.all( - results.map((b) => toZodSchema(b, input.includeContent)), - ), + bookmarks: results.map((b) => b.asZBookmark()), nextCursor: resp.hits.length + (input.cursor?.offset || 0) >= resp.totalHits ? null -- cgit v1.2.3-70-g09d2