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 | |
| 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')
| -rw-r--r-- | packages/trpc/models/bookmarks.ts | 309 | ||||
| -rw-r--r-- | packages/trpc/models/highlights.ts | 8 | ||||
| -rw-r--r-- | packages/trpc/models/lists.ts | 587 | ||||
| -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 | ||||
| -rw-r--r-- | packages/trpc/testUtils.ts | 4 |
9 files changed, 2981 insertions, 320 deletions
diff --git a/packages/trpc/models/bookmarks.ts b/packages/trpc/models/bookmarks.ts index c689f64d..e4bfdab2 100644 --- a/packages/trpc/models/bookmarks.ts +++ b/packages/trpc/models/bookmarks.ts @@ -15,19 +15,23 @@ import { import invariant from "tiny-invariant"; import { z } from "zod"; +import { db as DONT_USE_db } from "@karakeep/db"; import { assets, AssetTypes, bookmarkAssets, bookmarkLinks, + bookmarkLists, bookmarks, bookmarksInLists, bookmarkTags, bookmarkTexts, + listCollaborators, rssFeedImportsTable, tagsOnBookmarks, } from "@karakeep/db/schema"; -import { readAsset } from "@karakeep/shared/assetdb"; +import { SearchIndexingQueue, triggerWebhook } from "@karakeep/shared-server"; +import { deleteAsset, readAsset } from "@karakeep/shared/assetdb"; import serverConfig from "@karakeep/shared/config"; import { createSignedToken, @@ -37,6 +41,7 @@ import { zAssetSignedTokenSchema } from "@karakeep/shared/types/assets"; import { BookmarkTypes, DEFAULT_NUM_BOOKMARKS_PER_PAGE, + ZBareBookmark, ZBookmark, ZBookmarkContent, zGetBookmarksRequestSchema, @@ -54,26 +59,225 @@ import { mapDBAssetTypeToUserType } from "../lib/attachments"; import { List } from "./lists"; import { PrivacyAware } from "./privacy"; -export class Bookmark implements PrivacyAware { +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> +>; + +export class BareBookmark implements PrivacyAware { protected constructor( protected ctx: AuthedContext, - public bookmark: ZBookmark & { userId: string }, + private bareBookmark: ZBareBookmark, ) {} + get id() { + return this.bareBookmark.id; + } + + get createdAt() { + return this.bareBookmark.createdAt; + } + + static async bareFromId(ctx: AuthedContext, bookmarkId: string) { + const bookmark = await ctx.db.query.bookmarks.findFirst({ + where: eq(bookmarks.id, bookmarkId), + }); + + if (!bookmark) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Bookmark not found", + }); + } + + if (!(await BareBookmark.isAllowedToAccessBookmark(ctx, bookmark))) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Bookmark not found", + }); + } + + return new BareBookmark(ctx, bookmark); + } + + protected static async isAllowedToAccessBookmark( + ctx: AuthedContext, + { id: bookmarkId, userId: bookmarkOwnerId }: { id: string; userId: string }, + ): Promise<boolean> { + if (bookmarkOwnerId == ctx.user.id) { + return true; + } + const bookmarkLists = await List.forBookmark(ctx, bookmarkId); + return bookmarkLists.some((l) => l.canUserView()); + } + + ensureOwnership() { + if (this.bareBookmark.userId != this.ctx.user.id) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "User is not allowed to access resource", + }); + } + } + ensureCanAccess(ctx: AuthedContext): void { - if (this.bookmark.userId != ctx.user.id) { + if (this.bareBookmark.userId != ctx.user.id) { throw new TRPCError({ code: "FORBIDDEN", message: "User is not allowed to access resource", }); } } +} - static fromData(ctx: AuthedContext, data: ZBookmark) { - return new Bookmark(ctx, { - ...data, - userId: ctx.user.id, +export class Bookmark extends BareBookmark { + protected constructor( + ctx: AuthedContext, + private bookmark: ZBookmark, + ) { + super(ctx, bookmark); + } + + private static async 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, + }; + } + + static async fromId( + ctx: AuthedContext, + bookmarkId: string, + includeContent: boolean, + ) { + const bookmark = await ctx.db.query.bookmarks.findFirst({ + where: eq(bookmarks.id, bookmarkId), + with: { + tagsOnBookmarks: { + with: { + tag: true, + }, + }, + link: true, + text: true, + asset: true, + assets: true, + }, }); + + if (!bookmark) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Bookmark not found", + }); + } + + if (!(await BareBookmark.isAllowedToAccessBookmark(ctx, bookmark))) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Bookmark not found", + }); + } + return Bookmark.fromData( + ctx, + await Bookmark.toZodSchema(bookmark, includeContent), + ); + } + + static fromData(ctx: AuthedContext, data: ZBookmark) { + return new Bookmark(ctx, data); } static async loadMulti( @@ -103,7 +307,42 @@ export class Bookmark implements PrivacyAware { .from(bookmarks) .where( and( - eq(bookmarks.userId, ctx.user.id), + // Access control: User can access bookmarks if they either: + // 1. Own the bookmark (always) + // 2. The bookmark is in a specific shared list being viewed + // When listId is specified, we need special handling to show all bookmarks in that list + input.listId !== undefined + ? // If querying a specific list, check if user has access to that list + or( + eq(bookmarks.userId, ctx.user.id), + // User is the owner of the list being queried + exists( + ctx.db + .select() + .from(bookmarkLists) + .where( + and( + eq(bookmarkLists.id, input.listId), + eq(bookmarkLists.userId, ctx.user.id), + ), + ), + ), + // User is a collaborator on the list being queried + exists( + ctx.db + .select() + .from(listCollaborators) + .where( + and( + eq(listCollaborators.listId, input.listId), + eq(listCollaborators.userId, ctx.user.id), + ), + ), + ), + ) + : // If not querying a specific list, only show bookmarks the user owns + // Shared bookmarks should only appear when viewing the specific shared list + eq(bookmarks.userId, ctx.user.id), input.archived !== undefined ? eq(bookmarks.archived, input.archived) : undefined, @@ -317,7 +556,7 @@ export class Bookmark implements PrivacyAware { ) { try { const asset = await readAsset({ - userId: ctx.user.id, + userId: bookmark.userId, assetId: bookmark.content.contentAssetId, }); bookmark.content.htmlContent = asset.asset.toString("utf8"); @@ -365,7 +604,18 @@ export class Bookmark implements PrivacyAware { } asZBookmark(): ZBookmark { - return this.bookmark; + if (this.bookmark.userId === this.ctx.user.id) { + return this.bookmark; + } + + // Collaborators shouldn't see owner-specific state such as favourites, + // archived flag, or personal notes. + return { + ...this.bookmark, + archived: false, + favourited: false, + note: null, + }; } asPublicBookmark(): ZPublicBookmark { @@ -512,4 +762,41 @@ export class Bookmark implements PrivacyAware { } return htmlToPlainText(content); } + + private async cleanupAssets() { + const assetIds: Set<string> = new Set<string>( + this.bookmark.assets.map((a) => a.id), + ); + // Todo: Remove when the bookmark asset is also in the assets table + if (this.bookmark.content.type == BookmarkTypes.ASSET) { + assetIds.add(this.bookmark.content.assetId); + } + await Promise.all( + Array.from(assetIds).map((assetId) => + deleteAsset({ userId: this.bookmark.userId, assetId }), + ), + ); + } + + async delete() { + this.ensureOwnership(); + const deleted = await this.ctx.db + .delete(bookmarks) + .where( + and( + eq(bookmarks.userId, this.ctx.user.id), + eq(bookmarks.id, this.bookmark.id), + ), + ); + + await SearchIndexingQueue.enqueue({ + bookmarkId: this.bookmark.id, + type: "delete", + }); + + await triggerWebhook(this.bookmark.id, "deleted", this.ctx.user.id); + if (deleted.changes > 0) { + await this.cleanupAssets(); + } + } } diff --git a/packages/trpc/models/highlights.ts b/packages/trpc/models/highlights.ts index 326f97f3..260c4b8a 100644 --- a/packages/trpc/models/highlights.ts +++ b/packages/trpc/models/highlights.ts @@ -11,6 +11,7 @@ import { import { zCursorV2 } from "@karakeep/shared/types/pagination"; import { AuthedContext } from ".."; +import { BareBookmark } from "./bookmarks"; import { PrivacyAware } from "./privacy"; export class Highlight implements PrivacyAware { @@ -64,13 +65,10 @@ export class Highlight implements PrivacyAware { static async getForBookmark( ctx: AuthedContext, - bookmarkId: string, + bookmark: BareBookmark, ): Promise<Highlight[]> { const results = await ctx.db.query.highlights.findMany({ - where: and( - eq(highlights.bookmarkId, bookmarkId), - eq(highlights.userId, ctx.user.id), - ), + where: eq(highlights.bookmarkId, bookmark.id), orderBy: [desc(highlights.createdAt), desc(highlights.id)], }); diff --git a/packages/trpc/models/lists.ts b/packages/trpc/models/lists.ts index 48da5ed1..28473c12 100644 --- a/packages/trpc/models/lists.ts +++ b/packages/trpc/models/lists.ts @@ -1,11 +1,16 @@ import crypto from "node:crypto"; import { TRPCError } from "@trpc/server"; -import { and, count, eq, or } from "drizzle-orm"; +import { and, count, eq, inArray, or } from "drizzle-orm"; import invariant from "tiny-invariant"; import { z } from "zod"; import { SqliteError } from "@karakeep/db"; -import { bookmarkLists, bookmarksInLists } from "@karakeep/db/schema"; +import { + bookmarkLists, + bookmarksInLists, + listCollaborators, + users, +} from "@karakeep/db/schema"; import { triggerRuleEngineOnEvent } from "@karakeep/shared-server"; import { parseSearchQuery } from "@karakeep/shared/searchQueryParser"; import { ZSortOrder } from "@karakeep/shared/types/bookmarks"; @@ -15,6 +20,7 @@ import { zNewBookmarkListSchema, } from "@karakeep/shared/types/lists"; import { ZCursor } from "@karakeep/shared/types/pagination"; +import { switchCase } from "@karakeep/shared/utils/switch"; import { AuthedContext, Context } from ".."; import { buildImpersonatingAuthedContext } from "../lib/impersonate"; @@ -22,20 +28,54 @@ import { getBookmarkIdsFromMatcher } from "../lib/search"; import { Bookmark } from "./bookmarks"; import { PrivacyAware } from "./privacy"; +interface ListCollaboratorEntry { + membershipId: string; +} + export abstract class List implements PrivacyAware { protected constructor( protected ctx: AuthedContext, - public list: ZBookmarkList & { userId: string }, + protected list: ZBookmarkList & { userId: string }, ) {} + get id() { + return this.list.id; + } + + asZBookmarkList() { + if (this.list.userId === this.ctx.user.id) { + return this.list; + } + + // There's some privacy implications here, so we need to think twice + // about the values that we return. + return { + id: this.list.id, + name: this.list.name, + description: this.list.description, + userId: this.list.userId, + icon: this.list.icon, + type: this.list.type, + query: this.list.query, + userRole: this.list.userRole, + hasCollaborators: this.list.hasCollaborators, + + // Hide parentId as it is not relevant to the user + parentId: null, + // Hide whether the list is public or not. + public: false, + }; + } + private static fromData( ctx: AuthedContext, data: ZBookmarkList & { userId: string }, + collaboratorEntry: ListCollaboratorEntry | null, ) { if (data.type === "smart") { return new SmartList(ctx, data); } else { - return new ManualList(ctx, data); + return new ManualList(ctx, data, collaboratorEntry); } } @@ -43,12 +83,64 @@ export abstract class List implements PrivacyAware { ctx: AuthedContext, id: string, ): Promise<ManualList | SmartList> { - const list = await ctx.db.query.bookmarkLists.findFirst({ - where: and( - eq(bookmarkLists.id, id), - eq(bookmarkLists.userId, ctx.user.id), - ), - }); + // First try to find the list owned by the user + let list = await (async (): Promise< + (ZBookmarkList & { userId: string }) | undefined + > => { + const l = await ctx.db.query.bookmarkLists.findFirst({ + columns: { + rssToken: false, + }, + where: and( + eq(bookmarkLists.id, id), + eq(bookmarkLists.userId, ctx.user.id), + ), + with: { + collaborators: { + columns: { + id: true, + }, + limit: 1, + }, + }, + }); + return l + ? { + ...l, + userRole: "owner", + hasCollaborators: l.collaborators.length > 0, + } + : l; + })(); + + // If not found, check if the user is a collaborator + let collaboratorEntry: ListCollaboratorEntry | null = null; + if (!list) { + const collaborator = await ctx.db.query.listCollaborators.findFirst({ + where: and( + eq(listCollaborators.listId, id), + eq(listCollaborators.userId, ctx.user.id), + ), + with: { + list: { + columns: { + rssToken: false, + }, + }, + }, + }); + + if (collaborator) { + list = { + ...collaborator.list, + userRole: collaborator.role, + hasCollaborators: true, // If you're a collaborator, the list has collaborators + }; + collaboratorEntry = { + membershipId: collaborator.id, + }; + } + } if (!list) { throw new TRPCError({ @@ -59,7 +151,7 @@ export abstract class List implements PrivacyAware { if (list.type === "smart") { return new SmartList(ctx, list); } else { - return new ManualList(ctx, list); + return new ManualList(ctx, list, collaboratorEntry); } } @@ -124,8 +216,17 @@ export abstract class List implements PrivacyAware { // an impersonating context for the list owner as long as // we don't leak the context. const authedCtx = await buildImpersonatingAuthedContext(listdb.userId); - const list = List.fromData(authedCtx, listdb); - const bookmarkIds = await list.getBookmarkIds(); + const listObj = List.fromData( + authedCtx, + { + ...listdb, + userRole: "public", + hasCollaborators: false, // Public lists don't expose collaborators + }, + null, + ); + const bookmarkIds = await listObj.getBookmarkIds(); + const list = listObj.asZBookmarkList(); const bookmarks = await Bookmark.loadMulti(authedCtx, { ids: bookmarkIds, @@ -137,9 +238,9 @@ export abstract class List implements PrivacyAware { return { list: { - icon: list.list.icon, - name: list.list.name, - description: list.list.description, + icon: list.icon, + name: list.name, + description: list.description, ownerName: listdb.user.name, numItems: bookmarkIds.length, }, @@ -164,32 +265,127 @@ export abstract class List implements PrivacyAware { query: input.query, }) .returning(); - return this.fromData(ctx, result); + return this.fromData( + ctx, + { + ...result, + userRole: "owner", + hasCollaborators: false, // Newly created lists have no collaborators + }, + null, + ); + } + + static async getAll(ctx: AuthedContext) { + const [ownedLists, sharedLists] = await Promise.all([ + this.getAllOwned(ctx), + this.getSharedWithUser(ctx), + ]); + return [...ownedLists, ...sharedLists]; } - static async getAll(ctx: AuthedContext): Promise<(ManualList | SmartList)[]> { + static async getAllOwned( + ctx: AuthedContext, + ): Promise<(ManualList | SmartList)[]> { const lists = await ctx.db.query.bookmarkLists.findMany({ columns: { rssToken: false, }, where: and(eq(bookmarkLists.userId, ctx.user.id)), + with: { + collaborators: { + columns: { + id: true, + }, + limit: 1, + }, + }, }); - return lists.map((l) => this.fromData(ctx, l)); + return lists.map((l) => + this.fromData( + ctx, + { + ...l, + userRole: "owner", + hasCollaborators: l.collaborators.length > 0, + }, + null /* this is an owned list */, + ), + ); } static async forBookmark(ctx: AuthedContext, bookmarkId: string) { const lists = await ctx.db.query.bookmarksInLists.findMany({ - where: and(eq(bookmarksInLists.bookmarkId, bookmarkId)), + where: eq(bookmarksInLists.bookmarkId, bookmarkId), with: { list: { columns: { rssToken: false, }, + with: { + collaborators: { + where: eq(listCollaborators.userId, ctx.user.id), + columns: { + id: true, + role: true, + }, + }, + }, }, }, }); - invariant(lists.map((l) => l.list.userId).every((id) => id == ctx.user.id)); - return lists.map((l) => this.fromData(ctx, l.list)); + + // For owner lists, we need to check if they actually have collaborators + // by querying the collaborators table separately (without user filter) + const ownerListIds = lists + .filter((l) => l.list.userId === ctx.user.id) + .map((l) => l.list.id); + + const listsWithCollaborators = new Set<string>(); + if (ownerListIds.length > 0) { + // Use a single query with inArray instead of N queries + const collaborators = await ctx.db.query.listCollaborators.findMany({ + where: inArray(listCollaborators.listId, ownerListIds), + columns: { + listId: true, + }, + }); + collaborators.forEach((c) => { + listsWithCollaborators.add(c.listId); + }); + } + + return lists.flatMap((l) => { + let userRole: "owner" | "editor" | "viewer" | null; + let collaboratorEntry: ListCollaboratorEntry | null = null; + if (l.list.collaborators.length > 0) { + invariant(l.list.collaborators.length == 1); + userRole = l.list.collaborators[0].role; + collaboratorEntry = { + membershipId: l.list.collaborators[0].id, + }; + } else if (l.list.userId === ctx.user.id) { + userRole = "owner"; + } else { + userRole = null; + } + return userRole + ? [ + this.fromData( + ctx, + { + ...l.list, + userRole, + hasCollaborators: + userRole !== "owner" + ? true + : listsWithCollaborators.has(l.list.id), + }, + collaboratorEntry, + ), + ] + : []; + }); } ensureCanAccess(ctx: AuthedContext): void { @@ -201,7 +397,81 @@ export abstract class List implements PrivacyAware { } } + /** + * Check if the user can view this list and its bookmarks. + */ + canUserView(): boolean { + return switchCase(this.list.userRole, { + owner: true, + editor: true, + viewer: true, + public: true, + }); + } + + /** + * Check if the user can edit this list (add/remove bookmarks). + */ + canUserEdit(): boolean { + return switchCase(this.list.userRole, { + owner: true, + editor: true, + viewer: false, + public: false, + }); + } + + /** + * Check if the user can manage this list (edit metadata, delete, manage collaborators). + * Only the owner can manage the list. + */ + canUserManage(): boolean { + return switchCase(this.list.userRole, { + owner: true, + editor: false, + viewer: false, + public: false, + }); + } + + /** + * Ensure the user can view this list. Throws if they cannot. + */ + ensureCanView(): void { + if (!this.canUserView()) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "User is not allowed to view this list", + }); + } + } + + /** + * Ensure the user can edit this list. Throws if they cannot. + */ + ensureCanEdit(): void { + if (!this.canUserEdit()) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "User is not allowed to edit this list", + }); + } + } + + /** + * Ensure the user can manage this list. Throws if they cannot. + */ + ensureCanManage(): void { + if (!this.canUserManage()) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "User is not allowed to manage this list", + }); + } + } + async delete() { + this.ensureCanManage(); const res = await this.ctx.db .delete(bookmarkLists) .where( @@ -216,22 +486,23 @@ export abstract class List implements PrivacyAware { } async getChildren(): Promise<(ManualList | SmartList)[]> { - const lists = await List.getAll(this.ctx); - const listById = new Map(lists.map((l) => [l.list.id, l])); + const lists = await List.getAllOwned(this.ctx); + const listById = new Map(lists.map((l) => [l.id, l])); const adjecencyList = new Map<string, string[]>(); // Initialize all lists with empty arrays first lists.forEach((l) => { - adjecencyList.set(l.list.id, []); + adjecencyList.set(l.id, []); }); // Then populate the parent-child relationships lists.forEach((l) => { - if (l.list.parentId) { - const currentChildren = adjecencyList.get(l.list.parentId) ?? []; - currentChildren.push(l.list.id); - adjecencyList.set(l.list.parentId, currentChildren); + const parentId = l.asZBookmarkList().parentId; + if (parentId) { + const currentChildren = adjecencyList.get(parentId) ?? []; + currentChildren.push(l.id); + adjecencyList.set(parentId, currentChildren); } }); @@ -253,6 +524,7 @@ export abstract class List implements PrivacyAware { async update( input: z.infer<typeof zEditBookmarkListSchemaWithValidation>, ): Promise<void> { + this.ensureCanManage(); const result = await this.ctx.db .update(bookmarkLists) .set({ @@ -273,7 +545,21 @@ export abstract class List implements PrivacyAware { if (result.length == 0) { throw new TRPCError({ code: "NOT_FOUND" }); } - this.list = result[0]; + invariant(result[0].userId === this.ctx.user.id); + // Fetch current collaborators count to update hasCollaborators + const collaboratorsCount = + await this.ctx.db.query.listCollaborators.findMany({ + where: eq(listCollaborators.listId, this.list.id), + columns: { + id: true, + }, + limit: 1, + }); + this.list = { + ...result[0], + userRole: "owner", + hasCollaborators: collaboratorsCount.length > 0, + }; } private async setRssToken(token: string | null) { @@ -294,6 +580,7 @@ export abstract class List implements PrivacyAware { } async getRssToken(): Promise<string | null> { + this.ensureCanManage(); const [result] = await this.ctx.db .select({ rssToken: bookmarkLists.rssToken }) .from(bookmarkLists) @@ -308,13 +595,237 @@ export abstract class List implements PrivacyAware { } async regenRssToken() { + this.ensureCanManage(); return await this.setRssToken(crypto.randomBytes(32).toString("hex")); } async clearRssToken() { + this.ensureCanManage(); await this.setRssToken(null); } + /** + * Add a collaborator to this list by email. + */ + async addCollaboratorByEmail( + email: string, + role: "viewer" | "editor", + ): Promise<void> { + this.ensureCanManage(); + + // Look up the user by email + const user = await this.ctx.db.query.users.findFirst({ + where: (users, { eq }) => eq(users.email, email), + }); + + if (!user) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "No user found with that email address", + }); + } + + // Check that the user is not adding themselves + if (user.id === this.list.userId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Cannot add the list owner as a collaborator", + }); + } + + // Check that the collaborator is not already added + const existing = await this.ctx.db.query.listCollaborators.findFirst({ + where: and( + eq(listCollaborators.listId, this.list.id), + eq(listCollaborators.userId, user.id), + ), + }); + + if (existing) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "User is already a collaborator on this list", + }); + } + + // Only manual lists can be collaborative + if (this.list.type !== "manual") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Only manual lists can have collaborators", + }); + } + + await this.ctx.db.insert(listCollaborators).values({ + listId: this.list.id, + userId: user.id, + role, + addedBy: this.ctx.user.id, + }); + } + + /** + * Remove a collaborator from this list. + * Only the list owner can remove collaborators. + * This also removes all bookmarks that the collaborator added to the list. + */ + async removeCollaborator(userId: string): Promise<void> { + this.ensureCanManage(); + + const result = await this.ctx.db + .delete(listCollaborators) + .where( + and( + eq(listCollaborators.listId, this.list.id), + eq(listCollaborators.userId, userId), + ), + ); + + if (result.changes === 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Collaborator not found", + }); + } + } + + /** + * Allow a user to leave a list (remove themselves as a collaborator). + * This bypasses the owner check since users should be able to leave lists they're collaborating on. + * This also removes all bookmarks that the user added to the list. + */ + async leaveList(): Promise<void> { + if (this.list.userRole === "owner") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "List owners cannot leave their own list. Delete the list instead.", + }); + } + + const result = await this.ctx.db + .delete(listCollaborators) + .where( + and( + eq(listCollaborators.listId, this.list.id), + eq(listCollaborators.userId, this.ctx.user.id), + ), + ); + + if (result.changes === 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Collaborator not found", + }); + } + } + + /** + * Update a collaborator's role. + */ + async updateCollaboratorRole( + userId: string, + role: "viewer" | "editor", + ): Promise<void> { + this.ensureCanManage(); + + const result = await this.ctx.db + .update(listCollaborators) + .set({ role }) + .where( + and( + eq(listCollaborators.listId, this.list.id), + eq(listCollaborators.userId, userId), + ), + ); + + if (result.changes === 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Collaborator not found", + }); + } + } + + /** + * Get all collaborators for this list. + */ + async getCollaborators() { + this.ensureCanView(); + + const collaborators = await this.ctx.db.query.listCollaborators.findMany({ + where: eq(listCollaborators.listId, this.list.id), + with: { + user: { + columns: { + id: true, + name: true, + email: true, + }, + }, + }, + }); + + // Get the owner information + const owner = await this.ctx.db.query.users.findFirst({ + where: eq(users.id, this.list.userId), + columns: { + id: true, + name: true, + email: true, + }, + }); + + return { + collaborators: collaborators.map((c) => ({ + id: c.id, + userId: c.userId, + role: c.role, + addedAt: c.addedAt, + user: c.user, + })), + owner: owner + ? { + id: owner.id, + name: owner.name, + email: owner.email, + } + : null, + }; + } + + /** + * Get all lists shared with the user (as a collaborator). + */ + static async getSharedWithUser( + ctx: AuthedContext, + ): Promise<(ManualList | SmartList)[]> { + const collaborations = await ctx.db.query.listCollaborators.findMany({ + where: eq(listCollaborators.userId, ctx.user.id), + with: { + list: { + columns: { + rssToken: false, + }, + }, + }, + }); + + return collaborations.map((c) => + this.fromData( + ctx, + { + ...c.list, + userRole: c.role, + hasCollaborators: true, // If you're a collaborator, the list has collaborators + }, + { + membershipId: c.id, + }, + ), + ); + } + abstract get type(): "manual" | "smart"; abstract getBookmarkIds(ctx: AuthedContext): Promise<string[]>; abstract getSize(ctx: AuthedContext): Promise<number>; @@ -392,7 +903,11 @@ export class SmartList extends List { } export class ManualList extends List { - constructor(ctx: AuthedContext, list: ZBookmarkList & { userId: string }) { + constructor( + ctx: AuthedContext, + list: ZBookmarkList & { userId: string }, + private collaboratorEntry: ListCollaboratorEntry | null, + ) { super(ctx, list); } @@ -418,10 +933,13 @@ export class ManualList extends List { } async addBookmark(bookmarkId: string): Promise<void> { + this.ensureCanEdit(); + try { await this.ctx.db.insert(bookmarksInLists).values({ listId: this.list.id, bookmarkId, + listMembershipId: this.collaboratorEntry?.membershipId, }); await triggerRuleEngineOnEvent(bookmarkId, [ { @@ -444,6 +962,9 @@ export class ManualList extends List { } async removeBookmark(bookmarkId: string): Promise<void> { + // Check that the user can edit this list + this.ensureCanEdit(); + const deleted = await this.ctx.db .delete(bookmarksInLists) .where( @@ -480,6 +1001,8 @@ export class ManualList extends List { targetList: List, deleteSourceAfterMerge: boolean, ): Promise<void> { + this.ensureCanManage(); + targetList.ensureCanManage(); if (targetList.type !== "manual") { throw new TRPCError({ code: "BAD_REQUEST", @@ -495,7 +1018,7 @@ export class ManualList extends List { .values( bookmarkIds.map((id) => ({ bookmarkId: id, - listId: targetList.list.id, + listId: targetList.id, })), ) .onConflictDoNothing(); 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"); + }); + }); +}); diff --git a/packages/trpc/testUtils.ts b/packages/trpc/testUtils.ts index b8fe3c30..744b85af 100644 --- a/packages/trpc/testUtils.ts +++ b/packages/trpc/testUtils.ts @@ -24,6 +24,10 @@ export async function seedUsers(db: TestDB) { name: "Test User 2", email: "test2@test.com", }, + { + name: "Test User 3", + email: "test3@test.com", + }, ]) .returning(); } |
