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 | |
| 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')
| -rw-r--r-- | packages/trpc/routers/bookmarks.test.ts | 8 | ||||
| -rw-r--r-- | packages/trpc/routers/bookmarks.ts | 298 | ||||
| -rw-r--r-- | packages/trpc/routers/highlights.ts | 8 | ||||
| -rw-r--r-- | packages/trpc/routers/lists.ts | 157 | ||||
| -rw-r--r-- | packages/trpc/routers/sharedLists.test.ts | 1922 |
5 files changed, 2121 insertions, 272 deletions
diff --git a/packages/trpc/routers/bookmarks.test.ts b/packages/trpc/routers/bookmarks.test.ts index 87f34b31..c272e015 100644 --- a/packages/trpc/routers/bookmarks.test.ts +++ b/packages/trpc/routers/bookmarks.test.ts @@ -361,20 +361,20 @@ describe("Bookmark Routes", () => { // 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/); + ).rejects.toThrow(/Bookmark not found/); await expect(() => apiCallers[0].bookmarks.getBookmark({ bookmarkId: user2Bookmark.id }), - ).rejects.toThrow(/User is not allowed to access resource/); + ).rejects.toThrow(/Bookmark not found/); await expect(() => apiCallers[0].bookmarks.updateBookmark({ bookmarkId: user2Bookmark.id }), - ).rejects.toThrow(/User is not allowed to access resource/); + ).rejects.toThrow(/Bookmark not found/); await expect(() => apiCallers[0].bookmarks.updateTags({ bookmarkId: user2Bookmark.id, attach: [], detach: [], }), - ).rejects.toThrow(/User is not allowed to access resource/); + ).rejects.toThrow(/Bookmark not found/); // Get bookmarks should only show the correct one expect( 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 diff --git a/packages/trpc/routers/highlights.ts b/packages/trpc/routers/highlights.ts index cb9b5e79..65d99880 100644 --- a/packages/trpc/routers/highlights.ts +++ b/packages/trpc/routers/highlights.ts @@ -10,7 +10,7 @@ import { import { authedProcedure, router } from "../index"; import { Highlight } from "../models/highlights"; -import { ensureBookmarkOwnership } from "./bookmarks"; +import { ensureBookmarkAccess, ensureBookmarkOwnership } from "./bookmarks"; export const highlightsAppRouter = router({ create: authedProcedure @@ -24,9 +24,9 @@ export const highlightsAppRouter = router({ getForBookmark: authedProcedure .input(z.object({ bookmarkId: z.string() })) .output(z.object({ highlights: z.array(zHighlightSchema) })) - .use(ensureBookmarkOwnership) - .query(async ({ input, ctx }) => { - const highlights = await Highlight.getForBookmark(ctx, input.bookmarkId); + .use(ensureBookmarkAccess) + .query(async ({ ctx }) => { + const highlights = await Highlight.getForBookmark(ctx, ctx.bookmark); return { highlights: highlights.map((h) => h.asPublicHighlight()) }; }), get: authedProcedure diff --git a/packages/trpc/routers/lists.ts b/packages/trpc/routers/lists.ts index 7118c608..c9a19f30 100644 --- a/packages/trpc/routers/lists.ts +++ b/packages/trpc/routers/lists.ts @@ -9,14 +9,15 @@ import { } from "@karakeep/shared/types/lists"; import type { AuthedContext } from "../index"; -import { authedProcedure, router } from "../index"; +import { authedProcedure, createRateLimitMiddleware, router } from "../index"; import { List } from "../models/lists"; import { ensureBookmarkOwnership } from "./bookmarks"; -export const ensureListOwnership = experimental_trpcMiddleware<{ +export const ensureListAtLeastViewer = experimental_trpcMiddleware<{ ctx: AuthedContext; input: { listId: string }; }>().create(async (opts) => { + // This would throw if the user can't view the list const list = await List.fromId(opts.ctx, opts.input.listId); return opts.next({ ctx: { @@ -26,20 +27,41 @@ export const ensureListOwnership = experimental_trpcMiddleware<{ }); }); +export const ensureListAtLeastEditor = experimental_trpcMiddleware<{ + ctx: AuthedContext & { list: List }; + input: { listId: string }; +}>().create(async (opts) => { + opts.ctx.list.ensureCanEdit(); + return opts.next({ + ctx: opts.ctx, + }); +}); + +export const ensureListAtLeastOwner = experimental_trpcMiddleware<{ + ctx: AuthedContext & { list: List }; + input: { listId: string }; +}>().create(async (opts) => { + opts.ctx.list.ensureCanManage(); + return opts.next({ + ctx: opts.ctx, + }); +}); + export const listsAppRouter = router({ create: authedProcedure .input(zNewBookmarkListSchema) .output(zBookmarkListSchema) .mutation(async ({ input, ctx }) => { - return await List.create(ctx, input).then((l) => l.list); + return await List.create(ctx, input).then((l) => l.asZBookmarkList()); }), edit: authedProcedure .input(zEditBookmarkListSchemaWithValidation) .output(zBookmarkListSchema) - .use(ensureListOwnership) + .use(ensureListAtLeastViewer) + .use(ensureListAtLeastOwner) .mutation(async ({ input, ctx }) => { await ctx.list.update(input); - return ctx.list.list; + return ctx.list.asZBookmarkList(); }), merge: authedProcedure .input(zMergeListSchema) @@ -48,6 +70,8 @@ export const listsAppRouter = router({ List.fromId(ctx, input.sourceId), List.fromId(ctx, input.targetId), ]); + sourceList.ensureCanManage(); + targetList.ensureCanManage(); return await sourceList.mergeInto( targetList, input.deleteSourceAfterMerge, @@ -60,7 +84,8 @@ export const listsAppRouter = router({ deleteChildren: z.boolean().optional().default(false), }), ) - .use(ensureListOwnership) + .use(ensureListAtLeastViewer) + .use(ensureListAtLeastOwner) .mutation(async ({ ctx, input }) => { if (input.deleteChildren) { const children = await ctx.list.getChildren(); @@ -75,7 +100,8 @@ export const listsAppRouter = router({ bookmarkId: z.string(), }), ) - .use(ensureListOwnership) + .use(ensureListAtLeastViewer) + .use(ensureListAtLeastEditor) .use(ensureBookmarkOwnership) .mutation(async ({ input, ctx }) => { await ctx.list.addBookmark(input.bookmarkId); @@ -87,8 +113,8 @@ export const listsAppRouter = router({ bookmarkId: z.string(), }), ) - .use(ensureListOwnership) - .use(ensureBookmarkOwnership) + .use(ensureListAtLeastViewer) + .use(ensureListAtLeastEditor) .mutation(async ({ input, ctx }) => { await ctx.list.removeBookmark(input.bookmarkId); }), @@ -99,9 +125,9 @@ export const listsAppRouter = router({ }), ) .output(zBookmarkListSchema) - .use(ensureListOwnership) - .query(({ ctx }) => { - return ctx.list.list; + .use(ensureListAtLeastViewer) + .query(async ({ ctx }) => { + return ctx.list.asZBookmarkList(); }), list: authedProcedure .output( @@ -111,7 +137,7 @@ export const listsAppRouter = router({ ) .query(async ({ ctx }) => { const results = await List.getAll(ctx); - return { lists: results.map((l) => l.list) }; + return { lists: results.map((l) => l.asZBookmarkList()) }; }), getListsOfBookmark: authedProcedure .input(z.object({ bookmarkId: z.string() })) @@ -123,7 +149,7 @@ export const listsAppRouter = router({ .use(ensureBookmarkOwnership) .query(async ({ input, ctx }) => { const lists = await List.forBookmark(ctx, input.bookmarkId); - return { lists: lists.map((l) => l.list) }; + return { lists: lists.map((l) => l.asZBookmarkList()) }; }), stats: authedProcedure .output( @@ -134,7 +160,7 @@ export const listsAppRouter = router({ .query(async ({ ctx }) => { const lists = await List.getAll(ctx); const sizes = await Promise.all(lists.map((l) => l.getSize())); - return { stats: new Map(lists.map((l, i) => [l.list.id, sizes[i]])) }; + return { stats: new Map(lists.map((l, i) => [l.id, sizes[i]])) }; }), // Rss endpoints @@ -149,7 +175,8 @@ export const listsAppRouter = router({ token: z.string(), }), ) - .use(ensureListOwnership) + .use(ensureListAtLeastViewer) + .use(ensureListAtLeastOwner) .mutation(async ({ ctx }) => { const token = await ctx.list.regenRssToken(); return { token: token! }; @@ -160,7 +187,8 @@ export const listsAppRouter = router({ listId: z.string(), }), ) - .use(ensureListOwnership) + .use(ensureListAtLeastViewer) + .use(ensureListAtLeastOwner) .mutation(async ({ ctx }) => { await ctx.list.clearRssToken(); }), @@ -175,8 +203,101 @@ export const listsAppRouter = router({ token: z.string().nullable(), }), ) - .use(ensureListOwnership) + .use(ensureListAtLeastViewer) + .use(ensureListAtLeastOwner) .query(async ({ ctx }) => { return { token: await ctx.list.getRssToken() }; }), + + // Collaboration endpoints + addCollaborator: authedProcedure + .input( + z.object({ + listId: z.string(), + email: z.string().email(), + role: z.enum(["viewer", "editor"]), + }), + ) + .use( + createRateLimitMiddleware({ + name: "lists.addCollaborator", + windowMs: 15 * 60 * 1000, + maxRequests: 20, + }), + ) + .use(ensureListAtLeastViewer) + .use(ensureListAtLeastOwner) + .mutation(async ({ input, ctx }) => { + await ctx.list.addCollaboratorByEmail(input.email, input.role); + }), + removeCollaborator: authedProcedure + .input( + z.object({ + listId: z.string(), + userId: z.string(), + }), + ) + .use(ensureListAtLeastViewer) + .use(ensureListAtLeastOwner) + .mutation(async ({ input, ctx }) => { + await ctx.list.removeCollaborator(input.userId); + }), + updateCollaboratorRole: authedProcedure + .input( + z.object({ + listId: z.string(), + userId: z.string(), + role: z.enum(["viewer", "editor"]), + }), + ) + .use(ensureListAtLeastViewer) + .use(ensureListAtLeastOwner) + .mutation(async ({ input, ctx }) => { + await ctx.list.updateCollaboratorRole(input.userId, input.role); + }), + getCollaborators: authedProcedure + .input( + z.object({ + listId: z.string(), + }), + ) + .output( + z.object({ + collaborators: z.array( + z.object({ + id: z.string(), + userId: z.string(), + role: z.enum(["viewer", "editor"]), + addedAt: z.date(), + user: z.object({ + id: z.string(), + name: z.string(), + email: z.string(), + }), + }), + ), + owner: z + .object({ + id: z.string(), + name: z.string(), + email: z.string(), + }) + .nullable(), + }), + ) + .use(ensureListAtLeastViewer) + .query(async ({ ctx }) => { + return await ctx.list.getCollaborators(); + }), + + leaveList: authedProcedure + .input( + z.object({ + listId: z.string(), + }), + ) + .use(ensureListAtLeastViewer) + .mutation(async ({ ctx }) => { + await ctx.list.leaveList(); + }), }); diff --git a/packages/trpc/routers/sharedLists.test.ts b/packages/trpc/routers/sharedLists.test.ts new file mode 100644 index 00000000..3b95a033 --- /dev/null +++ b/packages/trpc/routers/sharedLists.test.ts @@ -0,0 +1,1922 @@ +import { beforeEach, describe, expect, test } from "vitest"; + +import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; + +import type { CustomTestContext } from "../testUtils"; +import { defaultBeforeEach } from "../testUtils"; + +beforeEach<CustomTestContext>(defaultBeforeEach(true)); + +describe("Shared Lists", () => { + describe("List Collaboration Management", () => { + test<CustomTestContext>("should allow owner to add a collaborator by email", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + // Create a list as owner + const list = await ownerApi.lists.create({ + name: "Test Shared List", + icon: "📚", + type: "manual", + }); + + // Get collaborator email + const collaboratorUser = await collaboratorApi.users.whoami(); + const collaboratorEmail = collaboratorUser.email!; + + // Add collaborator + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "viewer", + }); + + // Verify collaborator was added + const { collaborators, owner } = await ownerApi.lists.getCollaborators({ + listId: list.id, + }); + + expect(collaborators).toHaveLength(1); + expect(collaborators[0].user.email).toBe(collaboratorEmail); + expect(collaborators[0].role).toBe("viewer"); + + // Verify owner is included + const ownerUser = await ownerApi.users.whoami(); + expect(owner).toBeDefined(); + expect(owner?.email).toBe(ownerUser.email); + }); + + test<CustomTestContext>("should not allow adding owner as collaborator", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + + const list = await ownerApi.lists.create({ + name: "Test List", + icon: "📚", + type: "manual", + }); + + const ownerUser = await ownerApi.users.whoami(); + + await expect( + ownerApi.lists.addCollaborator({ + listId: list.id, + email: ownerUser.email!, + role: "viewer", + }), + ).rejects.toThrow("Cannot add the list owner as a collaborator"); + }); + + test<CustomTestContext>("should not allow adding duplicate collaborator", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Test List", + icon: "📚", + type: "manual", + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "viewer", + }); + + // Try to add same collaborator again + await expect( + ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "editor", + }), + ).rejects.toThrow("User is already a collaborator on this list"); + }); + + test<CustomTestContext>("should allow owner to update collaborator role", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Test List", + icon: "📚", + type: "manual", + }); + + const collaboratorUser = await collaboratorApi.users.whoami(); + + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorUser.email!, + role: "viewer", + }); + + // Update role to editor + await ownerApi.lists.updateCollaboratorRole({ + listId: list.id, + userId: collaboratorUser.id, + role: "editor", + }); + + // Verify role was updated + const { collaborators, owner } = await ownerApi.lists.getCollaborators({ + listId: list.id, + }); + + expect(collaborators[0].role).toBe("editor"); + expect(owner).toBeDefined(); + }); + + test<CustomTestContext>("should allow owner to remove collaborator", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Test List", + icon: "📚", + type: "manual", + }); + + const collaboratorUser = await collaboratorApi.users.whoami(); + + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorUser.email!, + role: "viewer", + }); + + // Remove collaborator + await ownerApi.lists.removeCollaborator({ + listId: list.id, + userId: collaboratorUser.id, + }); + + // Verify collaborator was removed + const { collaborators, owner } = await ownerApi.lists.getCollaborators({ + listId: list.id, + }); + + expect(collaborators).toHaveLength(0); + expect(owner).toBeDefined(); + }); + + test<CustomTestContext>("should include owner information in getCollaborators response", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + + const list = await ownerApi.lists.create({ + name: "Test List", + icon: "📚", + type: "manual", + }); + + const ownerUser = await ownerApi.users.whoami(); + + const { collaborators, owner } = await ownerApi.lists.getCollaborators({ + listId: list.id, + }); + + // Verify owner information is present + expect(owner).toBeDefined(); + expect(owner?.id).toBe(ownerUser.id); + expect(owner?.name).toBe(ownerUser.name); + expect(owner?.email).toBe(ownerUser.email); + + // List with no collaborators should still have owner + expect(collaborators).toHaveLength(0); + }); + + test<CustomTestContext>("should remove collaborator's bookmarks when they are removed", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Test List", + icon: "📚", + type: "manual", + }); + + // Owner adds a bookmark + const ownerBookmark = await ownerApi.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "Owner's bookmark", + }); + + await ownerApi.lists.addToList({ + listId: list.id, + bookmarkId: ownerBookmark.id, + }); + + const collaboratorUser = await collaboratorApi.users.whoami(); + + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorUser.email!, + role: "editor", + }); + + // Collaborator adds their own bookmark + const collabBookmark = await collaboratorApi.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "Collaborator's bookmark", + }); + + await collaboratorApi.lists.addToList({ + listId: list.id, + bookmarkId: collabBookmark.id, + }); + + // Verify both bookmarks are in the list + const bookmarksBefore = await ownerApi.bookmarks.getBookmarks({ + listId: list.id, + }); + expect(bookmarksBefore.bookmarks).toHaveLength(2); + + // Remove collaborator + await ownerApi.lists.removeCollaborator({ + listId: list.id, + userId: collaboratorUser.id, + }); + + // Verify only owner's bookmark remains in the list + const bookmarksAfter = await ownerApi.bookmarks.getBookmarks({ + listId: list.id, + }); + expect(bookmarksAfter.bookmarks).toHaveLength(1); + expect(bookmarksAfter.bookmarks[0].id).toBe(ownerBookmark.id); + + // Verify collaborator's bookmark still exists (just not in the list) + const collabBookmarkStillExists = + await collaboratorApi.bookmarks.getBookmark({ + bookmarkId: collabBookmark.id, + }); + expect(collabBookmarkStillExists.id).toBe(collabBookmark.id); + }); + + test<CustomTestContext>("should allow collaborator to leave list", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Test List", + icon: "📚", + type: "manual", + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "viewer", + }); + + // Collaborator leaves the list + await collaboratorApi.lists.leaveList({ + listId: list.id, + }); + + // Verify collaborator was removed + const { collaborators, owner } = await ownerApi.lists.getCollaborators({ + listId: list.id, + }); + + expect(collaborators).toHaveLength(0); + expect(owner).toBeDefined(); + + // Verify list no longer appears in shared lists + const { lists: allLists } = await collaboratorApi.lists.list(); + const sharedLists = allLists.filter( + (l) => l.userRole === "viewer" || l.userRole === "editor", + ); + expect(sharedLists.find((l) => l.id === list.id)).toBeUndefined(); + }); + + test<CustomTestContext>("should remove collaborator's bookmarks when they leave list", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Test List", + icon: "📚", + type: "manual", + }); + + // Owner adds a bookmark + const ownerBookmark = await ownerApi.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "Owner's bookmark", + }); + + await ownerApi.lists.addToList({ + listId: list.id, + bookmarkId: ownerBookmark.id, + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "editor", + }); + + // Collaborator adds their own bookmark + const collabBookmark = await collaboratorApi.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "Collaborator's bookmark", + }); + + await collaboratorApi.lists.addToList({ + listId: list.id, + bookmarkId: collabBookmark.id, + }); + + // Verify both bookmarks are in the list + const bookmarksBefore = await ownerApi.bookmarks.getBookmarks({ + listId: list.id, + }); + expect(bookmarksBefore.bookmarks).toHaveLength(2); + + // Collaborator leaves the list + await collaboratorApi.lists.leaveList({ + listId: list.id, + }); + + // Verify only owner's bookmark remains in the list + const bookmarksAfter = await ownerApi.bookmarks.getBookmarks({ + listId: list.id, + }); + expect(bookmarksAfter.bookmarks).toHaveLength(1); + expect(bookmarksAfter.bookmarks[0].id).toBe(ownerBookmark.id); + + // Verify collaborator's bookmark still exists (just not in the list) + const collabBookmarkStillExists = + await collaboratorApi.bookmarks.getBookmark({ + bookmarkId: collabBookmark.id, + }); + expect(collabBookmarkStillExists.id).toBe(collabBookmark.id); + }); + + test<CustomTestContext>("should not allow owner to leave their own list", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + + const list = await ownerApi.lists.create({ + name: "Test List", + icon: "📚", + type: "manual", + }); + + await expect( + ownerApi.lists.leaveList({ + listId: list.id, + }), + ).rejects.toThrow( + "List owners cannot leave their own list. Delete the list instead.", + ); + }); + + test<CustomTestContext>("should not allow non-collaborator to manage collaborators", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const thirdUserApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Test List", + icon: "📚", + type: "manual", + }); + + const thirdUser = await thirdUserApi.users.whoami(); + + // Third user tries to add themselves as collaborator + await expect( + thirdUserApi.lists.addCollaborator({ + listId: list.id, + email: thirdUser.email!, + role: "viewer", + }), + ).rejects.toThrow("List not found"); + }); + }); + + describe("List Access and Visibility", () => { + test<CustomTestContext>("should show shared list in list endpoint", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Shared List", + icon: "📚", + type: "manual", + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "viewer", + }); + + const { lists: allLists } = await collaboratorApi.lists.list(); + const sharedLists = allLists.filter( + (l) => l.userRole === "viewer" || l.userRole === "editor", + ); + + expect(sharedLists).toHaveLength(1); + expect(sharedLists[0].id).toBe(list.id); + expect(sharedLists[0].name).toBe("Shared List"); + }); + + test<CustomTestContext>("should allow collaborator to get list details", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Shared List", + icon: "📚", + type: "manual", + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "viewer", + }); + + const retrievedList = await collaboratorApi.lists.get({ + listId: list.id, + }); + + expect(retrievedList.id).toBe(list.id); + expect(retrievedList.name).toBe("Shared List"); + expect(retrievedList.userRole).toBe("viewer"); + }); + + test<CustomTestContext>("should not allow non-collaborator to access list", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + const thirdUserApi = apiCallers[2]; + + const list = await ownerApi.lists.create({ + name: "Private List", + icon: "📚", + type: "manual", + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "viewer", + }); + + await expect( + thirdUserApi.lists.get({ + listId: list.id, + }), + ).rejects.toThrow("List not found"); + }); + + test<CustomTestContext>("should show correct userRole for owner", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + + const list = await ownerApi.lists.create({ + name: "My List", + icon: "📚", + type: "manual", + }); + + const retrievedList = await ownerApi.lists.get({ + listId: list.id, + }); + + expect(retrievedList.userRole).toBe("owner"); + }); + + test<CustomTestContext>("should show correct userRole for editor", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Shared List", + icon: "📚", + type: "manual", + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "editor", + }); + + const retrievedList = await collaboratorApi.lists.get({ + listId: list.id, + }); + + expect(retrievedList.userRole).toBe("editor"); + }); + }); + + describe("Bookmark Access in Shared Lists", () => { + test<CustomTestContext>("should allow collaborator to view bookmarks in shared list", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + // Owner creates list and bookmark + const list = await ownerApi.lists.create({ + name: "Shared List", + icon: "📚", + type: "manual", + }); + + const bookmark = await ownerApi.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "Shared bookmark", + }); + + await ownerApi.lists.addToList({ + listId: list.id, + bookmarkId: bookmark.id, + }); + + // Share list with collaborator + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "viewer", + }); + + // Collaborator fetches bookmarks from shared list + const bookmarks = await collaboratorApi.bookmarks.getBookmarks({ + listId: list.id, + }); + + expect(bookmarks.bookmarks).toHaveLength(1); + expect(bookmarks.bookmarks[0].id).toBe(bookmark.id); + }); + + test<CustomTestContext>("should hide owner-specific bookmark state from collaborators", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Shared List", + icon: "📚", + type: "manual", + }); + + const bookmark = await ownerApi.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "Shared bookmark", + }); + + await ownerApi.bookmarks.updateBookmark({ + bookmarkId: bookmark.id, + archived: true, + favourited: true, + note: "Private note", + }); + + await ownerApi.lists.addToList({ + listId: list.id, + bookmarkId: bookmark.id, + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "viewer", + }); + + const ownerView = await ownerApi.bookmarks.getBookmarks({ + listId: list.id, + }); + + const collaboratorView = await collaboratorApi.bookmarks.getBookmarks({ + listId: list.id, + }); + + const ownerBookmark = ownerView.bookmarks.find( + (b) => b.id === bookmark.id, + ); + expect(ownerBookmark?.favourited).toBe(true); + expect(ownerBookmark?.archived).toBe(true); + expect(ownerBookmark?.note).toBe("Private note"); + + const collaboratorBookmark = collaboratorView.bookmarks.find( + (b) => b.id === bookmark.id, + ); + expect(collaboratorBookmark?.favourited).toBe(false); + expect(collaboratorBookmark?.archived).toBe(false); + expect(collaboratorBookmark?.note).toBeNull(); + }); + + // Note: Asset handling for shared bookmarks is tested via the REST API in e2e tests + // This is because tRPC tests don't have easy access to file upload functionality + + test<CustomTestContext>("should allow collaborator to view individual shared bookmark", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Shared List", + icon: "📚", + type: "manual", + }); + + const bookmark = await ownerApi.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "Shared bookmark", + }); + + await ownerApi.lists.addToList({ + listId: list.id, + bookmarkId: bookmark.id, + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "viewer", + }); + + // Collaborator gets individual bookmark + const response = await collaboratorApi.bookmarks.getBookmark({ + bookmarkId: bookmark.id, + }); + + expect(response.id).toBe(bookmark.id); + }); + + test<CustomTestContext>("should not show shared bookmarks on collaborator's homepage", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Shared List", + icon: "📚", + type: "manual", + }); + + const sharedBookmark = await ownerApi.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "Shared bookmark", + }); + + await ownerApi.lists.addToList({ + listId: list.id, + bookmarkId: sharedBookmark.id, + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "viewer", + }); + + // Collaborator creates their own bookmark + const ownBookmark = await collaboratorApi.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "My own bookmark", + }); + + // Fetch all bookmarks (no listId filter) + const allBookmarks = await collaboratorApi.bookmarks.getBookmarks({}); + + // Should only see own bookmark, not shared one + expect(allBookmarks.bookmarks).toHaveLength(1); + expect(allBookmarks.bookmarks[0].id).toBe(ownBookmark.id); + }); + + test<CustomTestContext>("should not allow non-collaborator to access shared bookmark", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + const thirdUserApi = apiCallers[2]; // User 3 will be the non-collaborator + + const list = await ownerApi.lists.create({ + name: "Shared List", + icon: "📚", + type: "manual", + }); + + const bookmark = await ownerApi.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "Shared bookmark", + }); + + await ownerApi.lists.addToList({ + listId: list.id, + bookmarkId: bookmark.id, + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "viewer", + }); + + // Don't add thirdUserApi as a collaborator + // Third user tries to access the bookmark + await expect( + thirdUserApi.bookmarks.getBookmark({ + bookmarkId: bookmark.id, + }), + ).rejects.toThrow("Bookmark not found"); + }); + + test<CustomTestContext>("should show all bookmarks in shared list regardless of owner", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Shared List", + icon: "📚", + type: "manual", + }); + + // Owner adds a bookmark + const ownerBookmark = await ownerApi.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "Owner's bookmark", + }); + + await ownerApi.lists.addToList({ + listId: list.id, + bookmarkId: ownerBookmark.id, + }); + + // Share list with collaborator as editor + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "editor", + }); + + // Collaborator adds their own bookmark + const collabBookmark = await collaboratorApi.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "Collaborator's bookmark", + }); + + await collaboratorApi.lists.addToList({ + listId: list.id, + bookmarkId: collabBookmark.id, + }); + + // Both users should see both bookmarks in the list + const ownerView = await ownerApi.bookmarks.getBookmarks({ + listId: list.id, + }); + + const collabView = await collaboratorApi.bookmarks.getBookmarks({ + listId: list.id, + }); + + expect(ownerView.bookmarks).toHaveLength(2); + expect(collabView.bookmarks).toHaveLength(2); + }); + }); + + describe("Bookmark Editing Permissions", () => { + test<CustomTestContext>("should not allow viewer to add bookmarks to list", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Shared List", + icon: "📚", + type: "manual", + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "viewer", + }); + + // Viewer creates their own bookmark + const bookmark = await collaboratorApi.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "My bookmark", + }); + + // Viewer tries to add it to shared list + await expect( + collaboratorApi.lists.addToList({ + listId: list.id, + bookmarkId: bookmark.id, + }), + ).rejects.toThrow("User is not allowed to edit this list"); + }); + + test<CustomTestContext>("should allow editor to add bookmarks to list", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Shared List", + icon: "📚", + type: "manual", + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "editor", + }); + + // Editor creates their own bookmark + const bookmark = await collaboratorApi.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "My bookmark", + }); + + // Editor adds it to shared list + await collaboratorApi.lists.addToList({ + listId: list.id, + bookmarkId: bookmark.id, + }); + + // Verify bookmark was added + const bookmarks = await ownerApi.bookmarks.getBookmarks({ + listId: list.id, + }); + + expect(bookmarks.bookmarks).toHaveLength(1); + expect(bookmarks.bookmarks[0].id).toBe(bookmark.id); + }); + + test<CustomTestContext>("should not allow viewer to remove bookmarks from list", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Shared List", + icon: "📚", + type: "manual", + }); + + const bookmark = await ownerApi.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "Test bookmark", + }); + + await ownerApi.lists.addToList({ + listId: list.id, + bookmarkId: bookmark.id, + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "viewer", + }); + + // Viewer tries to remove bookmark + await expect( + collaboratorApi.lists.removeFromList({ + listId: list.id, + bookmarkId: bookmark.id, + }), + ).rejects.toThrow("User is not allowed to edit this list"); + }); + + test<CustomTestContext>("should allow editor to remove bookmarks from list", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Shared List", + icon: "📚", + type: "manual", + }); + + const bookmark = await ownerApi.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "Test bookmark", + }); + + await ownerApi.lists.addToList({ + listId: list.id, + bookmarkId: bookmark.id, + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "editor", + }); + + // Editor removes bookmark + await collaboratorApi.lists.removeFromList({ + listId: list.id, + bookmarkId: bookmark.id, + }); + + // Verify bookmark was removed + const bookmarks = await ownerApi.bookmarks.getBookmarks({ + listId: list.id, + }); + + expect(bookmarks.bookmarks).toHaveLength(0); + }); + + test<CustomTestContext>("should not allow collaborator to edit bookmark they don't own", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Shared List", + icon: "📚", + type: "manual", + }); + + const bookmark = await ownerApi.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "Owner's bookmark", + }); + + await ownerApi.lists.addToList({ + listId: list.id, + bookmarkId: bookmark.id, + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "editor", + }); + + // Collaborator tries to edit owner's bookmark + await expect( + collaboratorApi.bookmarks.updateBookmark({ + bookmarkId: bookmark.id, + title: "Modified title", + }), + ).rejects.toThrow("User is not allowed to access resource"); + }); + + test<CustomTestContext>("should not allow collaborator to delete bookmark they don't own", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Shared List", + icon: "📚", + type: "manual", + }); + + const bookmark = await ownerApi.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "Owner's bookmark", + }); + + await ownerApi.lists.addToList({ + listId: list.id, + bookmarkId: bookmark.id, + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "editor", + }); + + // Collaborator tries to delete owner's bookmark + await expect( + collaboratorApi.bookmarks.deleteBookmark({ + bookmarkId: bookmark.id, + }), + ).rejects.toThrow("User is not allowed to access resource"); + }); + }); + + describe("List Management Permissions", () => { + test<CustomTestContext>("should not allow collaborator to edit list metadata", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Shared List", + icon: "📚", + type: "manual", + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "editor", + }); + + // Collaborator tries to edit list + await expect( + collaboratorApi.lists.edit({ + listId: list.id, + name: "Modified Name", + }), + ).rejects.toThrow("User is not allowed to manage this list"); + }); + + test<CustomTestContext>("should not allow collaborator to delete list", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Shared List", + icon: "📚", + type: "manual", + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "editor", + }); + + // Collaborator tries to delete list + await expect( + collaboratorApi.lists.delete({ + listId: list.id, + }), + ).rejects.toThrow("User is not allowed to manage this list"); + }); + + test<CustomTestContext>("should not allow collaborator to manage other collaborators", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + const thirdUserApi = apiCallers[2]; + + const list = await ownerApi.lists.create({ + name: "Shared List", + icon: "📚", + type: "manual", + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "editor", + }); + + const thirdUserEmail = (await thirdUserApi.users.whoami()).email!; + + // Collaborator tries to add another user + await expect( + collaboratorApi.lists.addCollaborator({ + listId: list.id, + email: thirdUserEmail, + role: "viewer", + }), + ).rejects.toThrow("User is not allowed to manage this list"); + }); + + test<CustomTestContext>("should only allow collaborators to view collaborator list", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + const thirdUserApi = apiCallers[2]; + + const list = await ownerApi.lists.create({ + name: "Shared List", + icon: "📚", + type: "manual", + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "viewer", + }); + + // Collaborator can view collaborators + const { collaborators, owner } = + await collaboratorApi.lists.getCollaborators({ + listId: list.id, + }); + + expect(collaborators).toHaveLength(1); + expect(owner).toBeDefined(); + + // Non-collaborator cannot view + await expect( + thirdUserApi.lists.getCollaborators({ + listId: list.id, + }), + ).rejects.toThrow("List not found"); + }); + }); + + describe("Access After Removal", () => { + test<CustomTestContext>("should revoke access after removing collaborator", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Shared List", + icon: "📚", + type: "manual", + }); + + const bookmark = await ownerApi.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "Shared bookmark", + }); + + await ownerApi.lists.addToList({ + listId: list.id, + bookmarkId: bookmark.id, + }); + + const collaboratorUser = await collaboratorApi.users.whoami(); + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorUser.email!, + role: "viewer", + }); + + // Verify collaborator has access to list + const bookmarksBefore = await collaboratorApi.bookmarks.getBookmarks({ + listId: list.id, + }); + expect(bookmarksBefore.bookmarks).toHaveLength(1); + + // Verify collaborator has access to individual bookmark + const bookmarkBefore = await collaboratorApi.bookmarks.getBookmark({ + bookmarkId: bookmark.id, + }); + expect(bookmarkBefore.id).toBe(bookmark.id); + + // Remove collaborator + await ownerApi.lists.removeCollaborator({ + listId: list.id, + userId: collaboratorUser.id, + }); + + // Verify list access is revoked + await expect( + collaboratorApi.bookmarks.getBookmarks({ + listId: list.id, + }), + ).rejects.toThrow("List not found"); + + // Verify bookmark access is revoked + await expect( + collaboratorApi.bookmarks.getBookmark({ + bookmarkId: bookmark.id, + }), + ).rejects.toThrow("Bookmark not found"); + }); + + test<CustomTestContext>("should revoke access after leaving list", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Shared List", + icon: "📚", + type: "manual", + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "viewer", + }); + + // Collaborator leaves + await collaboratorApi.lists.leaveList({ + listId: list.id, + }); + + // Verify access is revoked + await expect( + collaboratorApi.lists.get({ + listId: list.id, + }), + ).rejects.toThrow("List not found"); + }); + }); + + describe("Smart Lists", () => { + test<CustomTestContext>("should not allow adding collaborators to smart lists", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Smart List", + icon: "🔍", + type: "smart", + query: "is:fav", + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + + await expect( + ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "viewer", + }), + ).rejects.toThrow("Only manual lists can have collaborators"); + }); + }); + + describe("List Operations Privacy", () => { + test<CustomTestContext>("should not allow collaborator to merge lists", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list1 = await ownerApi.lists.create({ + name: "List 1", + icon: "📚", + type: "manual", + }); + + const list2 = await ownerApi.lists.create({ + name: "List 2", + icon: "📖", + type: "manual", + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + await ownerApi.lists.addCollaborator({ + listId: list1.id, + email: collaboratorEmail, + role: "editor", + }); + await ownerApi.lists.addCollaborator({ + listId: list2.id, + email: collaboratorEmail, + role: "editor", + }); + + // Collaborator tries to merge the shared list into another list + await expect( + collaboratorApi.lists.merge({ + sourceId: list1.id, + targetId: list2.id, + deleteSourceAfterMerge: false, + }), + ).rejects.toThrow("User is not allowed to manage this list"); + }); + + test<CustomTestContext>("should not allow collaborator to access RSS token operations", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Shared List", + icon: "📚", + type: "manual", + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "editor", + }); + + // Collaborator tries to generate RSS token + await expect( + collaboratorApi.lists.regenRssToken({ + listId: list.id, + }), + ).rejects.toThrow("User is not allowed to manage this list"); + + // Collaborator tries to get RSS token + await expect( + collaboratorApi.lists.getRssToken({ + listId: list.id, + }), + ).rejects.toThrow("User is not allowed to manage this list"); + + // Owner generates token first + await ownerApi.lists.regenRssToken({ + listId: list.id, + }); + + // Collaborator tries to clear RSS token + await expect( + collaboratorApi.lists.clearRssToken({ + listId: list.id, + }), + ).rejects.toThrow("User is not allowed to manage this list"); + }); + + test<CustomTestContext>("should not allow collaborator to access getListsOfBookmark for bookmark they don't own", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Shared List", + icon: "📚", + type: "manual", + }); + + const bookmark = await ownerApi.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "Owner's bookmark", + }); + + await ownerApi.lists.addToList({ + listId: list.id, + bookmarkId: bookmark.id, + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "viewer", + }); + + // Collaborator cannot use getListsOfBookmark for owner's bookmark + // This is expected - only bookmark owners can query which lists contain their bookmarks + await expect( + collaboratorApi.lists.getListsOfBookmark({ + bookmarkId: bookmark.id, + }), + ).rejects.toThrow(); + }); + + test<CustomTestContext>("should allow collaborator to use getListsOfBookmark for their own bookmarks in shared lists", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Shared List", + icon: "📚", + type: "manual", + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "editor", + }); + + // Collaborator creates their own bookmark and adds it to the shared list + const bookmark = await collaboratorApi.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "Collaborator's bookmark", + }); + + await collaboratorApi.lists.addToList({ + listId: list.id, + bookmarkId: bookmark.id, + }); + + // Collaborator can see the shared list in getListsOfBookmark for their own bookmark + const { lists } = await collaboratorApi.lists.getListsOfBookmark({ + bookmarkId: bookmark.id, + }); + + expect(lists).toHaveLength(1); + expect(lists[0].id).toBe(list.id); + expect(lists[0].userRole).toBe("editor"); + expect(lists[0].hasCollaborators).toBe(true); + }); + + test<CustomTestContext>("should show hasCollaborators=true for owner when their bookmark is in a list with collaborators", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + // Owner creates a list + const list = await ownerApi.lists.create({ + name: "Shared List", + icon: "📚", + type: "manual", + }); + + // Owner creates and adds a bookmark + const bookmark = await ownerApi.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "Owner's bookmark", + }); + + await ownerApi.lists.addToList({ + listId: list.id, + bookmarkId: bookmark.id, + }); + + // Add a collaborator + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "viewer", + }); + + // Owner queries which lists contain their bookmark + const { lists } = await ownerApi.lists.getListsOfBookmark({ + bookmarkId: bookmark.id, + }); + + expect(lists).toHaveLength(1); + expect(lists[0].id).toBe(list.id); + expect(lists[0].userRole).toBe("owner"); + expect(lists[0].hasCollaborators).toBe(true); + }); + + test<CustomTestContext>("should show hasCollaborators=false for owner when their bookmark is in a list without collaborators", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + + // Owner creates a list + const list = await ownerApi.lists.create({ + name: "Private List", + icon: "📚", + type: "manual", + }); + + // Owner creates and adds a bookmark + const bookmark = await ownerApi.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "Owner's bookmark", + }); + + await ownerApi.lists.addToList({ + listId: list.id, + bookmarkId: bookmark.id, + }); + + // Owner queries which lists contain their bookmark + const { lists } = await ownerApi.lists.getListsOfBookmark({ + bookmarkId: bookmark.id, + }); + + expect(lists).toHaveLength(1); + expect(lists[0].id).toBe(list.id); + expect(lists[0].userRole).toBe("owner"); + expect(lists[0].hasCollaborators).toBe(false); + }); + + test<CustomTestContext>("should include shared lists in stats", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Shared List", + icon: "📚", + type: "manual", + }); + + // Add bookmarks to the list + const bookmark1 = await ownerApi.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "Bookmark 1", + }); + const bookmark2 = await ownerApi.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "Bookmark 2", + }); + + await ownerApi.lists.addToList({ + listId: list.id, + bookmarkId: bookmark1.id, + }); + await ownerApi.lists.addToList({ + listId: list.id, + bookmarkId: bookmark2.id, + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "viewer", + }); + + // Collaborator gets stats + const { stats } = await collaboratorApi.lists.stats(); + + // Shared list should appear in stats with correct count + expect(stats.get(list.id)).toBe(2); + }); + + test<CustomTestContext>("should allow editor to add their own bookmark to shared list via addToList", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const editorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Shared List", + icon: "📚", + type: "manual", + }); + + const editorEmail = (await editorApi.users.whoami()).email!; + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: editorEmail, + role: "editor", + }); + + // Editor creates their own bookmark + const bookmark = await editorApi.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "Editor's bookmark", + }); + + // Editor should be able to add their bookmark to the shared list + await editorApi.lists.addToList({ + listId: list.id, + bookmarkId: bookmark.id, + }); + + // Verify bookmark was added + const bookmarks = await ownerApi.bookmarks.getBookmarks({ + listId: list.id, + }); + + expect(bookmarks.bookmarks).toHaveLength(1); + expect(bookmarks.bookmarks[0].id).toBe(bookmark.id); + }); + + test<CustomTestContext>("should not allow viewer to add their own bookmark to shared list via addToList", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const viewerApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Shared List", + icon: "📚", + type: "manual", + }); + + const viewerEmail = (await viewerApi.users.whoami()).email!; + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: viewerEmail, + role: "viewer", + }); + + // Viewer creates their own bookmark + const bookmark = await viewerApi.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "Viewer's bookmark", + }); + + // Viewer should not be able to add their bookmark to the shared list + await expect( + viewerApi.lists.addToList({ + listId: list.id, + bookmarkId: bookmark.id, + }), + ).rejects.toThrow(); + }); + + test<CustomTestContext>("should not allow editor to add someone else's bookmark to shared list", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const editorApi = apiCallers[1]; + const thirdUserApi = apiCallers[2]; + + const list = await ownerApi.lists.create({ + name: "Shared List", + icon: "📚", + type: "manual", + }); + + const editorEmail = (await editorApi.users.whoami()).email!; + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: editorEmail, + role: "editor", + }); + + // Third user creates a bookmark (or owner if only 2 users) + const bookmark = await thirdUserApi.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "Someone else's bookmark", + }); + + // Editor should not be able to add someone else's bookmark + await expect( + editorApi.lists.addToList({ + listId: list.id, + bookmarkId: bookmark.id, + }), + ).rejects.toThrow(/Bookmark not found/); + }); + + test<CustomTestContext>("should not allow collaborator to update list metadata fields", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const editorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Shared List", + icon: "📚", + type: "manual", + }); + + const editorEmail = (await editorApi.users.whoami()).email!; + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: editorEmail, + role: "editor", + }); + + // Editor tries to change list name + await expect( + editorApi.lists.edit({ + listId: list.id, + name: "Modified Name", + }), + ).rejects.toThrow(); + + // Editor tries to make list public + await expect( + editorApi.lists.edit({ + listId: list.id, + public: true, + }), + ).rejects.toThrow(); + }); + }); + + describe("hasCollaborators Field", () => { + test<CustomTestContext>("should be false for newly created list", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + + const list = await ownerApi.lists.create({ + name: "New List", + icon: "📚", + type: "manual", + }); + + expect(list.hasCollaborators).toBe(false); + }); + + test<CustomTestContext>("should be true for owner after adding a collaborator", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Shared List", + icon: "📚", + type: "manual", + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "viewer", + }); + + // Fetch the list again to get updated hasCollaborators + const updatedList = await ownerApi.lists.get({ + listId: list.id, + }); + + expect(updatedList.hasCollaborators).toBe(true); + }); + + test<CustomTestContext>("should be true for collaborator viewing shared list", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Shared List", + icon: "📚", + type: "manual", + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "viewer", + }); + + // Collaborator fetches the list + const sharedList = await collaboratorApi.lists.get({ + listId: list.id, + }); + + expect(sharedList.hasCollaborators).toBe(true); + }); + + test<CustomTestContext>("should be false for owner after removing all collaborators", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Shared List", + icon: "📚", + type: "manual", + }); + + const collaboratorUser = await collaboratorApi.users.whoami(); + + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorUser.email!, + role: "viewer", + }); + + // Remove the collaborator + await ownerApi.lists.removeCollaborator({ + listId: list.id, + userId: collaboratorUser.id, + }); + + // Fetch the list again + const updatedList = await ownerApi.lists.get({ + listId: list.id, + }); + + expect(updatedList.hasCollaborators).toBe(false); + }); + + test<CustomTestContext>("should show correct value in lists.list() endpoint", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + // Create list without collaborators + const list1 = await ownerApi.lists.create({ + name: "Private List", + icon: "🔒", + type: "manual", + }); + + // Create list with collaborators + const list2 = await ownerApi.lists.create({ + name: "Shared List", + icon: "📚", + type: "manual", + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + + await ownerApi.lists.addCollaborator({ + listId: list2.id, + email: collaboratorEmail, + role: "viewer", + }); + + // Get all lists + const { lists } = await ownerApi.lists.list(); + + const privateList = lists.find((l) => l.id === list1.id); + const sharedList = lists.find((l) => l.id === list2.id); + + expect(privateList?.hasCollaborators).toBe(false); + expect(sharedList?.hasCollaborators).toBe(true); + }); + + test<CustomTestContext>("should show true for collaborator in lists.list() endpoint", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const list = await ownerApi.lists.create({ + name: "Shared List", + icon: "📚", + type: "manual", + }); + + const collaboratorEmail = (await collaboratorApi.users.whoami()).email!; + + await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaboratorEmail, + role: "editor", + }); + + // Collaborator gets all lists + const { lists } = await collaboratorApi.lists.list(); + + const sharedList = lists.find((l) => l.id === list.id); + + expect(sharedList?.hasCollaborators).toBe(true); + expect(sharedList?.userRole).toBe("editor"); + }); + }); +}); |
