aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-11-23 00:54:38 +0000
committerGitHub <noreply@github.com>2025-11-23 00:54:38 +0000
commit5f0934acc0f7dde119be9f0a42a42742ec128377 (patch)
treef13bd90961eab0c694eed101db0eea96e0fc4725 /packages
parentdaee8e7a4f764f188e1773a9def1542513bf66e1 (diff)
downloadkarakeep-5f0934acc0f7dde119be9f0a42a42742ec128377.tar.zst
feat: Add invitation approval for shared lists (#2152)
* feat: Add invitation approval system for collaborative lists - Add database schema changes to support pending invitations - Add status field (pending/accepted/declined) to listCollaborators - Add invitedAt and invitedEmail fields for tracking - Add index on status for efficient queries - Update List model with invitation workflow methods - Modify addCollaboratorByEmail to create pending invitations - Add acceptInvitation() for users to accept invites - Add declineInvitation() for users to decline invites - Add revokeInvitation() for owners to revoke pending invites - Add getPendingInvitations() to get user's pending invites - Implement privacy protection for pending invitations - Mask user names as "Pending User" until invitation is accepted - Only show email to list owner for pending invitations - Update getSharedWithUser to only include accepted collaborations - Ensures lists only appear after invitation is accepted * feat: Add tRPC procedures and email notifications for list invitations - Add new tRPC procedures for invitation workflow - acceptInvitation: Allow users to accept pending invitations - declineInvitation: Allow users to decline invitations - revokeInvitation: Allow owners to revoke pending invitations - getPendingInvitations: Get all pending invitations for current user - Update getCollaborators output schema - Add status, invitedAt fields to collaborator objects - Support privacy-masked user info for pending invitations - Add sendListInvitationEmail function - Email notification when user is invited to collaborate - Includes list name, inviter name, and link to view invitation - Gracefully handles missing SMTP configuration - Integrate email sending into invitation workflow - Send email when new invitation is created - Send email when declined invitation is renewed - Catch and log errors without failing the invitation * feat: Add UI for list invitation approval workflow - Update ManageCollaboratorsModal to support pending invitations - Show "Pending" badge for pending invitations - Add revoke button for owners to cancel pending invitations - Update success message to reflect invitation sent - Disable role change and remove buttons for pending invitations - Create PendingInvitationsCard component - Display all pending invitations for the current user - Show list name, description, inviter, and role - Provide Accept and Decline buttons - Auto-hide when no pending invitations exist - Add PendingInvitationsCard to lists page - Show at the top of the lists page - Only renders when user has pending invitations * fix: Add missing translation keys and fix TypeScript errors - Add translation keys for invitation system - lists.collaborators.invitation_sent - lists.collaborators.pending - lists.collaborators.revoke - lists.collaborators.invitation_revoked - lists.collaborators.failed_to_revoke - lists.invitations.* (all invitation-related keys) - Fix TypeScript errors in email sending - Handle optional user.name with fallback to 'A user' * wip * fixes * more fixes * fix revoke * more improvements * comment fix * fix email url * fix schemas * split pending invites into components * more fixes * test * test fixes --------- Co-authored-by: Claude <noreply@anthropic.com>
Diffstat (limited to 'packages')
-rw-r--r--packages/db/drizzle/0066_collaborative_lists_invites.sql18
-rw-r--r--packages/db/drizzle/meta/0066_snapshot.json2776
-rw-r--r--packages/db/drizzle/meta/_journal.json7
-rw-r--r--packages/db/schema.ts53
-rw-r--r--packages/trpc/email.ts63
-rw-r--r--packages/trpc/models/listInvitations.ts398
-rw-r--r--packages/trpc/models/lists.ts114
-rw-r--r--packages/trpc/routers/lists.ts92
-rw-r--r--packages/trpc/routers/sharedLists.test.ts1461
9 files changed, 4643 insertions, 339 deletions
diff --git a/packages/db/drizzle/0066_collaborative_lists_invites.sql b/packages/db/drizzle/0066_collaborative_lists_invites.sql
new file mode 100644
index 00000000..76c45334
--- /dev/null
+++ b/packages/db/drizzle/0066_collaborative_lists_invites.sql
@@ -0,0 +1,18 @@
+CREATE TABLE `listInvitations` (
+ `id` text PRIMARY KEY NOT NULL,
+ `listId` text NOT NULL,
+ `userId` text NOT NULL,
+ `role` text NOT NULL,
+ `status` text DEFAULT 'pending' NOT NULL,
+ `invitedAt` integer NOT NULL,
+ `invitedEmail` text,
+ `invitedBy` 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 (`invitedBy`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null
+);
+--> statement-breakpoint
+CREATE INDEX `listInvitations_listId_idx` ON `listInvitations` (`listId`);--> statement-breakpoint
+CREATE INDEX `listInvitations_userId_idx` ON `listInvitations` (`userId`);--> statement-breakpoint
+CREATE INDEX `listInvitations_status_idx` ON `listInvitations` (`status`);--> statement-breakpoint
+CREATE UNIQUE INDEX `listInvitations_listId_userId_unique` ON `listInvitations` (`listId`,`userId`); \ No newline at end of file
diff --git a/packages/db/drizzle/meta/0066_snapshot.json b/packages/db/drizzle/meta/0066_snapshot.json
new file mode 100644
index 00000000..affb3f40
--- /dev/null
+++ b/packages/db/drizzle/meta/0066_snapshot.json
@@ -0,0 +1,2776 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "f5049a1f-7de4-4107-9b8c-c13118699381",
+ "prevId": "f14d2087-e465-4cb5-81bc-accff017ee02",
+ "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": {}
+ },
+ "listInvitations": {
+ "name": "listInvitations",
+ "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
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "invitedAt": {
+ "name": "invitedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "invitedEmail": {
+ "name": "invitedEmail",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "invitedBy": {
+ "name": "invitedBy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "listInvitations_listId_idx": {
+ "name": "listInvitations_listId_idx",
+ "columns": [
+ "listId"
+ ],
+ "isUnique": false
+ },
+ "listInvitations_userId_idx": {
+ "name": "listInvitations_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "listInvitations_status_idx": {
+ "name": "listInvitations_status_idx",
+ "columns": [
+ "status"
+ ],
+ "isUnique": false
+ },
+ "listInvitations_listId_userId_unique": {
+ "name": "listInvitations_listId_userId_unique",
+ "columns": [
+ "listId",
+ "userId"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "listInvitations_listId_bookmarkLists_id_fk": {
+ "name": "listInvitations_listId_bookmarkLists_id_fk",
+ "tableFrom": "listInvitations",
+ "tableTo": "bookmarkLists",
+ "columnsFrom": [
+ "listId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "listInvitations_userId_user_id_fk": {
+ "name": "listInvitations_userId_user_id_fk",
+ "tableFrom": "listInvitations",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "listInvitations_invitedBy_user_id_fk": {
+ "name": "listInvitations_invitedBy_user_id_fk",
+ "tableFrom": "listInvitations",
+ "tableTo": "user",
+ "columnsFrom": [
+ "invitedBy"
+ ],
+ "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 29a877d6..80b91ea6 100644
--- a/packages/db/drizzle/meta/_journal.json
+++ b/packages/db/drizzle/meta/_journal.json
@@ -463,6 +463,13 @@
"when": 1763335572156,
"tag": "0065_collaborative_lists",
"breakpoints": true
+ },
+ {
+ "idx": 66,
+ "version": "6",
+ "when": 1763854050669,
+ "tag": "0066_collaborative_lists_invites",
+ "breakpoints": true
}
]
} \ No newline at end of file
diff --git a/packages/db/schema.ts b/packages/db/schema.ts
index 0479bb52..d5fd162d 100644
--- a/packages/db/schema.ts
+++ b/packages/db/schema.ts
@@ -453,6 +453,39 @@ export const listCollaborators = sqliteTable(
],
);
+export const listInvitations = sqliteTable(
+ "listInvitations",
+ {
+ 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(),
+ status: text("status", { enum: ["pending", "declined"] })
+ .notNull()
+ .default("pending"),
+ invitedAt: integer("invitedAt", { mode: "timestamp" })
+ .notNull()
+ .$defaultFn(() => new Date()),
+ invitedEmail: text("invitedEmail"),
+ invitedBy: text("invitedBy").references(() => users.id, {
+ onDelete: "set null",
+ }),
+ },
+ (li) => [
+ unique().on(li.listId, li.userId),
+ index("listInvitations_listId_idx").on(li.listId),
+ index("listInvitations_userId_idx").on(li.userId),
+ index("listInvitations_status_idx").on(li.status),
+ ],
+);
+
export const customPrompts = sqliteTable(
"customPrompts",
{
@@ -733,6 +766,7 @@ export const userRelations = relations(users, ({ many, one }) => ({
subscription: one(subscriptions),
importSessions: many(importSessions),
listCollaborations: many(listCollaborators),
+ listInvitations: many(listInvitations),
}));
export const bookmarkRelations = relations(bookmarks, ({ many, one }) => ({
@@ -803,6 +837,7 @@ export const bookmarkListsRelations = relations(
({ one, many }) => ({
bookmarksInLists: many(bookmarksInLists),
collaborators: many(listCollaborators),
+ invitations: many(listInvitations),
user: one(users, {
fields: [bookmarkLists.userId],
references: [users.id],
@@ -846,6 +881,24 @@ export const listCollaboratorsRelations = relations(
}),
);
+export const listInvitationsRelations = relations(
+ listInvitations,
+ ({ one }) => ({
+ list: one(bookmarkLists, {
+ fields: [listInvitations.listId],
+ references: [bookmarkLists.id],
+ }),
+ user: one(users, {
+ fields: [listInvitations.userId],
+ references: [users.id],
+ }),
+ invitedByUser: one(users, {
+ fields: [listInvitations.invitedBy],
+ references: [users.id],
+ }),
+ }),
+);
+
export const webhooksRelations = relations(webhooksTable, ({ one }) => ({
user: one(users, {
fields: [webhooksTable.userId],
diff --git a/packages/trpc/email.ts b/packages/trpc/email.ts
index edf8ec92..3c0b8b39 100644
--- a/packages/trpc/email.ts
+++ b/packages/trpc/email.ts
@@ -179,3 +179,66 @@ If you didn't request a password reset, please ignore this email. Your password
await transporter.sendMail(mailOptions);
}
+
+export async function sendListInvitationEmail(
+ email: string,
+ inviterName: string,
+ listName: string,
+ listId: string,
+) {
+ if (!serverConfig.email.smtp) {
+ // Silently fail if email is not configured
+ return;
+ }
+
+ const transporter = createTransport({
+ host: serverConfig.email.smtp.host,
+ port: serverConfig.email.smtp.port,
+ secure: serverConfig.email.smtp.secure,
+ auth:
+ serverConfig.email.smtp.user && serverConfig.email.smtp.password
+ ? {
+ user: serverConfig.email.smtp.user,
+ pass: serverConfig.email.smtp.password,
+ }
+ : undefined,
+ });
+
+ const inviteUrl = `${serverConfig.publicUrl}/dashboard/lists?pendingInvitation=${encodeURIComponent(listId)}`;
+
+ const mailOptions = {
+ from: serverConfig.email.smtp.from,
+ to: email,
+ subject: `${inviterName} invited you to collaborate on "${listName}"`,
+ html: `
+ <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
+ <h2>You've been invited to collaborate on a list!</h2>
+ <p>${inviterName} has invited you to collaborate on the list <strong>"${listName}"</strong> in Karakeep.</p>
+ <p>Click the link below to view and accept or decline the invitation:</p>
+ <p>
+ <a href="${inviteUrl}" style="background-color: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;">
+ View Invitation
+ </a>
+ </p>
+ <p>If the button doesn't work, you can copy and paste this link into your browser:</p>
+ <p><a href="${inviteUrl}">${inviteUrl}</a></p>
+ <p>You can accept or decline this invitation from your Karakeep dashboard.</p>
+ <p>If you weren't expecting this invitation, you can safely ignore this email or decline it in your dashboard.</p>
+ </div>
+ `,
+ text: `
+You've been invited to collaborate on a list!
+
+${inviterName} has invited you to collaborate on the list "${listName}" in Karakeep.
+
+View your invitation by visiting this link:
+${inviteUrl}
+
+You can accept or decline this invitation from your Karakeep dashboard.
+
+If you weren't expecting this invitation, you can safely ignore this email or decline it in your dashboard.
+ `,
+ };
+
+ await transporter.sendMail(mailOptions);
+}
diff --git a/packages/trpc/models/listInvitations.ts b/packages/trpc/models/listInvitations.ts
new file mode 100644
index 00000000..6bdc8ffa
--- /dev/null
+++ b/packages/trpc/models/listInvitations.ts
@@ -0,0 +1,398 @@
+import { TRPCError } from "@trpc/server";
+import { and, eq } from "drizzle-orm";
+
+import { listCollaborators, listInvitations } from "@karakeep/db/schema";
+
+import type { AuthedContext } from "..";
+
+type Role = "viewer" | "editor";
+type InvitationStatus = "pending" | "declined";
+
+interface InvitationData {
+ id: string;
+ listId: string;
+ userId: string;
+ role: Role;
+ status: InvitationStatus;
+ invitedAt: Date;
+ invitedEmail: string | null;
+ invitedBy: string | null;
+ listOwnerUserId: string;
+}
+
+export class ListInvitation {
+ protected constructor(
+ protected ctx: AuthedContext,
+ protected invitation: InvitationData,
+ ) {}
+
+ get id() {
+ return this.invitation.id;
+ }
+
+ /**
+ * Load an invitation by ID
+ * Can be accessed by:
+ * - The invited user (userId matches)
+ * - The list owner (via list ownership check)
+ */
+ static async fromId(
+ ctx: AuthedContext,
+ invitationId: string,
+ ): Promise<ListInvitation> {
+ const invitation = await ctx.db.query.listInvitations.findFirst({
+ where: eq(listInvitations.id, invitationId),
+ with: {
+ list: {
+ columns: {
+ userId: true,
+ },
+ },
+ },
+ });
+
+ if (!invitation) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Invitation not found",
+ });
+ }
+
+ // Check if user has access to this invitation
+ const isInvitedUser = invitation.userId === ctx.user.id;
+ const isListOwner = invitation.list.userId === ctx.user.id;
+
+ if (!isInvitedUser && !isListOwner) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Invitation not found",
+ });
+ }
+
+ return new ListInvitation(ctx, {
+ id: invitation.id,
+ listId: invitation.listId,
+ userId: invitation.userId,
+ role: invitation.role,
+ status: invitation.status,
+ invitedAt: invitation.invitedAt,
+ invitedEmail: invitation.invitedEmail,
+ invitedBy: invitation.invitedBy,
+ listOwnerUserId: invitation.list.userId,
+ });
+ }
+
+ /**
+ * Ensure the current user is the invited user
+ */
+ ensureIsInvitedUser() {
+ if (this.invitation.userId !== this.ctx.user.id) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Only the invited user can perform this action",
+ });
+ }
+ }
+
+ /**
+ * Ensure the current user is the list owner
+ */
+ ensureIsListOwner() {
+ if (this.invitation.listOwnerUserId !== this.ctx.user.id) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Only the list owner can perform this action",
+ });
+ }
+ }
+
+ /**
+ * Accept the invitation
+ */
+ async accept(): Promise<void> {
+ this.ensureIsInvitedUser();
+
+ if (this.invitation.status !== "pending") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Only pending invitations can be accepted",
+ });
+ }
+
+ await this.ctx.db.transaction(async (tx) => {
+ await tx
+ .delete(listInvitations)
+ .where(eq(listInvitations.id, this.invitation.id));
+
+ await tx
+ .insert(listCollaborators)
+ .values({
+ listId: this.invitation.listId,
+ userId: this.invitation.userId,
+ role: this.invitation.role,
+ addedBy: this.invitation.invitedBy,
+ })
+ .onConflictDoNothing();
+ });
+ }
+
+ /**
+ * Decline the invitation
+ */
+ async decline(): Promise<void> {
+ this.ensureIsInvitedUser();
+
+ if (this.invitation.status !== "pending") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Only pending invitations can be declined",
+ });
+ }
+
+ await this.ctx.db
+ .update(listInvitations)
+ .set({
+ status: "declined",
+ })
+ .where(eq(listInvitations.id, this.invitation.id));
+ }
+
+ /**
+ * Revoke the invitation (owner only)
+ */
+ async revoke(): Promise<void> {
+ this.ensureIsListOwner();
+
+ await this.ctx.db
+ .delete(listInvitations)
+ .where(eq(listInvitations.id, this.invitation.id));
+ }
+
+ /**
+ * @returns the invitation ID
+ */
+ static async inviteByEmail(
+ ctx: AuthedContext,
+ params: {
+ email: string;
+ role: Role;
+ listId: string;
+ listName: string;
+ listType: "manual" | "smart";
+ listOwnerId: string;
+ inviterUserId: string;
+ inviterName: string | null;
+ },
+ ): Promise<string> {
+ const {
+ email,
+ role,
+ listId,
+ listName,
+ listType,
+ listOwnerId,
+ inviterUserId,
+ inviterName,
+ } = params;
+
+ const user = await 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",
+ });
+ }
+
+ if (user.id === listOwnerId) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Cannot add the list owner as a collaborator",
+ });
+ }
+
+ if (listType !== "manual") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Only manual lists can have collaborators",
+ });
+ }
+
+ const existingCollaborator = await ctx.db.query.listCollaborators.findFirst(
+ {
+ where: and(
+ eq(listCollaborators.listId, listId),
+ eq(listCollaborators.userId, user.id),
+ ),
+ },
+ );
+
+ if (existingCollaborator) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "User is already a collaborator on this list",
+ });
+ }
+
+ const existingInvitation = await ctx.db.query.listInvitations.findFirst({
+ where: and(
+ eq(listInvitations.listId, listId),
+ eq(listInvitations.userId, user.id),
+ ),
+ });
+
+ if (existingInvitation) {
+ if (existingInvitation.status === "pending") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "User already has a pending invitation for this list",
+ });
+ } else if (existingInvitation.status === "declined") {
+ await ctx.db
+ .update(listInvitations)
+ .set({
+ status: "pending",
+ role,
+ invitedAt: new Date(),
+ invitedEmail: email,
+ invitedBy: inviterUserId,
+ })
+ .where(eq(listInvitations.id, existingInvitation.id));
+
+ await this.sendInvitationEmail({
+ email,
+ inviterName,
+ listName,
+ listId,
+ });
+ return existingInvitation.id;
+ }
+ }
+
+ const res = await ctx.db
+ .insert(listInvitations)
+ .values({
+ listId,
+ userId: user.id,
+ role,
+ status: "pending",
+ invitedEmail: email,
+ invitedBy: inviterUserId,
+ })
+ .returning();
+
+ await this.sendInvitationEmail({
+ email,
+ inviterName,
+ listName,
+ listId,
+ });
+ return res[0].id;
+ }
+
+ static async pendingForUser(ctx: AuthedContext) {
+ const invitations = await ctx.db.query.listInvitations.findMany({
+ where: and(
+ eq(listInvitations.userId, ctx.user.id),
+ eq(listInvitations.status, "pending"),
+ ),
+ with: {
+ list: {
+ columns: {
+ id: true,
+ name: true,
+ icon: true,
+ description: true,
+ rssToken: false,
+ },
+ with: {
+ user: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ },
+ },
+ },
+ },
+ },
+ });
+
+ return invitations.map((inv) => ({
+ id: inv.id,
+ listId: inv.listId,
+ role: inv.role,
+ invitedAt: inv.invitedAt,
+ list: {
+ id: inv.list.id,
+ name: inv.list.name,
+ icon: inv.list.icon,
+ description: inv.list.description,
+ owner: inv.list.user
+ ? {
+ id: inv.list.user.id,
+ name: inv.list.user.name,
+ email: inv.list.user.email,
+ }
+ : null,
+ },
+ }));
+ }
+
+ static async invitationsForList(
+ ctx: AuthedContext,
+ params: { listId: string },
+ ) {
+ const invitations = await ctx.db.query.listInvitations.findMany({
+ where: eq(listInvitations.listId, params.listId),
+ with: {
+ user: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ },
+ },
+ },
+ });
+
+ return invitations.map((invitation) => ({
+ id: invitation.id,
+ listId: invitation.listId,
+ userId: invitation.userId,
+ role: invitation.role,
+ status: invitation.status,
+ invitedAt: invitation.invitedAt,
+ addedAt: invitation.invitedAt,
+ user: {
+ id: invitation.user.id,
+ // Don't show the actual user's name for any invitation (pending or declined)
+ // This protects user privacy until they accept
+ name: "Pending User",
+ email: invitation.user.email || "",
+ },
+ }));
+ }
+
+ static async sendInvitationEmail(params: {
+ email: string;
+ inviterName: string | null;
+ listName: string;
+ listId: string;
+ }) {
+ try {
+ const { sendListInvitationEmail } = await import("../email");
+ await sendListInvitationEmail(
+ params.email,
+ params.inviterName || "A user",
+ params.listName,
+ params.listId,
+ );
+ } catch (error) {
+ // Log the error but don't fail the invitation
+ console.error("Failed to send list invitation email:", error);
+ }
+ }
+}
diff --git a/packages/trpc/models/lists.ts b/packages/trpc/models/lists.ts
index 2250819f..a0d9ca23 100644
--- a/packages/trpc/models/lists.ts
+++ b/packages/trpc/models/lists.ts
@@ -26,6 +26,7 @@ import { AuthedContext, Context } from "..";
import { buildImpersonatingAuthedContext } from "../lib/impersonate";
import { getBookmarkIdsFromMatcher } from "../lib/search";
import { Bookmark } from "./bookmarks";
+import { ListInvitation } from "./listInvitations";
interface ListCollaboratorEntry {
membershipId: string;
@@ -536,7 +537,7 @@ export abstract class List {
throw new TRPCError({ code: "NOT_FOUND" });
}
invariant(result[0].userId === this.ctx.user.id);
- // Fetch current collaborators count to update hasCollaborators
+ // Fetch current collaborators to update hasCollaborators
const collaboratorsCount =
await this.ctx.db.query.listCollaborators.findMany({
where: eq(listCollaborators.listId, this.list.id),
@@ -596,61 +597,24 @@ export abstract class List {
/**
* Add a collaborator to this list by email.
+ * Creates a pending invitation that must be accepted by the user.
+ * Returns the invitation ID.
*/
async addCollaboratorByEmail(
email: string,
role: "viewer" | "editor",
- ): Promise<void> {
+ ): Promise<string> {
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,
+ return await ListInvitation.inviteByEmail(this.ctx, {
+ email,
role,
- addedBy: this.ctx.user.id,
+ listId: this.list.id,
+ listName: this.list.name,
+ listType: this.list.type,
+ listOwnerId: this.list.userId,
+ inviterUserId: this.ctx.user.id,
+ inviterName: this.ctx.user.name ?? null,
});
}
@@ -738,23 +702,34 @@ export abstract class List {
}
/**
- * Get all collaborators for this list.
+ * Get all collaborators for this list, including pending invitations.
+ * For privacy, pending invitations show masked user info unless the invitation has been accepted.
*/
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,
+ const isOwner = this.list.userId === this.ctx.user.id;
+
+ const [collaborators, invitations] = await Promise.all([
+ this.ctx.db.query.listCollaborators.findMany({
+ where: eq(listCollaborators.listId, this.list.id),
+ with: {
+ user: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ },
},
},
- },
- });
+ }),
+ // Only show invitations for the owner
+ isOwner
+ ? ListInvitation.invitationsForList(this.ctx, {
+ listId: this.list.id,
+ })
+ : [],
+ ]);
// Get the owner information
const owner = await this.ctx.db.query.users.findFirst({
@@ -766,14 +741,24 @@ export abstract class List {
},
});
- return {
- collaborators: collaborators.map((c) => ({
+ const collaboratorEntries = collaborators.map((c) => {
+ return {
id: c.id,
userId: c.userId,
role: c.role,
+ status: "accepted" as const,
addedAt: c.addedAt,
- user: c.user,
- })),
+ invitedAt: c.addedAt,
+ user: {
+ id: c.user.id,
+ name: c.user.name,
+ email: c.user.email,
+ },
+ };
+ });
+
+ return {
+ collaborators: [...collaboratorEntries, ...invitations],
owner: owner
? {
id: owner.id,
@@ -786,6 +771,7 @@ export abstract class List {
/**
* Get all lists shared with the user (as a collaborator).
+ * Only includes lists where the invitation has been accepted.
*/
static async getSharedWithUser(
ctx: AuthedContext,
diff --git a/packages/trpc/routers/lists.ts b/packages/trpc/routers/lists.ts
index c9a19f30..5eb0baff 100644
--- a/packages/trpc/routers/lists.ts
+++ b/packages/trpc/routers/lists.ts
@@ -10,6 +10,7 @@ import {
import type { AuthedContext } from "../index";
import { authedProcedure, createRateLimitMiddleware, router } from "../index";
+import { ListInvitation } from "../models/listInvitations";
import { List } from "../models/lists";
import { ensureBookmarkOwnership } from "./bookmarks";
@@ -47,6 +48,22 @@ export const ensureListAtLeastOwner = experimental_trpcMiddleware<{
});
});
+export const ensureInvitationAccess = experimental_trpcMiddleware<{
+ ctx: AuthedContext;
+ input: { invitationId: string };
+}>().create(async (opts) => {
+ const invitation = await ListInvitation.fromId(
+ opts.ctx,
+ opts.input.invitationId,
+ );
+ return opts.next({
+ ctx: {
+ ...opts.ctx,
+ invitation,
+ },
+ });
+});
+
export const listsAppRouter = router({
create: authedProcedure
.input(zNewBookmarkListSchema)
@@ -218,6 +235,11 @@ export const listsAppRouter = router({
role: z.enum(["viewer", "editor"]),
}),
)
+ .output(
+ z.object({
+ invitationId: z.string(),
+ }),
+ )
.use(
createRateLimitMiddleware({
name: "lists.addCollaborator",
@@ -228,7 +250,12 @@ export const listsAppRouter = router({
.use(ensureListAtLeastViewer)
.use(ensureListAtLeastOwner)
.mutation(async ({ input, ctx }) => {
- await ctx.list.addCollaboratorByEmail(input.email, input.role);
+ return {
+ invitationId: await ctx.list.addCollaboratorByEmail(
+ input.email,
+ input.role,
+ ),
+ };
}),
removeCollaborator: authedProcedure
.input(
@@ -268,7 +295,9 @@ export const listsAppRouter = router({
id: z.string(),
userId: z.string(),
role: z.enum(["viewer", "editor"]),
+ status: z.enum(["pending", "accepted", "declined"]),
addedAt: z.date(),
+ invitedAt: z.date(),
user: z.object({
id: z.string(),
name: z.string(),
@@ -290,6 +319,67 @@ export const listsAppRouter = router({
return await ctx.list.getCollaborators();
}),
+ acceptInvitation: authedProcedure
+ .input(
+ z.object({
+ invitationId: z.string(),
+ }),
+ )
+ .use(ensureInvitationAccess)
+ .mutation(async ({ ctx }) => {
+ await ctx.invitation.accept();
+ }),
+
+ declineInvitation: authedProcedure
+ .input(
+ z.object({
+ invitationId: z.string(),
+ }),
+ )
+ .use(ensureInvitationAccess)
+ .mutation(async ({ ctx }) => {
+ await ctx.invitation.decline();
+ }),
+
+ revokeInvitation: authedProcedure
+ .input(
+ z.object({
+ invitationId: z.string(),
+ }),
+ )
+ .use(ensureInvitationAccess)
+ .mutation(async ({ ctx }) => {
+ await ctx.invitation.revoke();
+ }),
+
+ getPendingInvitations: authedProcedure
+ .output(
+ z.array(
+ z.object({
+ id: z.string(),
+ listId: z.string(),
+ role: z.enum(["viewer", "editor"]),
+ invitedAt: z.date(),
+ list: z.object({
+ id: z.string(),
+ name: z.string(),
+ icon: z.string(),
+ description: z.string().nullable(),
+ owner: z
+ .object({
+ id: z.string(),
+ name: z.string(),
+ email: z.string(),
+ })
+ .nullable(),
+ }),
+ }),
+ ),
+ )
+ .query(async ({ ctx }) => {
+ return ListInvitation.pendingForUser(ctx);
+ }),
+
leaveList: authedProcedure
.input(
z.object({
diff --git a/packages/trpc/routers/sharedLists.test.ts b/packages/trpc/routers/sharedLists.test.ts
index 3b95a033..58a24d46 100644
--- a/packages/trpc/routers/sharedLists.test.ts
+++ b/packages/trpc/routers/sharedLists.test.ts
@@ -2,11 +2,35 @@ import { beforeEach, describe, expect, test } from "vitest";
import { BookmarkTypes } from "@karakeep/shared/types/bookmarks";
-import type { CustomTestContext } from "../testUtils";
+import type { APICallerType, CustomTestContext } from "../testUtils";
import { defaultBeforeEach } from "../testUtils";
beforeEach<CustomTestContext>(defaultBeforeEach(true));
+/**
+ * Helper function to add a collaborator and have them accept the invitation
+ */
+async function addAndAcceptCollaborator(
+ ownerApi: APICallerType,
+ collaboratorApi: APICallerType,
+ listId: string,
+ role: "viewer" | "editor",
+) {
+ const collaboratorUser = await collaboratorApi.users.whoami();
+
+ // Owner invites the collaborator
+ const { invitationId } = await ownerApi.lists.addCollaborator({
+ listId,
+ email: collaboratorUser.email!,
+ role,
+ });
+
+ // Collaborator accepts the invitation
+ await collaboratorApi.lists.acceptInvitation({
+ invitationId,
+ });
+}
+
describe("Shared Lists", () => {
describe("List Collaboration Management", () => {
test<CustomTestContext>("should allow owner to add a collaborator by email", async ({
@@ -26,12 +50,13 @@ describe("Shared Lists", () => {
const collaboratorUser = await collaboratorApi.users.whoami();
const collaboratorEmail = collaboratorUser.email!;
- // Add collaborator
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "viewer",
- });
+ // Add collaborator (creates pending invitation)
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
// Verify collaborator was added
const { collaborators, owner } = await ownerApi.lists.getCollaborators({
@@ -84,13 +109,27 @@ describe("Shared Lists", () => {
const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
+ const { invitationId } = await ownerApi.lists.addCollaborator({
listId: list.id,
email: collaboratorEmail,
role: "viewer",
});
- // Try to add same collaborator again
+ // Try to add same collaborator again (should fail - pending invitation exists)
+ await expect(
+ ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "editor",
+ }),
+ ).rejects.toThrow("User already has a pending invitation for this list");
+
+ // Accept the invitation
+ await collaboratorApi.lists.acceptInvitation({
+ invitationId,
+ });
+
+ // Try to add them again after they're a collaborator
await expect(
ownerApi.lists.addCollaborator({
listId: list.id,
@@ -114,11 +153,12 @@ describe("Shared Lists", () => {
const collaboratorUser = await collaboratorApi.users.whoami();
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorUser.email!,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
// Update role to editor
await ownerApi.lists.updateCollaboratorRole({
@@ -150,11 +190,12 @@ describe("Shared Lists", () => {
const collaboratorUser = await collaboratorApi.users.whoami();
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorUser.email!,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
// Remove collaborator
await ownerApi.lists.removeCollaborator({
@@ -223,11 +264,12 @@ describe("Shared Lists", () => {
const collaboratorUser = await collaboratorApi.users.whoami();
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorUser.email!,
- role: "editor",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "editor",
+ );
// Collaborator adds their own bookmark
const collabBookmark = await collaboratorApi.bookmarks.createBookmark({
@@ -279,13 +321,12 @@ describe("Shared Lists", () => {
type: "manual",
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
-
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
// Collaborator leaves the list
await collaboratorApi.lists.leaveList({
@@ -331,13 +372,12 @@ describe("Shared Lists", () => {
bookmarkId: ownerBookmark.id,
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
-
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "editor",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "editor",
+ );
// Collaborator adds their own bookmark
const collabBookmark = await collaboratorApi.bookmarks.createBookmark({
@@ -434,13 +474,12 @@ describe("Shared Lists", () => {
type: "manual",
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
-
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
const { lists: allLists } = await collaboratorApi.lists.list();
const sharedLists = allLists.filter(
@@ -464,13 +503,12 @@ describe("Shared Lists", () => {
type: "manual",
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
-
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
const retrievedList = await collaboratorApi.lists.get({
listId: list.id,
@@ -494,13 +532,12 @@ describe("Shared Lists", () => {
type: "manual",
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
-
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
await expect(
thirdUserApi.lists.get({
@@ -539,13 +576,12 @@ describe("Shared Lists", () => {
type: "manual",
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
-
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "editor",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "editor",
+ );
const retrievedList = await collaboratorApi.lists.get({
listId: list.id,
@@ -579,13 +615,12 @@ describe("Shared Lists", () => {
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",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
// Collaborator fetches bookmarks from shared list
const bookmarks = await collaboratorApi.bookmarks.getBookmarks({
@@ -625,12 +660,12 @@ describe("Shared Lists", () => {
bookmarkId: bookmark.id,
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
const ownerView = await ownerApi.bookmarks.getBookmarks({
listId: list.id,
@@ -680,12 +715,12 @@ describe("Shared Lists", () => {
bookmarkId: bookmark.id,
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
// Collaborator gets individual bookmark
const response = await collaboratorApi.bookmarks.getBookmark({
@@ -717,12 +752,12 @@ describe("Shared Lists", () => {
bookmarkId: sharedBookmark.id,
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
// Collaborator creates their own bookmark
const ownBookmark = await collaboratorApi.bookmarks.createBookmark({
@@ -761,12 +796,12 @@ describe("Shared Lists", () => {
bookmarkId: bookmark.id,
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
// Don't add thirdUserApi as a collaborator
// Third user tries to access the bookmark
@@ -801,12 +836,12 @@ describe("Shared Lists", () => {
});
// Share list with collaborator as editor
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "editor",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "editor",
+ );
// Collaborator adds their own bookmark
const collabBookmark = await collaboratorApi.bookmarks.createBookmark({
@@ -846,12 +881,12 @@ describe("Shared Lists", () => {
type: "manual",
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
// Viewer creates their own bookmark
const bookmark = await collaboratorApi.bookmarks.createBookmark({
@@ -880,12 +915,12 @@ describe("Shared Lists", () => {
type: "manual",
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "editor",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "editor",
+ );
// Editor creates their own bookmark
const bookmark = await collaboratorApi.bookmarks.createBookmark({
@@ -930,12 +965,12 @@ describe("Shared Lists", () => {
bookmarkId: bookmark.id,
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
// Viewer tries to remove bookmark
await expect(
@@ -968,12 +1003,12 @@ describe("Shared Lists", () => {
bookmarkId: bookmark.id,
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "editor",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "editor",
+ );
// Editor removes bookmark
await collaboratorApi.lists.removeFromList({
@@ -1011,12 +1046,12 @@ describe("Shared Lists", () => {
bookmarkId: bookmark.id,
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "editor",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "editor",
+ );
// Collaborator tries to edit owner's bookmark
await expect(
@@ -1049,12 +1084,12 @@ describe("Shared Lists", () => {
bookmarkId: bookmark.id,
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "editor",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "editor",
+ );
// Collaborator tries to delete owner's bookmark
await expect(
@@ -1078,12 +1113,12 @@ describe("Shared Lists", () => {
type: "manual",
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "editor",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "editor",
+ );
// Collaborator tries to edit list
await expect(
@@ -1106,12 +1141,12 @@ describe("Shared Lists", () => {
type: "manual",
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "editor",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "editor",
+ );
// Collaborator tries to delete list
await expect(
@@ -1134,12 +1169,12 @@ describe("Shared Lists", () => {
type: "manual",
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "editor",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "editor",
+ );
const thirdUserEmail = (await thirdUserApi.users.whoami()).email!;
@@ -1166,12 +1201,12 @@ describe("Shared Lists", () => {
type: "manual",
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
// Collaborator can view collaborators
const { collaborators, owner } =
@@ -1215,11 +1250,13 @@ describe("Shared Lists", () => {
});
const collaboratorUser = await collaboratorApi.users.whoami();
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorUser.email!,
- role: "viewer",
- });
+
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
// Verify collaborator has access to list
const bookmarksBefore = await collaboratorApi.bookmarks.getBookmarks({
@@ -1266,12 +1303,12 @@ describe("Shared Lists", () => {
type: "manual",
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
// Collaborator leaves
await collaboratorApi.lists.leaveList({
@@ -1332,17 +1369,18 @@ describe("Shared Lists", () => {
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",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list1.id,
+ "editor",
+ );
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list2.id,
+ "editor",
+ );
// Collaborator tries to merge the shared list into another list
await expect(
@@ -1366,12 +1404,12 @@ describe("Shared Lists", () => {
type: "manual",
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "editor",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "editor",
+ );
// Collaborator tries to generate RSS token
await expect(
@@ -1422,12 +1460,12 @@ describe("Shared Lists", () => {
bookmarkId: bookmark.id,
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
// Collaborator cannot use getListsOfBookmark for owner's bookmark
// This is expected - only bookmark owners can query which lists contain their bookmarks
@@ -1435,7 +1473,7 @@ describe("Shared Lists", () => {
collaboratorApi.lists.getListsOfBookmark({
bookmarkId: bookmark.id,
}),
- ).rejects.toThrow();
+ ).rejects.toThrow("User is not allowed to access resource");
});
test<CustomTestContext>("should allow collaborator to use getListsOfBookmark for their own bookmarks in shared lists", async ({
@@ -1450,12 +1488,12 @@ describe("Shared Lists", () => {
type: "manual",
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "editor",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "editor",
+ );
// Collaborator creates their own bookmark and adds it to the shared list
const bookmark = await collaboratorApi.bookmarks.createBookmark({
@@ -1504,12 +1542,12 @@ describe("Shared Lists", () => {
});
// Add a collaborator
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
// Owner queries which lists contain their bookmark
const { lists } = await ownerApi.lists.getListsOfBookmark({
@@ -1587,12 +1625,12 @@ describe("Shared Lists", () => {
bookmarkId: bookmark2.id,
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
// Collaborator gets stats
const { stats } = await collaboratorApi.lists.stats();
@@ -1605,7 +1643,7 @@ describe("Shared Lists", () => {
apiCallers,
}) => {
const ownerApi = apiCallers[0];
- const editorApi = apiCallers[1];
+ const collaboratorApi = apiCallers[1];
const list = await ownerApi.lists.create({
name: "Shared List",
@@ -1613,21 +1651,21 @@ describe("Shared Lists", () => {
type: "manual",
});
- const editorEmail = (await editorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: editorEmail,
- role: "editor",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "editor",
+ );
// Editor creates their own bookmark
- const bookmark = await editorApi.bookmarks.createBookmark({
+ const bookmark = await collaboratorApi.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({
+ await collaboratorApi.lists.addToList({
listId: list.id,
bookmarkId: bookmark.id,
});
@@ -1653,12 +1691,7 @@ describe("Shared Lists", () => {
type: "manual",
});
- const viewerEmail = (await viewerApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: viewerEmail,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(ownerApi, viewerApi, list.id, "viewer");
// Viewer creates their own bookmark
const bookmark = await viewerApi.bookmarks.createBookmark({
@@ -1688,12 +1721,7 @@ describe("Shared Lists", () => {
type: "manual",
});
- const editorEmail = (await editorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: editorEmail,
- role: "editor",
- });
+ await addAndAcceptCollaborator(ownerApi, editorApi, list.id, "editor");
// Third user creates a bookmark (or owner if only 2 users)
const bookmark = await thirdUserApi.bookmarks.createBookmark({
@@ -1722,12 +1750,7 @@ describe("Shared Lists", () => {
type: "manual",
});
- const editorEmail = (await editorApi.users.whoami()).email!;
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: editorEmail,
- role: "editor",
- });
+ await addAndAcceptCollaborator(ownerApi, editorApi, list.id, "editor");
// Editor tries to change list name
await expect(
@@ -1774,13 +1797,12 @@ describe("Shared Lists", () => {
type: "manual",
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
-
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
// Fetch the list again to get updated hasCollaborators
const updatedList = await ownerApi.lists.get({
@@ -1802,13 +1824,12 @@ describe("Shared Lists", () => {
type: "manual",
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
-
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorEmail,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
// Collaborator fetches the list
const sharedList = await collaboratorApi.lists.get({
@@ -1832,11 +1853,12 @@ describe("Shared Lists", () => {
const collaboratorUser = await collaboratorApi.users.whoami();
- await ownerApi.lists.addCollaborator({
- listId: list.id,
- email: collaboratorUser.email!,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
// Remove the collaborator
await ownerApi.lists.removeCollaborator({
@@ -1872,13 +1894,12 @@ describe("Shared Lists", () => {
type: "manual",
});
- const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
-
- await ownerApi.lists.addCollaborator({
- listId: list2.id,
- email: collaboratorEmail,
- role: "viewer",
- });
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list2.id,
+ "viewer",
+ );
// Get all lists
const { lists } = await ownerApi.lists.list();
@@ -1902,6 +1923,233 @@ describe("Shared Lists", () => {
type: "manual",
});
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "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");
+ });
+ });
+
+ describe("List Invitations", () => {
+ test<CustomTestContext>("should create pending invitation when adding 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!;
+
+ // Add collaborator (creates pending invitation)
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // Check that collaborator has a pending invitation
+ const pendingInvitations =
+ await collaboratorApi.lists.getPendingInvitations();
+
+ expect(pendingInvitations).toHaveLength(1);
+ expect(pendingInvitations[0].listId).toBe(list.id);
+ expect(pendingInvitations[0].role).toBe("viewer");
+ });
+
+ test<CustomTestContext>("should allow collaborator to accept invitation", 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!;
+
+ const { invitationId } = await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // Accept the invitation
+ await collaboratorApi.lists.acceptInvitation({
+ invitationId,
+ });
+
+ // Verify collaborator was added
+ const { collaborators } = await ownerApi.lists.getCollaborators({
+ listId: list.id,
+ });
+
+ expect(collaborators).toHaveLength(1);
+ expect(collaborators[0].user.email).toBe(collaboratorEmail);
+ expect(collaborators[0].status).toBe("accepted");
+
+ // Verify no more pending invitations
+ const pendingInvitations =
+ await collaboratorApi.lists.getPendingInvitations();
+
+ expect(pendingInvitations).toHaveLength(0);
+ });
+
+ test<CustomTestContext>("should allow collaborator to decline invitation", 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!;
+
+ const { invitationId } = await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // Decline the invitation
+ await collaboratorApi.lists.declineInvitation({
+ invitationId,
+ });
+
+ // Verify collaborator was not added
+ const { collaborators } = await ownerApi.lists.getCollaborators({
+ listId: list.id,
+ });
+
+ expect(collaborators).toHaveLength(1);
+ expect(collaborators[0].status).toBe("declined");
+
+ // Verify no pending invitations
+ const pendingInvitations =
+ await collaboratorApi.lists.getPendingInvitations();
+
+ expect(pendingInvitations).toHaveLength(0);
+ });
+
+ test<CustomTestContext>("should allow owner to revoke pending invitation", 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();
+
+ const { invitationId } = await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorUser.email!,
+ role: "viewer",
+ });
+
+ // Owner revokes the invitation
+ await ownerApi.lists.revokeInvitation({
+ invitationId,
+ });
+
+ // Verify invitation was revoked
+ const pendingInvitations =
+ await collaboratorApi.lists.getPendingInvitations();
+
+ expect(pendingInvitations).toHaveLength(0);
+ });
+
+ test<CustomTestContext>("should not allow access to list with pending invitation", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Test 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!;
+
+ // Add collaborator but don't accept invitation
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // Collaborator should not be able to access the list yet
+ await expect(
+ collaboratorApi.lists.get({
+ listId: list.id,
+ }),
+ ).rejects.toThrow("List not found");
+
+ // Collaborator should not be able to access bookmarks in the list
+ await expect(
+ collaboratorApi.bookmarks.getBookmarks({
+ listId: list.id,
+ }),
+ ).rejects.toThrow("List not found");
+
+ // Collaborator should not be able to access individual bookmarks
+ await expect(
+ collaboratorApi.bookmarks.getBookmark({
+ bookmarkId: bookmark.id,
+ }),
+ ).rejects.toThrow("Bookmark not found");
+ });
+
+ test<CustomTestContext>("should show pending invitations with list details", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Test Shared List",
+ icon: "📚",
+ description: "A test list for sharing",
+ type: "manual",
+ });
+
const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
await ownerApi.lists.addCollaborator({
@@ -1910,13 +2158,678 @@ describe("Shared Lists", () => {
role: "editor",
});
- // Collaborator gets all lists
- const { lists } = await collaboratorApi.lists.list();
+ const pendingInvitations =
+ await collaboratorApi.lists.getPendingInvitations();
- const sharedList = lists.find((l) => l.id === list.id);
+ expect(pendingInvitations).toHaveLength(1);
+ const invitation = pendingInvitations[0];
- expect(sharedList?.hasCollaborators).toBe(true);
- expect(sharedList?.userRole).toBe("editor");
+ expect(invitation.listId).toBe(list.id);
+ expect(invitation.role).toBe("editor");
+ expect(invitation.list.name).toBe("Test Shared List");
+ expect(invitation.list.icon).toBe("📚");
+ expect(invitation.list.description).toBe("A test list for sharing");
+ expect(invitation.list.owner).toBeDefined();
+ });
+
+ test<CustomTestContext>("should show pending invitations in getCollaborators for owner", 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",
+ });
+
+ // Owner should see pending invitation in collaborators list
+ const { collaborators } = await ownerApi.lists.getCollaborators({
+ listId: list.id,
+ });
+
+ expect(collaborators).toHaveLength(1);
+ expect(collaborators[0].status).toBe("pending");
+ expect(collaborators[0].role).toBe("viewer");
+ expect(collaborators[0].user.email).toBe(collaboratorEmail);
+ });
+
+ test<CustomTestContext>("should update hasCollaborators after invitation is accepted", 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!;
+
+ // hasCollaborators should be false initially
+ expect(list.hasCollaborators).toBe(false);
+
+ const { invitationId } = await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // hasCollaborators should be false after adding invitation (pending does not counts)
+ const listAfterInvite = await ownerApi.lists.get({
+ listId: list.id,
+ });
+ expect(listAfterInvite.hasCollaborators).toBe(false);
+
+ // Accept the invitation
+ await collaboratorApi.lists.acceptInvitation({
+ invitationId,
+ });
+
+ // hasCollaborators should still be true
+ const listAfterAccept = await ownerApi.lists.get({
+ listId: list.id,
+ });
+ expect(listAfterAccept.hasCollaborators).toBe(true);
+ });
+
+ test<CustomTestContext>("should update hasCollaborators after invitation is declined", 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!;
+
+ const { invitationId } = await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // hasCollaborators should be false with pending invitation
+ const listAfterInvite = await ownerApi.lists.get({
+ listId: list.id,
+ });
+ expect(listAfterInvite.hasCollaborators).toBe(false);
+
+ // Decline the invitation
+ await collaboratorApi.lists.declineInvitation({
+ invitationId,
+ });
+
+ // hasCollaborators should be false after declining
+ const listAfterDecline = await ownerApi.lists.get({
+ listId: list.id,
+ });
+ expect(listAfterDecline.hasCollaborators).toBe(false);
+ });
+
+ test<CustomTestContext>("should not show declined invitations in pending 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!;
+
+ const { invitationId } = await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // Decline the invitation
+ await collaboratorApi.lists.declineInvitation({
+ invitationId,
+ });
+
+ // Should not appear in pending invitations
+ const pendingInvitations =
+ await collaboratorApi.lists.getPendingInvitations();
+
+ expect(pendingInvitations).toHaveLength(0);
+ });
+
+ test<CustomTestContext>("should allow re-inviting after decline", 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!;
+
+ // First invitation
+ const { invitationId } = await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // Decline it
+ await collaboratorApi.lists.declineInvitation({
+ invitationId,
+ });
+
+ // Re-invite with different role
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "editor",
+ });
+
+ // Should have a new pending invitation
+ const pendingInvitations =
+ await collaboratorApi.lists.getPendingInvitations();
+
+ expect(pendingInvitations).toHaveLength(1);
+ expect(pendingInvitations[0].role).toBe("editor");
+ });
+
+ test<CustomTestContext>("should not allow accepting non-existent invitation", async ({
+ apiCallers,
+ }) => {
+ const collaboratorApi = apiCallers[1];
+
+ const fakeInvitationId = "non-existent-invitation-id";
+
+ await expect(
+ collaboratorApi.lists.acceptInvitation({
+ invitationId: fakeInvitationId,
+ }),
+ ).rejects.toThrow("Invitation not found");
+ });
+
+ test<CustomTestContext>("should not allow accepting already accepted invitation", 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!;
+
+ const { invitationId } = await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // Accept once
+ await collaboratorApi.lists.acceptInvitation({
+ invitationId,
+ });
+
+ // Try to accept again (should fail since invitation is already accepted and deleted)
+ await expect(
+ collaboratorApi.lists.acceptInvitation({
+ invitationId,
+ }),
+ ).rejects.toThrow("Invitation not found");
+ });
+
+ test<CustomTestContext>("should show list in shared lists only after accepting invitation", 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!;
+
+ const { invitationId } = await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // List should not appear in collaborator's lists yet
+ const listsBefore = await collaboratorApi.lists.list();
+ expect(listsBefore.lists.find((l) => l.id === list.id)).toBeUndefined();
+
+ // Accept invitation
+ await collaboratorApi.lists.acceptInvitation({
+ invitationId,
+ });
+
+ // Now list should appear
+ const listsAfter = await collaboratorApi.lists.list();
+ const sharedList = listsAfter.lists.find((l) => l.id === list.id);
+ expect(sharedList).toBeDefined();
+ expect(sharedList?.userRole).toBe("viewer");
+ });
+
+ test<CustomTestContext>("should handle multiple pending invitations for different 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!;
+
+ // Invite to both lists
+ const { invitationId: invitationId1 } =
+ await ownerApi.lists.addCollaborator({
+ listId: list1.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ const { invitationId: invitationId2 } =
+ await ownerApi.lists.addCollaborator({
+ listId: list2.id,
+ email: collaboratorEmail,
+ role: "editor",
+ });
+
+ // Should have 2 pending invitations
+ const pendingInvitations =
+ await collaboratorApi.lists.getPendingInvitations();
+
+ expect(pendingInvitations).toHaveLength(2);
+
+ // Accept one
+ await collaboratorApi.lists.acceptInvitation({
+ invitationId: invitationId1,
+ });
+
+ // Should have 1 pending invitation left
+ const remainingInvitations =
+ await collaboratorApi.lists.getPendingInvitations();
+
+ expect(remainingInvitations).toHaveLength(1);
+ expect(remainingInvitations[0].id).toBe(invitationId2);
+ expect(remainingInvitations[0].listId).toBe(list2.id);
+ });
+
+ test<CustomTestContext>("should not allow collaborator to revoke invitation", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+ const thirdUserApi = apiCallers[2];
+
+ const list = await ownerApi.lists.create({
+ name: "Test List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ // Owner adds collaborator 1 and they accept
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "editor",
+ );
+
+ // Owner invites third user
+ const thirdUserEmail = (await thirdUserApi.users.whoami()).email!;
+ const { invitationId } = await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: thirdUserEmail,
+ role: "viewer",
+ });
+
+ // Collaborator tries to revoke the third user's invitation
+ // Collaborator cannot access the invitation at all (not the invitee, not the owner)
+ await expect(
+ collaboratorApi.lists.revokeInvitation({
+ invitationId,
+ }),
+ ).rejects.toThrow("Invitation not found");
+ });
+
+ test<CustomTestContext>("should not allow invited user to revoke their own invitation", 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!;
+
+ const { invitationId } = await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // Invited user tries to revoke (should only be able to decline)
+ await expect(
+ collaboratorApi.lists.revokeInvitation({
+ invitationId,
+ }),
+ ).rejects.toThrow("Only the list owner can perform this action");
+ });
+
+ test<CustomTestContext>("should not allow non-owner/non-invitee to access invitation", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+ const thirdUserApi = apiCallers[2];
+
+ const list = await ownerApi.lists.create({
+ name: "Test List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+
+ const { invitationId } = await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // Third user (not owner, not invitee) tries to revoke invitation
+ await expect(
+ thirdUserApi.lists.revokeInvitation({
+ invitationId,
+ }),
+ ).rejects.toThrow("Invitation not found");
+
+ // Third user tries to accept invitation
+ await expect(
+ thirdUserApi.lists.acceptInvitation({
+ invitationId,
+ }),
+ ).rejects.toThrow("Invitation not found");
+
+ // Third user tries to decline invitation
+ await expect(
+ thirdUserApi.lists.declineInvitation({
+ invitationId,
+ }),
+ ).rejects.toThrow("Invitation not found");
+ });
+
+ test<CustomTestContext>("should not show invitations to collaborators in getCollaborators", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+ const thirdUserApi = apiCallers[2];
+
+ const list = await ownerApi.lists.create({
+ name: "Test List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ // Owner adds collaborator 1 and they accept
+ await addAndAcceptCollaborator(
+ ownerApi,
+ collaboratorApi,
+ list.id,
+ "viewer",
+ );
+
+ // Owner invites third user (pending invitation)
+ const thirdUserEmail = (await thirdUserApi.users.whoami()).email!;
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: thirdUserEmail,
+ role: "viewer",
+ });
+
+ // Owner should see 2 collaborators (1 accepted + 1 pending)
+ const ownerView = await ownerApi.lists.getCollaborators({
+ listId: list.id,
+ });
+ expect(ownerView.collaborators).toHaveLength(2);
+
+ // Collaborator should only see 1 (themselves, no pending invitations)
+ const collaboratorView = await collaboratorApi.lists.getCollaborators({
+ listId: list.id,
+ });
+ expect(collaboratorView.collaborators).toHaveLength(1);
+ expect(collaboratorView.collaborators[0].status).toBe("accepted");
+ });
+
+ test<CustomTestContext>("should allow owner to see both accepted collaborators and pending invitations", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+ const thirdUserApi = apiCallers[2];
+
+ const list = await ownerApi.lists.create({
+ name: "Test List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ // Add and accept one collaborator
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+ const { invitationId } = await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "editor",
+ });
+
+ await collaboratorApi.lists.acceptInvitation({
+ invitationId,
+ });
+
+ // Add pending invitation for third user
+ const thirdUserEmail = (await thirdUserApi.users.whoami()).email!;
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: thirdUserEmail,
+ role: "viewer",
+ });
+
+ // Owner should see both
+ const { collaborators } = await ownerApi.lists.getCollaborators({
+ listId: list.id,
+ });
+
+ expect(collaborators).toHaveLength(2);
+
+ const acceptedCollaborator = collaborators.find(
+ (c) => c.status === "accepted",
+ );
+ const pendingCollaborator = collaborators.find(
+ (c) => c.status === "pending",
+ );
+
+ expect(acceptedCollaborator).toBeDefined();
+ expect(acceptedCollaborator?.role).toBe("editor");
+ expect(acceptedCollaborator?.user.email).toBe(collaboratorEmail);
+
+ expect(pendingCollaborator).toBeDefined();
+ expect(pendingCollaborator?.role).toBe("viewer");
+ expect(pendingCollaborator?.user.email).toBe(thirdUserEmail);
+ });
+
+ test<CustomTestContext>("should not show invitee name for pending invitations", 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();
+ const collaboratorEmail = collaboratorUser.email!;
+ const collaboratorName = collaboratorUser.name;
+
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // Owner checks pending invitations
+ const { collaborators } = await ownerApi.lists.getCollaborators({
+ listId: list.id,
+ });
+
+ const pendingInvitation = collaborators.find(
+ (c) => c.status === "pending",
+ );
+
+ expect(pendingInvitation).toBeDefined();
+ // Name should be masked as "Pending User"
+ expect(pendingInvitation?.user.name).toBe("Pending User");
+ // Name should NOT be the actual user's name
+ expect(pendingInvitation?.user.name).not.toBe(collaboratorName);
+ // Email should still be visible to owner
+ expect(pendingInvitation?.user.email).toBe(collaboratorEmail);
+ });
+
+ test<CustomTestContext>("should show invitee name after invitation is accepted", 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();
+ const collaboratorEmail = collaboratorUser.email!;
+ const collaboratorName = collaboratorUser.name;
+
+ const { invitationId } = await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // Before acceptance - name should be masked
+ const beforeAccept = await ownerApi.lists.getCollaborators({
+ listId: list.id,
+ });
+ const pendingInvitation = beforeAccept.collaborators.find(
+ (c) => c.status === "pending",
+ );
+ expect(pendingInvitation?.user.name).toBe("Pending User");
+
+ // Accept invitation
+ await collaboratorApi.lists.acceptInvitation({
+ invitationId,
+ });
+
+ // After acceptance - name should be visible
+ const afterAccept = await ownerApi.lists.getCollaborators({
+ listId: list.id,
+ });
+ const acceptedCollaborator = afterAccept.collaborators.find(
+ (c) => c.status === "accepted",
+ );
+ expect(acceptedCollaborator?.user.name).toBe(collaboratorName);
+ expect(acceptedCollaborator?.user.email).toBe(collaboratorEmail);
+ });
+
+ test<CustomTestContext>("should not show invitee name for declined invitations", 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();
+ const collaboratorEmail = collaboratorUser.email!;
+ const collaboratorName = collaboratorUser.name;
+
+ const { invitationId } = await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // Decline the invitation
+ await collaboratorApi.lists.declineInvitation({
+ invitationId,
+ });
+
+ // Owner checks declined invitations
+ const { collaborators } = await ownerApi.lists.getCollaborators({
+ listId: list.id,
+ });
+
+ const declinedInvitation = collaborators.find(
+ (c) => c.status === "declined",
+ );
+
+ expect(declinedInvitation).toBeDefined();
+ // Name should still be masked as "Pending User" even after decline
+ expect(declinedInvitation?.user.name).toBe("Pending User");
+ expect(declinedInvitation?.user.name).not.toBe(collaboratorName);
+ // Email should still be visible to owner
+ expect(declinedInvitation?.user.email).toBe(collaboratorEmail);
});
});
});