diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-11-17 01:12:41 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-11-17 01:12:41 +0000 |
| commit | 88c73e212c4510ce41ad8c6557fa7d5c8f72d199 (patch) | |
| tree | 11f47349b8c34de1bf541febd9ba48cc44aa305a /packages/trpc/routers/bookmarks.ts | |
| parent | cc8fee0d28d87299ee9a3ad11dcb4ae5a7b86c15 (diff) | |
| download | karakeep-88c73e212c4510ce41ad8c6557fa7d5c8f72d199.tar.zst | |
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 <noreply@anthropic.com>
Diffstat (limited to 'packages/trpc/routers/bookmarks.ts')
| -rw-r--r-- | packages/trpc/routers/bookmarks.ts | 298 |
1 files changed, 52 insertions, 246 deletions
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<typeof dummyDrizzleReturnType> ->; - -async function cleanupAssetForBookmark( - bookmark: Pick<BookmarkQueryReturnType, "asset" | "userId" | "assets">, -) { - const assetIds: Set<string> = new Set<string>( - 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<ZBookmark> { - 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 |
