From 88c73e212c4510ce41ad8c6557fa7d5c8f72d199 Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Mon, 17 Nov 2025 01:12:41 +0000 Subject: feat: Add collaborative lists (#2146) * feat: Add collaborative lists backend implementation This commit implements the core backend functionality for collaborative lists, allowing multiple users to share and interact with bookmark lists. Database changes: - Add listCollaborators table to track users with access to lists and their roles (viewer/editor) - Add addedBy field to bookmarksInLists to track who added bookmarks - Add relations for collaborative list functionality Access control updates: - Update List model to support role-based access (owner/editor/viewer) - Add methods to check and enforce permissions for list operations - Update Bookmark model to allow access through collaborative lists - Modify bookmark queries to include bookmarks from collaborative lists List collaboration features: - Add/remove/update collaborators - Get list of collaborators - Get lists shared with current user - Only manual lists can have collaborators tRPC procedures: - addCollaborator: Add a user as a collaborator to a list - removeCollaborator: Remove a collaborator from a list - updateCollaboratorRole: Change a collaborator's role - getCollaborators: Get all collaborators for a list - getSharedWithMe: Get all lists shared with the current user - cloneBookmark: Clone a bookmark to the current user's collection Implementation notes: - Editors can add/remove bookmarks from the list (must own the bookmark) - Viewers can only view bookmarks in the list - Only the list owner can manage collaborators and list metadata - Smart lists cannot have collaborators (only manual lists) - Users cannot edit bookmarks they don't own, even in shared lists * feat: Add collaborative lists frontend UI This commit implements the frontend user interface for collaborative lists, allowing users to view shared bookmarks and manage list collaborators. New pages: - /dashboard/shared: Shows bookmarks from lists shared with the user - Displays bookmarks from all collaborative lists - Uses SharedBookmarks component - Shows empty state when no lists are shared Navigation: - Added "Shared with you" link to sidebar with Users icon - Positioned after "Home" in main navigation - Available in both desktop and mobile sidebar Collaborator management: - ManageCollaboratorsModal component for managing list collaborators - Add collaborators by user ID with viewer/editor role - View current collaborators with their roles - Update collaborator roles inline - Remove collaborators - Shows empty state when no collaborators - Integrated into ListOptions dropdown menu - Accessible via "Manage Collaborators" menu item Components created: - SharedBookmarks.tsx: Server component fetching shared lists/bookmarks - ManageCollaboratorsModal.tsx: Client component with tRPC mutations - /dashboard/shared/page.tsx: Route for shared bookmarks page UI features: - Role selector for viewer/editor permissions - Real-time collaborator list updates - Toast notifications for success/error states - Loading states for async operations - Responsive design matching existing UI patterns Implementation notes: - Uses existing tRPC endpoints (getSharedWithMe, getCollaborators, etc.) - Follows established modal patterns from ShareListModal - Integrates seamlessly with existing list UI - Currently uses user ID for adding collaborators (email lookup TBD) * fix typecheck * add collaborator by email * add shared list in the sidebar * fix perm issue * hide UI components from non list owners * list leaving * fix shared bookmarks showing up in homepage * fix getBookmark access check * e2e tests * hide user specific fields from shared lists * simplify bookmark perm checks * disable editable fields in bookmark preview * hide lists if they don't have options * fix list ownership * fix highlights * move tests to trpc * fix alignment of leave list * make tag lists unclickable * allow editors to remove from list * add a badge for shared lists * remove bookmarks of user when they're removed from a list * fix tests * show owner in the manage collab modal * fix hasCollab * drop shared with you * i18n * beta badge * correctly invalidate caches on collab change * reduce unnecessary changes * Add ratelimits * stop manually removing bookmarks on remove * some fixes * fixes * remove unused function * improve tests --------- Co-authored-by: Claude --- packages/api/routes/assets.ts | 33 +- packages/db/drizzle/0065_collaborative_lists.sql | 16 + packages/db/drizzle/meta/0065_snapshot.json | 2639 ++++++++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/schema.ts | 54 + packages/open-api/karakeep-openapi-spec.json | 32 +- packages/shared/types/bookmarks.ts | 3 + packages/shared/types/lists.ts | 2 + packages/trpc/models/bookmarks.ts | 309 ++- packages/trpc/models/highlights.ts | 8 +- packages/trpc/models/lists.ts | 587 ++++- packages/trpc/routers/bookmarks.test.ts | 8 +- packages/trpc/routers/bookmarks.ts | 298 +-- packages/trpc/routers/highlights.ts | 8 +- packages/trpc/routers/lists.ts | 157 +- packages/trpc/routers/sharedLists.test.ts | 1922 ++++++++++++++++ packages/trpc/testUtils.ts | 4 + 17 files changed, 5761 insertions(+), 326 deletions(-) create mode 100644 packages/db/drizzle/0065_collaborative_lists.sql create mode 100644 packages/db/drizzle/meta/0065_snapshot.json create mode 100644 packages/trpc/routers/sharedLists.test.ts (limited to 'packages') diff --git a/packages/api/routes/assets.ts b/packages/api/routes/assets.ts index 9d9a60b3..50d11c47 100644 --- a/packages/api/routes/assets.ts +++ b/packages/api/routes/assets.ts @@ -1,9 +1,11 @@ import { zValidator } from "@hono/zod-validator"; -import { and, eq } from "drizzle-orm"; +import { TRPCError } from "@trpc/server"; +import { eq } from "drizzle-orm"; import { Hono } from "hono"; import { z } from "zod"; import { assets } from "@karakeep/db/schema"; +import { BareBookmark } from "@karakeep/trpc/models/bookmarks"; import { authMiddleware } from "../middlewares/auth"; import { serveAsset } from "../utils/assets"; @@ -36,13 +38,38 @@ const app = new Hono() .get("/:assetId", async (c) => { const assetId = c.req.param("assetId"); const assetDb = await c.var.ctx.db.query.assets.findFirst({ - where: and(eq(assets.id, assetId), eq(assets.userId, c.var.ctx.user.id)), + where: eq(assets.id, assetId), + columns: { + id: true, + userId: true, + bookmarkId: true, + }, }); if (!assetDb) { return c.json({ error: "Asset not found" }, { status: 404 }); } - return await serveAsset(c, assetId, c.var.ctx.user.id); + + // If asset is not attached to a bookmark yet, only owner can access it + if (!assetDb.bookmarkId) { + if (assetDb.userId !== c.var.ctx.user.id) { + return c.json({ error: "Asset not found" }, { status: 404 }); + } + return await serveAsset(c, assetId, assetDb.userId); + } + + // If asset is attached to a bookmark, check bookmark access permissions + try { + // This throws if the user doesn't have access to the bookmark + await BareBookmark.bareFromId(c.var.ctx, assetDb.bookmarkId); + } catch (e) { + if (e instanceof TRPCError && e.code === "FORBIDDEN") { + return c.json({ error: "Asset not found" }, { status: 404 }); + } + throw e; + } + + return await serveAsset(c, assetId, assetDb.userId); }); export default app; diff --git a/packages/db/drizzle/0065_collaborative_lists.sql b/packages/db/drizzle/0065_collaborative_lists.sql new file mode 100644 index 00000000..81564878 --- /dev/null +++ b/packages/db/drizzle/0065_collaborative_lists.sql @@ -0,0 +1,16 @@ +CREATE TABLE `listCollaborators` ( + `id` text PRIMARY KEY NOT NULL, + `listId` text NOT NULL, + `userId` text NOT NULL, + `role` text NOT NULL, + `createdAt` integer NOT NULL, + `addedBy` text, + FOREIGN KEY (`listId`) REFERENCES `bookmarkLists`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`addedBy`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE INDEX `listCollaborators_listId_idx` ON `listCollaborators` (`listId`);--> statement-breakpoint +CREATE INDEX `listCollaborators_userId_idx` ON `listCollaborators` (`userId`);--> statement-breakpoint +CREATE UNIQUE INDEX `listCollaborators_listId_userId_unique` ON `listCollaborators` (`listId`,`userId`);--> statement-breakpoint +ALTER TABLE `bookmarksInLists` ADD `listMembershipId` text REFERENCES listCollaborators(id) ON UPDATE no action ON DELETE cascade; diff --git a/packages/db/drizzle/meta/0065_snapshot.json b/packages/db/drizzle/meta/0065_snapshot.json new file mode 100644 index 00000000..ce30fc7e --- /dev/null +++ b/packages/db/drizzle/meta/0065_snapshot.json @@ -0,0 +1,2639 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "f14d2087-e465-4cb5-81bc-accff017ee02", + "prevId": "faf078cf-fa55-46e3-8404-bf74628a25e0", + "tables": { + "account": { + "name": "account", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "columns": [ + "provider", + "providerAccountId" + ], + "name": "account_provider_providerAccountId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apiKey": { + "name": "apiKey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyId": { + "name": "keyId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyHash": { + "name": "keyHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "apiKey_keyId_unique": { + "name": "apiKey_keyId_unique", + "columns": [ + "keyId" + ], + "isUnique": true + }, + "apiKey_name_userId_unique": { + "name": "apiKey_name_userId_unique", + "columns": [ + "name", + "userId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "apiKey_userId_user_id_fk": { + "name": "apiKey_userId_user_id_fk", + "tableFrom": "apiKey", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "assets": { + "name": "assets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "assetType": { + "name": "assetType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "contentType": { + "name": "contentType", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fileName": { + "name": "fileName", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "assets_bookmarkId_idx": { + "name": "assets_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + }, + "assets_assetType_idx": { + "name": "assets_assetType_idx", + "columns": [ + "assetType" + ], + "isUnique": false + }, + "assets_userId_idx": { + "name": "assets_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "assets_bookmarkId_bookmarks_id_fk": { + "name": "assets_bookmarkId_bookmarks_id_fk", + "tableFrom": "assets", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "assets_userId_user_id_fk": { + "name": "assets_userId_user_id_fk", + "tableFrom": "assets", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkAssets": { + "name": "bookmarkAssets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "assetType": { + "name": "assetType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "assetId": { + "name": "assetId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fileName": { + "name": "fileName", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sourceUrl": { + "name": "sourceUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "bookmarkAssets_id_bookmarks_id_fk": { + "name": "bookmarkAssets_id_bookmarks_id_fk", + "tableFrom": "bookmarkAssets", + "tableTo": "bookmarks", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkLinks": { + "name": "bookmarkLinks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "publisher": { + "name": "publisher", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "datePublished": { + "name": "datePublished", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dateModified": { + "name": "dateModified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "imageUrl": { + "name": "imageUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "favicon": { + "name": "favicon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "htmlContent": { + "name": "htmlContent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contentAssetId": { + "name": "contentAssetId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "crawledAt": { + "name": "crawledAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "crawlStatus": { + "name": "crawlStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "crawlStatusCode": { + "name": "crawlStatusCode", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 200 + } + }, + "indexes": { + "bookmarkLinks_url_idx": { + "name": "bookmarkLinks_url_idx", + "columns": [ + "url" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bookmarkLinks_id_bookmarks_id_fk": { + "name": "bookmarkLinks_id_bookmarks_id_fk", + "tableFrom": "bookmarkLinks", + "tableTo": "bookmarks", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkLists": { + "name": "bookmarkLists", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "query": { + "name": "query", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parentId": { + "name": "parentId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rssToken": { + "name": "rssToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "bookmarkLists_userId_idx": { + "name": "bookmarkLists_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "bookmarkLists_userId_id_idx": { + "name": "bookmarkLists_userId_id_idx", + "columns": [ + "userId", + "id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "bookmarkLists_userId_user_id_fk": { + "name": "bookmarkLists_userId_user_id_fk", + "tableFrom": "bookmarkLists", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookmarkLists_parentId_bookmarkLists_id_fk": { + "name": "bookmarkLists_parentId_bookmarkLists_id_fk", + "tableFrom": "bookmarkLists", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "parentId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkTags": { + "name": "bookmarkTags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "bookmarkTags_name_idx": { + "name": "bookmarkTags_name_idx", + "columns": [ + "name" + ], + "isUnique": false + }, + "bookmarkTags_userId_idx": { + "name": "bookmarkTags_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "bookmarkTags_userId_name_unique": { + "name": "bookmarkTags_userId_name_unique", + "columns": [ + "userId", + "name" + ], + "isUnique": true + }, + "bookmarkTags_userId_id_idx": { + "name": "bookmarkTags_userId_id_idx", + "columns": [ + "userId", + "id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "bookmarkTags_userId_user_id_fk": { + "name": "bookmarkTags_userId_user_id_fk", + "tableFrom": "bookmarkTags", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkTexts": { + "name": "bookmarkTexts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sourceUrl": { + "name": "sourceUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "bookmarkTexts_id_bookmarks_id_fk": { + "name": "bookmarkTexts_id_bookmarks_id_fk", + "tableFrom": "bookmarkTexts", + "tableTo": "bookmarks", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarks": { + "name": "bookmarks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "modifiedAt": { + "name": "modifiedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived": { + "name": "archived", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "favourited": { + "name": "favourited", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "taggingStatus": { + "name": "taggingStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "summarizationStatus": { + "name": "summarizationStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "bookmarks_userId_idx": { + "name": "bookmarks_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "bookmarks_archived_idx": { + "name": "bookmarks_archived_idx", + "columns": [ + "archived" + ], + "isUnique": false + }, + "bookmarks_favourited_idx": { + "name": "bookmarks_favourited_idx", + "columns": [ + "favourited" + ], + "isUnique": false + }, + "bookmarks_createdAt_idx": { + "name": "bookmarks_createdAt_idx", + "columns": [ + "createdAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bookmarks_userId_user_id_fk": { + "name": "bookmarks_userId_user_id_fk", + "tableFrom": "bookmarks", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarksInLists": { + "name": "bookmarksInLists", + "columns": { + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "listId": { + "name": "listId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "addedAt": { + "name": "addedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "listMembershipId": { + "name": "listMembershipId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "bookmarksInLists_bookmarkId_idx": { + "name": "bookmarksInLists_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + }, + "bookmarksInLists_listId_idx": { + "name": "bookmarksInLists_listId_idx", + "columns": [ + "listId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bookmarksInLists_bookmarkId_bookmarks_id_fk": { + "name": "bookmarksInLists_bookmarkId_bookmarks_id_fk", + "tableFrom": "bookmarksInLists", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookmarksInLists_listId_bookmarkLists_id_fk": { + "name": "bookmarksInLists_listId_bookmarkLists_id_fk", + "tableFrom": "bookmarksInLists", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "listId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookmarksInLists_listMembershipId_listCollaborators_id_fk": { + "name": "bookmarksInLists_listMembershipId_listCollaborators_id_fk", + "tableFrom": "bookmarksInLists", + "tableTo": "listCollaborators", + "columnsFrom": [ + "listMembershipId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bookmarksInLists_bookmarkId_listId_pk": { + "columns": [ + "bookmarkId", + "listId" + ], + "name": "bookmarksInLists_bookmarkId_listId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "config": { + "name": "config", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "customPrompts": { + "name": "customPrompts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appliesTo": { + "name": "appliesTo", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "customPrompts_userId_idx": { + "name": "customPrompts_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "customPrompts_userId_user_id_fk": { + "name": "customPrompts_userId_user_id_fk", + "tableFrom": "customPrompts", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "highlights": { + "name": "highlights", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "startOffset": { + "name": "startOffset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endOffset": { + "name": "endOffset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'yellow'" + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "highlights_bookmarkId_idx": { + "name": "highlights_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + }, + "highlights_userId_idx": { + "name": "highlights_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "highlights_bookmarkId_bookmarks_id_fk": { + "name": "highlights_bookmarkId_bookmarks_id_fk", + "tableFrom": "highlights", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "highlights_userId_user_id_fk": { + "name": "highlights_userId_user_id_fk", + "tableFrom": "highlights", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "importSessionBookmarks": { + "name": "importSessionBookmarks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "importSessionId": { + "name": "importSessionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "importSessionBookmarks_sessionId_idx": { + "name": "importSessionBookmarks_sessionId_idx", + "columns": [ + "importSessionId" + ], + "isUnique": false + }, + "importSessionBookmarks_bookmarkId_idx": { + "name": "importSessionBookmarks_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + }, + "importSessionBookmarks_importSessionId_bookmarkId_unique": { + "name": "importSessionBookmarks_importSessionId_bookmarkId_unique", + "columns": [ + "importSessionId", + "bookmarkId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "importSessionBookmarks_importSessionId_importSessions_id_fk": { + "name": "importSessionBookmarks_importSessionId_importSessions_id_fk", + "tableFrom": "importSessionBookmarks", + "tableTo": "importSessions", + "columnsFrom": [ + "importSessionId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "importSessionBookmarks_bookmarkId_bookmarks_id_fk": { + "name": "importSessionBookmarks_bookmarkId_bookmarks_id_fk", + "tableFrom": "importSessionBookmarks", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "importSessions": { + "name": "importSessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rootListId": { + "name": "rootListId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "modifiedAt": { + "name": "modifiedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "importSessions_userId_idx": { + "name": "importSessions_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "importSessions_userId_user_id_fk": { + "name": "importSessions_userId_user_id_fk", + "tableFrom": "importSessions", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "importSessions_rootListId_bookmarkLists_id_fk": { + "name": "importSessions_rootListId_bookmarkLists_id_fk", + "tableFrom": "importSessions", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "rootListId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invites": { + "name": "invites", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "usedAt": { + "name": "usedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invitedBy": { + "name": "invitedBy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invites_token_unique": { + "name": "invites_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "invites_invitedBy_user_id_fk": { + "name": "invites_invitedBy_user_id_fk", + "tableFrom": "invites", + "tableTo": "user", + "columnsFrom": [ + "invitedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "listCollaborators": { + "name": "listCollaborators", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "listId": { + "name": "listId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "addedBy": { + "name": "addedBy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "listCollaborators_listId_idx": { + "name": "listCollaborators_listId_idx", + "columns": [ + "listId" + ], + "isUnique": false + }, + "listCollaborators_userId_idx": { + "name": "listCollaborators_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "listCollaborators_listId_userId_unique": { + "name": "listCollaborators_listId_userId_unique", + "columns": [ + "listId", + "userId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "listCollaborators_listId_bookmarkLists_id_fk": { + "name": "listCollaborators_listId_bookmarkLists_id_fk", + "tableFrom": "listCollaborators", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "listId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "listCollaborators_userId_user_id_fk": { + "name": "listCollaborators_userId_user_id_fk", + "tableFrom": "listCollaborators", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "listCollaborators_addedBy_user_id_fk": { + "name": "listCollaborators_addedBy_user_id_fk", + "tableFrom": "listCollaborators", + "tableTo": "user", + "columnsFrom": [ + "addedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "passwordResetToken": { + "name": "passwordResetToken", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "passwordResetToken_token_unique": { + "name": "passwordResetToken_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "passwordResetTokens_userId_idx": { + "name": "passwordResetTokens_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "passwordResetToken_userId_user_id_fk": { + "name": "passwordResetToken_userId_user_id_fk", + "tableFrom": "passwordResetToken", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rssFeedImports": { + "name": "rssFeedImports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entryId": { + "name": "entryId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rssFeedId": { + "name": "rssFeedId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "rssFeedImports_feedIdIdx_idx": { + "name": "rssFeedImports_feedIdIdx_idx", + "columns": [ + "rssFeedId" + ], + "isUnique": false + }, + "rssFeedImports_entryIdIdx_idx": { + "name": "rssFeedImports_entryIdIdx_idx", + "columns": [ + "entryId" + ], + "isUnique": false + }, + "rssFeedImports_rssFeedId_entryId_unique": { + "name": "rssFeedImports_rssFeedId_entryId_unique", + "columns": [ + "rssFeedId", + "entryId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "rssFeedImports_rssFeedId_rssFeeds_id_fk": { + "name": "rssFeedImports_rssFeedId_rssFeeds_id_fk", + "tableFrom": "rssFeedImports", + "tableTo": "rssFeeds", + "columnsFrom": [ + "rssFeedId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rssFeedImports_bookmarkId_bookmarks_id_fk": { + "name": "rssFeedImports_bookmarkId_bookmarks_id_fk", + "tableFrom": "rssFeedImports", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rssFeeds": { + "name": "rssFeeds", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "importTags": { + "name": "importTags", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastFetchedAt": { + "name": "lastFetchedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastFetchedStatus": { + "name": "lastFetchedStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "rssFeeds_userId_idx": { + "name": "rssFeeds_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "rssFeeds_userId_user_id_fk": { + "name": "rssFeeds_userId_user_id_fk", + "tableFrom": "rssFeeds", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ruleEngineActions": { + "name": "ruleEngineActions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ruleId": { + "name": "ruleId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "listId": { + "name": "listId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tagId": { + "name": "tagId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "ruleEngineActions_userId_idx": { + "name": "ruleEngineActions_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "ruleEngineActions_ruleId_idx": { + "name": "ruleEngineActions_ruleId_idx", + "columns": [ + "ruleId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ruleEngineActions_userId_user_id_fk": { + "name": "ruleEngineActions_userId_user_id_fk", + "tableFrom": "ruleEngineActions", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ruleEngineActions_ruleId_ruleEngineRules_id_fk": { + "name": "ruleEngineActions_ruleId_ruleEngineRules_id_fk", + "tableFrom": "ruleEngineActions", + "tableTo": "ruleEngineRules", + "columnsFrom": [ + "ruleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ruleEngineActions_userId_tagId_fk": { + "name": "ruleEngineActions_userId_tagId_fk", + "tableFrom": "ruleEngineActions", + "tableTo": "bookmarkTags", + "columnsFrom": [ + "userId", + "tagId" + ], + "columnsTo": [ + "userId", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ruleEngineActions_userId_listId_fk": { + "name": "ruleEngineActions_userId_listId_fk", + "tableFrom": "ruleEngineActions", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "userId", + "listId" + ], + "columnsTo": [ + "userId", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ruleEngineRules": { + "name": "ruleEngineRules", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "condition": { + "name": "condition", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "listId": { + "name": "listId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tagId": { + "name": "tagId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "ruleEngine_userId_idx": { + "name": "ruleEngine_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ruleEngineRules_userId_user_id_fk": { + "name": "ruleEngineRules_userId_user_id_fk", + "tableFrom": "ruleEngineRules", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ruleEngineRules_userId_tagId_fk": { + "name": "ruleEngineRules_userId_tagId_fk", + "tableFrom": "ruleEngineRules", + "tableTo": "bookmarkTags", + "columnsFrom": [ + "userId", + "tagId" + ], + "columnsTo": [ + "userId", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ruleEngineRules_userId_listId_fk": { + "name": "ruleEngineRules_userId_listId_fk", + "tableFrom": "ruleEngineRules", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "userId", + "listId" + ], + "columnsTo": [ + "userId", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "subscriptions": { + "name": "subscriptions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'free'" + }, + "priceId": { + "name": "priceId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancelAtPeriodEnd": { + "name": "cancelAtPeriodEnd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "startDate": { + "name": "startDate", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "endDate": { + "name": "endDate", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "modifiedAt": { + "name": "modifiedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "subscriptions_userId_unique": { + "name": "subscriptions_userId_unique", + "columns": [ + "userId" + ], + "isUnique": true + }, + "subscriptions_userId_idx": { + "name": "subscriptions_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "subscriptions_stripeCustomerId_idx": { + "name": "subscriptions_stripeCustomerId_idx", + "columns": [ + "stripeCustomerId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "subscriptions_userId_user_id_fk": { + "name": "subscriptions_userId_user_id_fk", + "tableFrom": "subscriptions", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tagsOnBookmarks": { + "name": "tagsOnBookmarks", + "columns": { + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tagId": { + "name": "tagId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attachedAt": { + "name": "attachedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attachedBy": { + "name": "attachedBy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tagsOnBookmarks_tagId_idx": { + "name": "tagsOnBookmarks_tagId_idx", + "columns": [ + "tagId" + ], + "isUnique": false + }, + "tagsOnBookmarks_bookmarkId_idx": { + "name": "tagsOnBookmarks_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tagsOnBookmarks_bookmarkId_bookmarks_id_fk": { + "name": "tagsOnBookmarks_bookmarkId_bookmarks_id_fk", + "tableFrom": "tagsOnBookmarks", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tagsOnBookmarks_tagId_bookmarkTags_id_fk": { + "name": "tagsOnBookmarks_tagId_bookmarkTags_id_fk", + "tableFrom": "tagsOnBookmarks", + "tableTo": "bookmarkTags", + "columnsFrom": [ + "tagId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tagsOnBookmarks_bookmarkId_tagId_pk": { + "columns": [ + "bookmarkId", + "tagId" + ], + "name": "tagsOnBookmarks_bookmarkId_tagId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "salt": { + "name": "salt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'user'" + }, + "bookmarkQuota": { + "name": "bookmarkQuota", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storageQuota": { + "name": "storageQuota", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "browserCrawlingEnabled": { + "name": "browserCrawlingEnabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bookmarkClickAction": { + "name": "bookmarkClickAction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open_original_link'" + }, + "archiveDisplayBehaviour": { + "name": "archiveDisplayBehaviour", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'show'" + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'UTC'" + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verificationToken": { + "name": "verificationToken", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "columns": [ + "identifier", + "token" + ], + "name": "verificationToken_identifier_token_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhooks": { + "name": "webhooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "events": { + "name": "events", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "webhooks_userId_idx": { + "name": "webhooks_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "webhooks_userId_user_id_fk": { + "name": "webhooks_userId_user_id_fk", + "tableFrom": "webhooks", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 019ba456..29a877d6 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -456,6 +456,13 @@ "when": 1762115406895, "tag": "0064_add_import_tags_to_feeds", "breakpoints": true + }, + { + "idx": 65, + "version": "6", + "when": 1763335572156, + "tag": "0065_collaborative_lists", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 5f523d21..0479bb52 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -411,6 +411,14 @@ export const bookmarksInLists = sqliteTable( addedAt: integer("addedAt", { mode: "timestamp" }).$defaultFn( () => new Date(), ), + // Tie the list's existence to the user's membership + // of this list. + listMembershipId: text("listMembershipId").references( + () => listCollaborators.id, + { + onDelete: "cascade", + }, + ), }, (tb) => [ primaryKey({ columns: [tb.bookmarkId, tb.listId] }), @@ -419,6 +427,32 @@ export const bookmarksInLists = sqliteTable( ], ); +export const listCollaborators = sqliteTable( + "listCollaborators", + { + id: text("id") + .notNull() + .primaryKey() + .$defaultFn(() => createId()), + listId: text("listId") + .notNull() + .references(() => bookmarkLists.id, { onDelete: "cascade" }), + userId: text("userId") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + role: text("role", { enum: ["viewer", "editor"] }).notNull(), + addedAt: createdAtField(), + addedBy: text("addedBy").references(() => users.id, { + onDelete: "set null", + }), + }, + (lc) => [ + unique().on(lc.listId, lc.userId), + index("listCollaborators_listId_idx").on(lc.listId), + index("listCollaborators_userId_idx").on(lc.userId), + ], +); + export const customPrompts = sqliteTable( "customPrompts", { @@ -698,6 +732,7 @@ export const userRelations = relations(users, ({ many, one }) => ({ invites: many(invites), subscription: one(subscriptions), importSessions: many(importSessions), + listCollaborations: many(listCollaborators), })); export const bookmarkRelations = relations(bookmarks, ({ many, one }) => ({ @@ -767,6 +802,7 @@ export const bookmarkListsRelations = relations( bookmarkLists, ({ one, many }) => ({ bookmarksInLists: many(bookmarksInLists), + collaborators: many(listCollaborators), user: one(users, { fields: [bookmarkLists.userId], references: [users.id], @@ -792,6 +828,24 @@ export const bookmarksInListsRelations = relations( }), ); +export const listCollaboratorsRelations = relations( + listCollaborators, + ({ one }) => ({ + list: one(bookmarkLists, { + fields: [listCollaborators.listId], + references: [bookmarkLists.id], + }), + user: one(users, { + fields: [listCollaborators.userId], + references: [users.id], + }), + addedByUser: one(users, { + fields: [listCollaborators.addedBy], + references: [users.id], + }), + }), +); + export const webhooksRelations = relations(webhooksTable, ({ one }) => ({ user: one(users, { fields: [webhooksTable.userId], diff --git a/packages/open-api/karakeep-openapi-spec.json b/packages/open-api/karakeep-openapi-spec.json index 4f846cef..04596bf0 100644 --- a/packages/open-api/karakeep-openapi-spec.json +++ b/packages/open-api/karakeep-openapi-spec.json @@ -108,6 +108,9 @@ "import" ] }, + "userId": { + "type": "string" + }, "tags": { "type": "array", "items": { @@ -341,6 +344,7 @@ "favourited", "taggingStatus", "summarizationStatus", + "userId", "tags", "content", "assets" @@ -402,6 +406,18 @@ }, "public": { "type": "boolean" + }, + "hasCollaborators": { + "type": "boolean" + }, + "userRole": { + "type": "string", + "enum": [ + "owner", + "editor", + "viewer", + "public" + ] } }, "required": [ @@ -409,7 +425,9 @@ "name", "icon", "parentId", - "public" + "public", + "hasCollaborators", + "userRole" ] }, "Highlight": { @@ -1194,6 +1212,9 @@ "rss", "import" ] + }, + "userId": { + "type": "string" } }, "required": [ @@ -1203,7 +1224,8 @@ "archived", "favourited", "taggingStatus", - "summarizationStatus" + "summarizationStatus", + "userId" ] } } @@ -1318,6 +1340,9 @@ "rss", "import" ] + }, + "userId": { + "type": "string" } }, "required": [ @@ -1327,7 +1352,8 @@ "archived", "favourited", "taggingStatus", - "summarizationStatus" + "summarizationStatus", + "userId" ] } } diff --git a/packages/shared/types/bookmarks.ts b/packages/shared/types/bookmarks.ts index d72525d1..cbaa4574 100644 --- a/packages/shared/types/bookmarks.ts +++ b/packages/shared/types/bookmarks.ts @@ -107,8 +107,11 @@ export const zBareBookmarkSchema = z.object({ note: z.string().nullish(), summary: z.string().nullish(), source: zBookmarkSourceSchema.nullish(), + userId: z.string(), }); +export type ZBareBookmark = z.infer; + export const zBookmarkSchema = zBareBookmarkSchema.merge( z.object({ tags: z.array(zBookmarkTagSchema), diff --git a/packages/shared/types/lists.ts b/packages/shared/types/lists.ts index 59abb007..823b6b8f 100644 --- a/packages/shared/types/lists.ts +++ b/packages/shared/types/lists.ts @@ -57,6 +57,8 @@ export const zBookmarkListSchema = z.object({ type: z.enum(["manual", "smart"]).default("manual"), query: z.string().nullish(), public: z.boolean(), + hasCollaborators: z.boolean(), + userRole: z.enum(["owner", "editor", "viewer", "public"]), }); export type ZBookmarkList = z.infer; 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 +>; + +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 { + 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 { + 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 = new Set( + 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 { 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 { - 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(); + 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(); // 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, ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; abstract getSize(ctx: AuthedContext): Promise; @@ -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 { + 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 { + // 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 { + 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 ->; - -async function cleanupAssetForBookmark( - bookmark: Pick, -) { - const assetIds: Set = new Set( - bookmark.assets.map((a) => a.id), - ); - // Todo: Remove when the bookmark asset is also in the assets table - if (bookmark.asset) { - assetIds.add(bookmark.asset.assetId); - } - await Promise.all( - Array.from(assetIds).map((assetId) => - deleteAsset({ userId: bookmark.userId, assetId }), - ), - ); -} - -async function toZodSchema( - bookmark: BookmarkQueryReturnType, - includeContent: boolean, -): Promise { - const { tagsOnBookmarks, link, text, asset, assets, ...rest } = bookmark; - - let content: ZBookmarkContent = { - type: BookmarkTypes.UNKNOWN, - }; - if (bookmark.link) { - content = { - type: BookmarkTypes.LINK, - screenshotAssetId: assets.find( - (a) => a.assetType == AssetTypes.LINK_SCREENSHOT, - )?.id, - fullPageArchiveAssetId: assets.find( - (a) => a.assetType == AssetTypes.LINK_FULL_PAGE_ARCHIVE, - )?.id, - precrawledArchiveAssetId: assets.find( - (a) => a.assetType == AssetTypes.LINK_PRECRAWLED_ARCHIVE, - )?.id, - imageAssetId: assets.find( - (a) => a.assetType == AssetTypes.LINK_BANNER_IMAGE, - )?.id, - videoAssetId: assets.find((a) => a.assetType == AssetTypes.LINK_VIDEO) - ?.id, - url: link.url, - title: link.title, - description: link.description, - imageUrl: link.imageUrl, - favicon: link.favicon, - htmlContent: includeContent - ? await Bookmark.getBookmarkHtmlContent(link, bookmark.userId) - : null, - crawledAt: link.crawledAt, - author: link.author, - publisher: link.publisher, - datePublished: link.datePublished, - dateModified: link.dateModified, - }; - } - if (bookmark.text) { - content = { - type: BookmarkTypes.TEXT, - // It's ok to include the text content as it's usually not big and is used to render the text bookmark card. - text: text.text ?? "", - sourceUrl: text.sourceUrl, - }; - } - if (bookmark.asset) { - content = { - type: BookmarkTypes.ASSET, - assetType: asset.assetType, - assetId: asset.assetId, - fileName: asset.fileName, - sourceUrl: asset.sourceUrl, - size: assets.find((a) => a.id == asset.assetId)?.size, - content: includeContent ? asset.content : null, - }; - } - - return { - tags: tagsOnBookmarks - .map((t) => ({ - attachedBy: t.attachedBy, - ...t.tag, - })) - .sort((a, b) => - a.attachedBy === "ai" ? 1 : b.attachedBy === "ai" ? -1 : 0, - ), - content, - assets: assets.map((a) => ({ - id: a.id, - assetType: mapDBAssetTypeToUserType(a.assetType), - fileName: a.fileName, - })), - ...rest, - }; + return ( + await Bookmark.fromId(ctx, result[0].id, /* includeContent: */ false) + ).asZBookmark(); } export const bookmarksAppRouter = router({ @@ -620,11 +467,13 @@ export const bookmarksAppRouter = router({ }); // Refetch the updated bookmark data to return the full object - const updatedBookmark = await getBookmark( - ctx, - input.bookmarkId, - /* includeContent: */ false, - ); + const updatedBookmark = ( + await Bookmark.fromId( + ctx, + input.bookmarkId, + /* includeContent: */ false, + ) + ).asZBookmark(); if (input.favourited === true || input.archived === true) { await triggerRuleEngineOnEvent( @@ -686,37 +535,8 @@ export const bookmarksAppRouter = router({ .input(z.object({ bookmarkId: z.string() })) .use(ensureBookmarkOwnership) .mutation(async ({ input, ctx }) => { - const bookmark = await ctx.db.query.bookmarks.findFirst({ - where: and( - eq(bookmarks.id, input.bookmarkId), - eq(bookmarks.userId, ctx.user.id), - ), - with: { - asset: true, - link: true, - assets: true, - }, - }); - const deleted = await ctx.db - .delete(bookmarks) - .where( - and( - eq(bookmarks.userId, ctx.user.id), - eq(bookmarks.id, input.bookmarkId), - ), - ); - await SearchIndexingQueue.enqueue({ - bookmarkId: input.bookmarkId, - type: "delete", - }); - await triggerWebhook(input.bookmarkId, "deleted", ctx.user.id); - if (deleted.changes > 0 && bookmark) { - await cleanupAssetForBookmark({ - asset: bookmark.asset, - userId: ctx.user.id, - assets: bookmark.assets, - }); - } + const bookmark = await Bookmark.fromId(ctx, input.bookmarkId, false); + await bookmark.delete(); }), recrawlBookmark: authedProcedure .use( @@ -754,9 +574,11 @@ export const bookmarksAppRouter = router({ }), ) .output(zBookmarkSchema) - .use(ensureBookmarkOwnership) + .use(ensureBookmarkAccess) .query(async ({ input, ctx }) => { - return await getBookmark(ctx, input.bookmarkId, input.includeContent); + return ( + await Bookmark.fromId(ctx, input.bookmarkId, input.includeContent) + ).asZBookmark(); }), searchBookmarks: authedProcedure .input(zSearchBookmarksRequestSchema) @@ -818,25 +640,11 @@ export const bookmarksAppRouter = router({ acc[r.id] = r.score || 0; return acc; }, {}); - const results = await ctx.db.query.bookmarks.findMany({ - where: and( - eq(bookmarks.userId, ctx.user.id), - inArray( - bookmarks.id, - resp.hits.map((h) => h.id), - ), - ), - with: { - tagsOnBookmarks: { - with: { - tag: true, - }, - }, - link: true, - text: true, - asset: true, - assets: true, - }, + + const { bookmarks: results } = await Bookmark.loadMulti(ctx, { + ids: resp.hits.map((h) => h.id), + includeContent: input.includeContent, + sortOrder: "desc", // Doesn't matter, we're sorting again afterwards and the list contain all data }); switch (true) { @@ -852,9 +660,7 @@ export const bookmarksAppRouter = router({ } return { - bookmarks: await Promise.all( - results.map((b) => toZodSchema(b, input.includeContent)), - ), + bookmarks: results.map((b) => b.asZBookmark()), nextCursor: resp.hits.length + (input.cursor?.offset || 0) >= resp.totalHits ? null 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(defaultBeforeEach(true)); + +describe("Shared Lists", () => { + describe("List Collaboration Management", () => { + test("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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(); } -- cgit v1.2.3-70-g09d2