aboutsummaryrefslogtreecommitdiffstats
path: root/packages/trpc/routers/bookmarks.ts
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-11-17 01:12:41 +0000
committerGitHub <noreply@github.com>2025-11-17 01:12:41 +0000
commit88c73e212c4510ce41ad8c6557fa7d5c8f72d199 (patch)
tree11f47349b8c34de1bf541febd9ba48cc44aa305a /packages/trpc/routers/bookmarks.ts
parentcc8fee0d28d87299ee9a3ad11dcb4ae5a7b86c15 (diff)
downloadkarakeep-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.ts298
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