aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--apps/web/app/dashboard/lists/[listId]/page.tsx5
-rw-r--r--apps/web/app/reader/[bookmarkId]/page.tsx14
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx6
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx267
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkTagsEditor.tsx9
-rw-r--r--apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx64
-rw-r--r--apps/web/components/dashboard/bookmarks/TagList.tsx36
-rw-r--r--apps/web/components/dashboard/bookmarks/TagsEditor.tsx4
-rw-r--r--apps/web/components/dashboard/highlights/AllHighlights.tsx2
-rw-r--r--apps/web/components/dashboard/highlights/HighlightCard.tsx22
-rw-r--r--apps/web/components/dashboard/lists/CollapsibleBookmarkLists.tsx14
-rw-r--r--apps/web/components/dashboard/lists/LeaveListConfirmationDialog.tsx83
-rw-r--r--apps/web/components/dashboard/lists/ListHeader.tsx30
-rw-r--r--apps/web/components/dashboard/lists/ListOptions.tsx161
-rw-r--r--apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx345
-rw-r--r--apps/web/components/dashboard/preview/AttachmentBox.tsx66
-rw-r--r--apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx6
-rw-r--r--apps/web/components/dashboard/preview/BookmarkPreview.tsx17
-rw-r--r--apps/web/components/dashboard/preview/HighlightsBox.tsx14
-rw-r--r--apps/web/components/dashboard/preview/LinkContentSection.tsx4
-rw-r--r--apps/web/components/dashboard/preview/NoteEditor.tsx10
-rw-r--r--apps/web/components/dashboard/preview/ReaderView.tsx3
-rw-r--r--apps/web/lib/i18n/locales/en/translation.json32
-rw-r--r--packages/api/routes/assets.ts33
-rw-r--r--packages/db/drizzle/0065_collaborative_lists.sql16
-rw-r--r--packages/db/drizzle/meta/0065_snapshot.json2639
-rw-r--r--packages/db/drizzle/meta/_journal.json7
-rw-r--r--packages/db/schema.ts54
-rw-r--r--packages/open-api/karakeep-openapi-spec.json32
-rw-r--r--packages/shared/types/bookmarks.ts3
-rw-r--r--packages/shared/types/lists.ts2
-rw-r--r--packages/trpc/models/bookmarks.ts309
-rw-r--r--packages/trpc/models/highlights.ts8
-rw-r--r--packages/trpc/models/lists.ts587
-rw-r--r--packages/trpc/routers/bookmarks.test.ts8
-rw-r--r--packages/trpc/routers/bookmarks.ts298
-rw-r--r--packages/trpc/routers/highlights.ts8
-rw-r--r--packages/trpc/routers/lists.ts157
-rw-r--r--packages/trpc/routers/sharedLists.test.ts1922
-rw-r--r--packages/trpc/testUtils.ts4
40 files changed, 6705 insertions, 596 deletions
diff --git a/apps/web/app/dashboard/lists/[listId]/page.tsx b/apps/web/app/dashboard/lists/[listId]/page.tsx
index 3f9c3416..2b2cc9bb 100644
--- a/apps/web/app/dashboard/lists/[listId]/page.tsx
+++ b/apps/web/app/dashboard/lists/[listId]/page.tsx
@@ -50,6 +50,9 @@ export default async function ListPage(props: {
? searchParams.includeArchived === "true"
: userSettings.archiveDisplayBehaviour === "show";
+ // Only show editor card if user is owner or editor (not viewer)
+ const canEdit = list.userRole === "owner" || list.userRole === "editor";
+
return (
<BookmarkListContextProvider list={list}>
<Bookmarks
@@ -58,7 +61,7 @@ export default async function ListPage(props: {
archived: !includeArchived ? false : undefined,
}}
showDivider={true}
- showEditorCard={list.type === "manual"}
+ showEditorCard={list.type === "manual" && canEdit}
header={<ListHeader initialData={list} />}
/>
</BookmarkListContextProvider>
diff --git a/apps/web/app/reader/[bookmarkId]/page.tsx b/apps/web/app/reader/[bookmarkId]/page.tsx
index 7c2b0c9e..e32811a9 100644
--- a/apps/web/app/reader/[bookmarkId]/page.tsx
+++ b/apps/web/app/reader/[bookmarkId]/page.tsx
@@ -1,7 +1,7 @@
"use client";
import { Suspense, useState } from "react";
-import { useRouter } from "next/navigation";
+import { useParams, useRouter } from "next/navigation";
import HighlightCard from "@/components/dashboard/highlights/HighlightCard";
import ReaderView from "@/components/dashboard/preview/ReaderView";
import { Button } from "@/components/ui/button";
@@ -29,16 +29,14 @@ import {
Type,
X,
} from "lucide-react";
+import { useSession } from "next-auth/react";
import { api } from "@karakeep/shared-react/trpc";
import { BookmarkTypes } from "@karakeep/shared/types/bookmarks";
import { getBookmarkTitle } from "@karakeep/shared/utils/bookmarkUtils";
-export default function ReaderViewPage({
- params,
-}: {
- params: { bookmarkId: string };
-}) {
+export default function ReaderViewPage() {
+ const params = useParams<{ bookmarkId: string }>();
const bookmarkId = params.bookmarkId;
const { data: highlights } = api.highlights.getForBookmark.useQuery({
bookmarkId,
@@ -47,12 +45,14 @@ export default function ReaderViewPage({
bookmarkId,
});
+ const { data: session } = useSession();
const router = useRouter();
const [fontSize, setFontSize] = useState([18]);
const [lineHeight, setLineHeight] = useState([1.6]);
const [fontFamily, setFontFamily] = useState("serif");
const [showHighlights, setShowHighlights] = useState(false);
const [showSettings, setShowSettings] = useState(false);
+ const isOwner = session?.user?.id === bookmark?.userId;
const fontFamilies = {
serif: "ui-serif, Georgia, Cambria, serif",
@@ -245,6 +245,7 @@ export default function ReaderViewPage({
lineHeight: lineHeight[0],
}}
bookmarkId={bookmarkId}
+ readOnly={!isOwner}
/>
</div>
</Suspense>
@@ -299,6 +300,7 @@ export default function ReaderViewPage({
key={highlight.id}
highlight={highlight}
clickable={true}
+ readOnly={!isOwner}
/>
))}
</div>
diff --git a/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx b/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx
index 98babb22..e8520b1a 100644
--- a/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx
+++ b/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx
@@ -13,6 +13,7 @@ import {
} from "@/lib/userLocalSettings/bookmarksLayout";
import { cn } from "@/lib/utils";
import { Check, Image as ImageIcon, NotebookPen } from "lucide-react";
+import { useSession } from "next-auth/react";
import { useTheme } from "next-themes";
import type { ZBookmark } from "@karakeep/shared/types/bookmarks";
@@ -64,12 +65,15 @@ function MultiBookmarkSelector({ bookmark }: { bookmark: ZBookmark }) {
const toggleBookmark = useBulkActionsStore((state) => state.toggleBookmark);
const [isSelected, setIsSelected] = useState(false);
const { theme } = useTheme();
+ const { data: session } = useSession();
useEffect(() => {
setIsSelected(selectedBookmarks.some((item) => item.id === bookmark.id));
}, [selectedBookmarks]);
- if (!isBulkEditEnabled) return null;
+ // Don't show selector for non-owned bookmarks or when bulk edit is disabled
+ const isOwner = session?.user?.id === bookmark.userId;
+ if (!isBulkEditEnabled || !isOwner) return null;
const getIconColor = () => {
if (theme === "dark") {
diff --git a/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx b/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx
index 4725c77f..66de6156 100644
--- a/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx
+++ b/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx
@@ -22,6 +22,7 @@ import {
SquarePen,
Trash2,
} from "lucide-react";
+import { useSession } from "next-auth/react";
import type {
ZBookmark,
@@ -46,9 +47,13 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) {
const { t } = useTranslation();
const { toast } = useToast();
const linkId = bookmark.id;
+ const { data: session } = useSession();
const demoMode = !!useClientConfig().demoMode;
+ // Check if the current user owns this bookmark
+ const isOwner = session?.user?.id === bookmark.userId;
+
const [isClipboardAvailable, setIsClipboardAvailable] = useState(false);
useEffect(() => {
@@ -114,6 +119,142 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) {
onError,
});
+ // Define action items array
+ const actionItems = [
+ {
+ id: "edit",
+ title: t("actions.edit"),
+ icon: <Pencil className="mr-2 size-4" />,
+ visible: isOwner,
+ disabled: false,
+ onClick: () => setEditBookmarkDialogOpen(true),
+ },
+ {
+ id: "open-editor",
+ title: t("actions.open_editor"),
+ icon: <SquarePen className="mr-2 size-4" />,
+ visible: isOwner && bookmark.content.type === BookmarkTypes.TEXT,
+ disabled: false,
+ onClick: () => setTextEditorOpen(true),
+ },
+ {
+ id: "favorite",
+ title: bookmark.favourited
+ ? t("actions.unfavorite")
+ : t("actions.favorite"),
+ icon: (
+ <FavouritedActionIcon
+ className="mr-2 size-4"
+ favourited={bookmark.favourited}
+ />
+ ),
+ visible: isOwner,
+ disabled: demoMode,
+ onClick: () =>
+ updateBookmarkMutator.mutate({
+ bookmarkId: linkId,
+ favourited: !bookmark.favourited,
+ }),
+ },
+ {
+ id: "archive",
+ title: bookmark.archived ? t("actions.unarchive") : t("actions.archive"),
+ icon: (
+ <ArchivedActionIcon
+ className="mr-2 size-4"
+ archived={bookmark.archived}
+ />
+ ),
+ visible: isOwner,
+ disabled: demoMode,
+ onClick: () =>
+ updateBookmarkMutator.mutate({
+ bookmarkId: linkId,
+ archived: !bookmark.archived,
+ }),
+ },
+ {
+ id: "download-full-page",
+ title: t("actions.download_full_page_archive"),
+ icon: <FileDown className="mr-2 size-4" />,
+ visible: isOwner && bookmark.content.type === BookmarkTypes.LINK,
+ disabled: false,
+ onClick: () => {
+ fullPageArchiveBookmarkMutator.mutate({
+ bookmarkId: bookmark.id,
+ archiveFullPage: true,
+ });
+ },
+ },
+ {
+ id: "copy-link",
+ title: t("actions.copy_link"),
+ icon: <Link className="mr-2 size-4" />,
+ visible: bookmark.content.type === BookmarkTypes.LINK,
+ disabled: !isClipboardAvailable,
+ onClick: () => {
+ navigator.clipboard.writeText(
+ (bookmark.content as ZBookmarkedLink).url,
+ );
+ toast({
+ description: t("toasts.bookmarks.clipboard_copied"),
+ });
+ },
+ },
+ {
+ id: "manage-lists",
+ title: t("actions.manage_lists"),
+ icon: <List className="mr-2 size-4" />,
+ visible: isOwner,
+ disabled: false,
+ onClick: () => setManageListsModalOpen(true),
+ },
+ {
+ id: "remove-from-list",
+ title: t("actions.remove_from_list"),
+ icon: <ListX className="mr-2 size-4" />,
+ visible:
+ (isOwner ||
+ (withinListContext &&
+ (withinListContext.userRole === "editor" ||
+ withinListContext.userRole === "owner"))) &&
+ !!listId &&
+ !!withinListContext &&
+ withinListContext.type === "manual",
+ disabled: demoMode,
+ onClick: () =>
+ removeFromListMutator.mutate({
+ listId: listId!,
+ bookmarkId: bookmark.id,
+ }),
+ },
+ {
+ id: "refresh",
+ title: t("actions.refresh"),
+ icon: <RotateCw className="mr-2 size-4" />,
+ visible: isOwner && bookmark.content.type === BookmarkTypes.LINK,
+ disabled: demoMode,
+ onClick: () => crawlBookmarkMutator.mutate({ bookmarkId: bookmark.id }),
+ },
+ {
+ id: "delete",
+ title: t("actions.delete"),
+ icon: <Trash2 className="mr-2 size-4" />,
+ visible: isOwner,
+ disabled: demoMode,
+ className: "text-destructive",
+ onClick: () => setDeleteBookmarkDialogOpen(true),
+ },
+ ];
+
+ // Filter visible items
+ const visibleItems = actionItems.filter((item) => item.visible);
+
+ // If no items are visible, don't render the dropdown
+ if (visibleItems.length === 0) {
+ return null;
+ }
+
return (
<>
{manageListsModal}
@@ -142,127 +283,17 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-fit">
- <DropdownMenuItem onClick={() => setEditBookmarkDialogOpen(true)}>
- <Pencil className="mr-2 size-4" />
- <span>{t("actions.edit")}</span>
- </DropdownMenuItem>
- {bookmark.content.type === BookmarkTypes.TEXT && (
- <DropdownMenuItem onClick={() => setTextEditorOpen(true)}>
- <SquarePen className="mr-2 size-4" />
- <span>{t("actions.open_editor")}</span>
- </DropdownMenuItem>
- )}
- <DropdownMenuItem
- disabled={demoMode}
- onClick={() =>
- updateBookmarkMutator.mutate({
- bookmarkId: linkId,
- favourited: !bookmark.favourited,
- })
- }
- >
- <FavouritedActionIcon
- className="mr-2 size-4"
- favourited={bookmark.favourited}
- />
- <span>
- {bookmark.favourited
- ? t("actions.unfavorite")
- : t("actions.favorite")}
- </span>
- </DropdownMenuItem>
- <DropdownMenuItem
- disabled={demoMode}
- onClick={() =>
- updateBookmarkMutator.mutate({
- bookmarkId: linkId,
- archived: !bookmark.archived,
- })
- }
- >
- <ArchivedActionIcon
- className="mr-2 size-4"
- archived={bookmark.archived}
- />
- <span>
- {bookmark.archived
- ? t("actions.unarchive")
- : t("actions.archive")}
- </span>
- </DropdownMenuItem>
-
- {bookmark.content.type === BookmarkTypes.LINK && (
- <DropdownMenuItem
- onClick={() => {
- fullPageArchiveBookmarkMutator.mutate({
- bookmarkId: bookmark.id,
- archiveFullPage: true,
- });
- }}
- >
- <FileDown className="mr-2 size-4" />
- <span>{t("actions.download_full_page_archive")}</span>
- </DropdownMenuItem>
- )}
-
- {bookmark.content.type === BookmarkTypes.LINK && (
+ {visibleItems.map((item) => (
<DropdownMenuItem
- disabled={!isClipboardAvailable}
- onClick={() => {
- navigator.clipboard.writeText(
- (bookmark.content as ZBookmarkedLink).url,
- );
- toast({
- description: t("toasts.bookmarks.clipboard_copied"),
- });
- }}
+ key={item.id}
+ disabled={item.disabled}
+ className={item.className}
+ onClick={item.onClick}
>
- <Link className="mr-2 size-4" />
- <span>{t("actions.copy_link")}</span>
+ {item.icon}
+ <span>{item.title}</span>
</DropdownMenuItem>
- )}
-
- <DropdownMenuItem onClick={() => setManageListsModalOpen(true)}>
- <List className="mr-2 size-4" />
- <span>{t("actions.manage_lists")}</span>
- </DropdownMenuItem>
-
- {listId &&
- withinListContext &&
- withinListContext.type === "manual" && (
- <DropdownMenuItem
- disabled={demoMode}
- onClick={() =>
- removeFromListMutator.mutate({
- listId,
- bookmarkId: bookmark.id,
- })
- }
- >
- <ListX className="mr-2 size-4" />
- <span>{t("actions.remove_from_list")}</span>
- </DropdownMenuItem>
- )}
-
- {bookmark.content.type === BookmarkTypes.LINK && (
- <DropdownMenuItem
- disabled={demoMode}
- onClick={() =>
- crawlBookmarkMutator.mutate({ bookmarkId: bookmark.id })
- }
- >
- <RotateCw className="mr-2 size-4" />
- <span>{t("actions.refresh")}</span>
- </DropdownMenuItem>
- )}
- <DropdownMenuItem
- disabled={demoMode}
- className="text-destructive"
- onClick={() => setDeleteBookmarkDialogOpen(true)}
- >
- <Trash2 className="mr-2 size-4" />
- <span>{t("actions.delete")}</span>
- </DropdownMenuItem>
+ ))}
</DropdownMenuContent>
</DropdownMenu>
</>
diff --git a/apps/web/components/dashboard/bookmarks/BookmarkTagsEditor.tsx b/apps/web/components/dashboard/bookmarks/BookmarkTagsEditor.tsx
index fa4f40de..22b5408e 100644
--- a/apps/web/components/dashboard/bookmarks/BookmarkTagsEditor.tsx
+++ b/apps/web/components/dashboard/bookmarks/BookmarkTagsEditor.tsx
@@ -5,7 +5,13 @@ import { useUpdateBookmarkTags } from "@karakeep/shared-react/hooks/bookmarks";
import { TagsEditor } from "./TagsEditor";
-export function BookmarkTagsEditor({ bookmark }: { bookmark: ZBookmark }) {
+export function BookmarkTagsEditor({
+ bookmark,
+ disabled,
+}: {
+ bookmark: ZBookmark;
+ disabled?: boolean;
+}) {
const { mutate } = useUpdateBookmarkTags({
onSuccess: () => {
toast({
@@ -24,6 +30,7 @@ export function BookmarkTagsEditor({ bookmark }: { bookmark: ZBookmark }) {
return (
<TagsEditor
tags={bookmark.tags}
+ disabled={disabled}
onAttach={({ tagName, tagId }) => {
mutate({
bookmarkId: bookmark.id,
diff --git a/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx b/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx
index e75886e1..b2cf118e 100644
--- a/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx
+++ b/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx
@@ -17,9 +17,11 @@ import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks";
function AISummary({
bookmarkId,
summary,
+ readOnly = false,
}: {
bookmarkId: string;
summary: string;
+ readOnly?: boolean;
}) {
const [isExpanded, setIsExpanded] = React.useState(false);
const { mutate: resummarize, isPending: isResummarizing } =
@@ -60,28 +62,34 @@ function AISummary({
</MarkdownReadonly>
{isExpanded && (
<span className="flex justify-end gap-2 pt-2">
- <ActionButton
- variant="none"
- size="none"
- spinner={<LoadingSpinner className="size-4" />}
- className="rounded-full bg-gray-200 p-1 text-gray-600 dark:bg-gray-700 dark:text-gray-400"
- aria-label={isExpanded ? "Collapse" : "Expand"}
- loading={isResummarizing}
- onClick={() => resummarize({ bookmarkId })}
- >
- <RefreshCw size={16} />
- </ActionButton>
- <ActionButton
- size="none"
- variant="none"
- spinner={<LoadingSpinner className="size-4" />}
- className="rounded-full bg-gray-200 p-1 text-gray-600 dark:bg-gray-700 dark:text-gray-400"
- aria-label={isExpanded ? "Collapse" : "Expand"}
- loading={isUpdatingBookmark}
- onClick={() => updateBookmark({ bookmarkId, summary: null })}
- >
- <Trash2 size={16} />
- </ActionButton>
+ {!readOnly && (
+ <>
+ <ActionButton
+ variant="none"
+ size="none"
+ spinner={<LoadingSpinner className="size-4" />}
+ className="rounded-full bg-gray-200 p-1 text-gray-600 dark:bg-gray-700 dark:text-gray-400"
+ aria-label={isExpanded ? "Collapse" : "Expand"}
+ loading={isResummarizing}
+ onClick={() => resummarize({ bookmarkId })}
+ >
+ <RefreshCw size={16} />
+ </ActionButton>
+ <ActionButton
+ size="none"
+ variant="none"
+ spinner={<LoadingSpinner className="size-4" />}
+ className="rounded-full bg-gray-200 p-1 text-gray-600 dark:bg-gray-700 dark:text-gray-400"
+ aria-label={isExpanded ? "Collapse" : "Expand"}
+ loading={isUpdatingBookmark}
+ onClick={() =>
+ updateBookmark({ bookmarkId, summary: null })
+ }
+ >
+ <Trash2 size={16} />
+ </ActionButton>
+ </>
+ )}
<button
className="rounded-full bg-gray-200 p-1 text-gray-600 dark:bg-gray-700 dark:text-gray-400"
aria-label="Collapse"
@@ -99,8 +107,10 @@ function AISummary({
export default function SummarizeBookmarkArea({
bookmark,
+ readOnly = false,
}: {
bookmark: ZBookmark;
+ readOnly?: boolean;
}) {
const { t } = useTranslation();
const { mutate, isPending } = useSummarizeBookmark({
@@ -118,8 +128,14 @@ export default function SummarizeBookmarkArea({
}
if (bookmark.summary) {
- return <AISummary bookmarkId={bookmark.id} summary={bookmark.summary} />;
- } else if (!clientConfig.inference.isConfigured) {
+ return (
+ <AISummary
+ bookmarkId={bookmark.id}
+ summary={bookmark.summary}
+ readOnly={readOnly}
+ />
+ );
+ } else if (!clientConfig.inference.isConfigured || readOnly) {
return null;
} else {
return (
diff --git a/apps/web/components/dashboard/bookmarks/TagList.tsx b/apps/web/components/dashboard/bookmarks/TagList.tsx
index 593a269b..f1c319ea 100644
--- a/apps/web/components/dashboard/bookmarks/TagList.tsx
+++ b/apps/web/components/dashboard/bookmarks/TagList.tsx
@@ -2,6 +2,7 @@ import Link from "next/link";
import { badgeVariants } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
+import { useSession } from "next-auth/react";
import type { ZBookmark } from "@karakeep/shared/types/bookmarks";
@@ -14,6 +15,9 @@ export default function TagList({
loading?: boolean;
className?: string;
}) {
+ const { data: session } = useSession();
+ const isOwner = session?.user?.id === bookmark.userId;
+
if (loading) {
return (
<div className="flex w-full flex-col justify-end space-y-2 p-2">
@@ -26,16 +30,28 @@ export default function TagList({
<>
{bookmark.tags.map((t) => (
<div key={t.id} className={className}>
- <Link
- key={t.id}
- className={cn(
- badgeVariants({ variant: "secondary" }),
- "text-nowrap font-light text-gray-700 hover:bg-foreground hover:text-secondary dark:text-gray-400",
- )}
- href={`/dashboard/tags/${t.id}`}
- >
- {t.name}
- </Link>
+ {isOwner ? (
+ <Link
+ key={t.id}
+ className={cn(
+ badgeVariants({ variant: "secondary" }),
+ "text-nowrap font-light text-gray-700 hover:bg-foreground hover:text-secondary dark:text-gray-400",
+ )}
+ href={`/dashboard/tags/${t.id}`}
+ >
+ {t.name}
+ </Link>
+ ) : (
+ <span
+ key={t.id}
+ className={cn(
+ badgeVariants({ variant: "secondary" }),
+ "text-nowrap font-light text-gray-700 dark:text-gray-400",
+ )}
+ >
+ {t.name}
+ </span>
+ )}
</div>
))}
</>
diff --git a/apps/web/components/dashboard/bookmarks/TagsEditor.tsx b/apps/web/components/dashboard/bookmarks/TagsEditor.tsx
index 512fa990..bc06c647 100644
--- a/apps/web/components/dashboard/bookmarks/TagsEditor.tsx
+++ b/apps/web/components/dashboard/bookmarks/TagsEditor.tsx
@@ -25,13 +25,15 @@ export function TagsEditor({
tags: _tags,
onAttach,
onDetach,
+ disabled,
}: {
tags: ZBookmarkTags[];
onAttach: (tag: { tagName: string; tagId?: string }) => void;
onDetach: (tag: { tagName: string; tagId: string }) => void;
+ disabled?: boolean;
}) {
const demoMode = !!useClientConfig().demoMode;
- const isDisabled = demoMode;
+ const isDisabled = demoMode || disabled;
const inputRef = React.useRef<HTMLInputElement>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
const [open, setOpen] = React.useState(false);
diff --git a/apps/web/components/dashboard/highlights/AllHighlights.tsx b/apps/web/components/dashboard/highlights/AllHighlights.tsx
index 9f39a471..23fa51d2 100644
--- a/apps/web/components/dashboard/highlights/AllHighlights.tsx
+++ b/apps/web/components/dashboard/highlights/AllHighlights.tsx
@@ -26,7 +26,7 @@ function Highlight({ highlight }: { highlight: ZHighlight }) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2">
- <HighlightCard highlight={highlight} clickable={false} />
+ <HighlightCard highlight={highlight} clickable={false} readOnly={false} />
<span className="flex items-center gap-0.5 text-xs italic text-gray-400">
<span title={localCreatedAt}>{fromNow}</span>
<Dot />
diff --git a/apps/web/components/dashboard/highlights/HighlightCard.tsx b/apps/web/components/dashboard/highlights/HighlightCard.tsx
index 8bb24353..1bba0b47 100644
--- a/apps/web/components/dashboard/highlights/HighlightCard.tsx
+++ b/apps/web/components/dashboard/highlights/HighlightCard.tsx
@@ -12,10 +12,12 @@ export default function HighlightCard({
highlight,
clickable,
className,
+ readOnly,
}: {
highlight: ZHighlight;
clickable: boolean;
className?: string;
+ readOnly: boolean;
}) {
const { mutate: deleteHighlight, isPending: isDeleting } = useDeleteHighlight(
{
@@ -62,15 +64,17 @@ export default function HighlightCard({
<p>{highlight.text}</p>
</blockquote>
</Wrapper>
- <div className="flex gap-2">
- <ActionButton
- loading={isDeleting}
- variant="ghost"
- onClick={() => deleteHighlight({ highlightId: highlight.id })}
- >
- <Trash2 className="size-4 text-destructive" />
- </ActionButton>
- </div>
+ {!readOnly && (
+ <div className="flex gap-2">
+ <ActionButton
+ loading={isDeleting}
+ variant="ghost"
+ onClick={() => deleteHighlight({ highlightId: highlight.id })}
+ >
+ <Trash2 className="size-4 text-destructive" />
+ </ActionButton>
+ </div>
+ )}
</div>
);
}
diff --git a/apps/web/components/dashboard/lists/CollapsibleBookmarkLists.tsx b/apps/web/components/dashboard/lists/CollapsibleBookmarkLists.tsx
index 90d4cb3f..2f8fca6a 100644
--- a/apps/web/components/dashboard/lists/CollapsibleBookmarkLists.tsx
+++ b/apps/web/components/dashboard/lists/CollapsibleBookmarkLists.tsx
@@ -4,10 +4,7 @@ import { FullPageSpinner } from "@/components/ui/full-page-spinner";
import { api } from "@/lib/trpc";
import { keepPreviousData } from "@tanstack/react-query";
-import {
- augmentBookmarkListsWithInitialData,
- useBookmarkLists,
-} from "@karakeep/shared-react/hooks/lists";
+import { useBookmarkLists } from "@karakeep/shared-react/hooks/lists";
import { ZBookmarkList } from "@karakeep/shared/types/lists";
import { ZBookmarkListTreeNode } from "@karakeep/shared/utils/listUtils";
@@ -83,20 +80,15 @@ export function CollapsibleBookmarkLists({
className,
isOpenFunc,
}: {
- initialData?: ZBookmarkList[];
+ initialData: ZBookmarkList[];
render: RenderFunc;
isOpenFunc?: IsOpenFunc;
className?: string;
}) {
let { data } = useBookmarkLists(undefined, {
- initialData: initialData ? { lists: initialData } : undefined,
+ initialData: { lists: initialData },
});
- // TODO: This seems to be a bug in react query
- if (initialData) {
- data = augmentBookmarkListsWithInitialData(data, initialData);
- }
-
if (!data) {
return <FullPageSpinner />;
}
diff --git a/apps/web/components/dashboard/lists/LeaveListConfirmationDialog.tsx b/apps/web/components/dashboard/lists/LeaveListConfirmationDialog.tsx
new file mode 100644
index 00000000..62dbbcef
--- /dev/null
+++ b/apps/web/components/dashboard/lists/LeaveListConfirmationDialog.tsx
@@ -0,0 +1,83 @@
+import React from "react";
+import { usePathname, useRouter } from "next/navigation";
+import { ActionButton } from "@/components/ui/action-button";
+import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog";
+import { toast } from "@/components/ui/use-toast";
+import { useTranslation } from "@/lib/i18n/client";
+import { api } from "@/lib/trpc";
+
+import type { ZBookmarkList } from "@karakeep/shared/types/lists";
+
+export default function LeaveListConfirmationDialog({
+ list,
+ children,
+ open,
+ setOpen,
+}: {
+ list: ZBookmarkList;
+ children?: React.ReactNode;
+ open: boolean;
+ setOpen: (v: boolean) => void;
+}) {
+ const { t } = useTranslation();
+ const currentPath = usePathname();
+ const router = useRouter();
+ const utils = api.useUtils();
+
+ const { mutate: leaveList, isPending } = api.lists.leaveList.useMutation({
+ onSuccess: () => {
+ toast({
+ description: t("lists.leave_list.success", {
+ icon: list.icon,
+ name: list.name,
+ }),
+ });
+ setOpen(false);
+ // Invalidate the lists cache
+ utils.lists.list.invalidate();
+ // If currently viewing this list, redirect to lists page
+ if (currentPath.includes(list.id)) {
+ router.push("/dashboard/lists");
+ }
+ },
+ onError: (error) => {
+ toast({
+ variant: "destructive",
+ description: error.message || t("common.something_went_wrong"),
+ });
+ },
+ });
+
+ return (
+ <ActionConfirmingDialog
+ open={open}
+ setOpen={setOpen}
+ title={t("lists.leave_list.title")}
+ description={
+ <div className="space-y-3">
+ <p className="text-balance">
+ {t("lists.leave_list.confirm_message", {
+ icon: list.icon,
+ name: list.name,
+ })}
+ </p>
+ <p className="text-balance text-sm text-muted-foreground">
+ {t("lists.leave_list.warning")}
+ </p>
+ </div>
+ }
+ actionButton={() => (
+ <ActionButton
+ type="button"
+ variant="destructive"
+ loading={isPending}
+ onClick={() => leaveList({ listId: list.id })}
+ >
+ {t("lists.leave_list.action")}
+ </ActionButton>
+ )}
+ >
+ {children}
+ </ActionConfirmingDialog>
+ );
+}
diff --git a/apps/web/components/dashboard/lists/ListHeader.tsx b/apps/web/components/dashboard/lists/ListHeader.tsx
index 4e318dad..8e014e2a 100644
--- a/apps/web/components/dashboard/lists/ListHeader.tsx
+++ b/apps/web/components/dashboard/lists/ListHeader.tsx
@@ -3,8 +3,14 @@
import { useMemo } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
import { useTranslation } from "@/lib/i18n/client";
-import { MoreHorizontal, SearchIcon } from "lucide-react";
+import { MoreHorizontal, SearchIcon, Users } from "lucide-react";
import { api } from "@karakeep/shared-react/trpc";
import { parseSearchQuery } from "@karakeep/shared/searchQueryParser";
@@ -48,12 +54,24 @@ export default function ListHeader({
<div className="flex items-center gap-2">
<span className="text-2xl">
{list.icon} {list.name}
- {list.description && (
- <span className="mx-2 text-lg text-gray-400">
- {`(${list.description})`}
- </span>
- )}
</span>
+ {list.hasCollaborators && (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Users className="size-5 text-primary" />
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>{t("lists.shared")}</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ )}
+ {list.description && (
+ <span className="text-lg text-gray-400">
+ {`(${list.description})`}
+ </span>
+ )}
</div>
<div className="flex items-center">
{parsedQuery && (
diff --git a/apps/web/components/dashboard/lists/ListOptions.tsx b/apps/web/components/dashboard/lists/ListOptions.tsx
index 7e020374..b80ac680 100644
--- a/apps/web/components/dashboard/lists/ListOptions.tsx
+++ b/apps/web/components/dashboard/lists/ListOptions.tsx
@@ -8,6 +8,7 @@ import {
import { useShowArchived } from "@/components/utils/useShowArchived";
import { useTranslation } from "@/lib/i18n/client";
import {
+ DoorOpen,
FolderInput,
Pencil,
Plus,
@@ -15,12 +16,15 @@ import {
Square,
SquareCheck,
Trash2,
+ Users,
} from "lucide-react";
import { ZBookmarkList } from "@karakeep/shared/types/lists";
import { EditListModal } from "../lists/EditListModal";
import DeleteListConfirmationDialog from "./DeleteListConfirmationDialog";
+import LeaveListConfirmationDialog from "./LeaveListConfirmationDialog";
+import { ManageCollaboratorsModal } from "./ManageCollaboratorsModal";
import { MergeListModal } from "./MergeListModal";
import { ShareListModal } from "./ShareListModal";
@@ -39,10 +43,102 @@ export function ListOptions({
const { showArchived, onClickShowArchived } = useShowArchived();
const [deleteListDialogOpen, setDeleteListDialogOpen] = useState(false);
+ const [leaveListDialogOpen, setLeaveListDialogOpen] = useState(false);
const [newNestedListModalOpen, setNewNestedListModalOpen] = useState(false);
const [mergeListModalOpen, setMergeListModalOpen] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [shareModalOpen, setShareModalOpen] = useState(false);
+ const [collaboratorsModalOpen, setCollaboratorsModalOpen] = useState(false);
+
+ // Only owners can manage the list (edit, delete, manage collaborators, etc.)
+ const isOwner = list.userRole === "owner";
+ // Collaborators (non-owners) can leave the list
+ const isCollaborator =
+ list.userRole === "editor" || list.userRole === "viewer";
+
+ // Define action items array
+ const actionItems = [
+ {
+ id: "edit",
+ title: t("actions.edit"),
+ icon: <Pencil className="size-4" />,
+ visible: isOwner,
+ disabled: false,
+ onClick: () => setEditModalOpen(true),
+ },
+ {
+ id: "share",
+ title: t("lists.share_list"),
+ icon: <Share className="size-4" />,
+ visible: isOwner,
+ disabled: false,
+ onClick: () => setShareModalOpen(true),
+ },
+ {
+ id: "manage-collaborators",
+ title: isOwner
+ ? t("lists.collaborators.manage")
+ : t("lists.collaborators.view"),
+ icon: <Users className="size-4" />,
+ visible: true, // Always visible for all roles
+ disabled: false,
+ onClick: () => setCollaboratorsModalOpen(true),
+ },
+ {
+ id: "new-nested-list",
+ title: t("lists.new_nested_list"),
+ icon: <Plus className="size-4" />,
+ visible: isOwner,
+ disabled: false,
+ onClick: () => setNewNestedListModalOpen(true),
+ },
+ {
+ id: "merge-list",
+ title: t("lists.merge_list"),
+ icon: <FolderInput className="size-4" />,
+ visible: isOwner,
+ disabled: false,
+ onClick: () => setMergeListModalOpen(true),
+ },
+ {
+ id: "toggle-archived",
+ title: t("actions.toggle_show_archived"),
+ icon: showArchived ? (
+ <SquareCheck className="size-4" />
+ ) : (
+ <Square className="size-4" />
+ ),
+ visible: true,
+ disabled: false,
+ onClick: onClickShowArchived,
+ },
+ {
+ id: "leave-list",
+ title: t("lists.leave_list.action"),
+ icon: <DoorOpen className="size-4" />,
+ visible: isCollaborator,
+ disabled: false,
+ className: "flex gap-2 text-destructive",
+ onClick: () => setLeaveListDialogOpen(true),
+ },
+ {
+ id: "delete",
+ title: t("actions.delete"),
+ icon: <Trash2 className="size-4" />,
+ visible: isOwner,
+ disabled: false,
+ className: "flex gap-2 text-destructive",
+ onClick: () => setDeleteListDialogOpen(true),
+ },
+ ];
+
+ // Filter visible items
+ const visibleItems = actionItems.filter((item) => item.visible);
+
+ // If no items are visible, don't render the dropdown
+ if (visibleItems.length === 0) {
+ return null;
+ }
return (
<DropdownMenu open={isOpen} onOpenChange={onOpenChange}>
@@ -51,6 +147,12 @@ export function ListOptions({
setOpen={setShareModalOpen}
list={list}
/>
+ <ManageCollaboratorsModal
+ open={collaboratorsModalOpen}
+ setOpen={setCollaboratorsModalOpen}
+ list={list}
+ readOnly={!isOwner}
+ />
<EditListModal
open={newNestedListModalOpen}
setOpen={setNewNestedListModalOpen}
@@ -73,51 +175,24 @@ export function ListOptions({
open={deleteListDialogOpen}
setOpen={setDeleteListDialogOpen}
/>
+ <LeaveListConfirmationDialog
+ list={list}
+ open={leaveListDialogOpen}
+ setOpen={setLeaveListDialogOpen}
+ />
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent>
- <DropdownMenuItem
- className="flex gap-2"
- onClick={() => setEditModalOpen(true)}
- >
- <Pencil className="size-4" />
- <span>{t("actions.edit")}</span>
- </DropdownMenuItem>
- <DropdownMenuItem
- className="flex gap-2"
- onClick={() => setShareModalOpen(true)}
- >
- <Share className="size-4" />
- <span>{t("lists.share_list")}</span>
- </DropdownMenuItem>
- <DropdownMenuItem
- className="flex gap-2"
- onClick={() => setNewNestedListModalOpen(true)}
- >
- <Plus className="size-4" />
- <span>{t("lists.new_nested_list")}</span>
- </DropdownMenuItem>
- <DropdownMenuItem
- className="flex gap-2"
- onClick={() => setMergeListModalOpen(true)}
- >
- <FolderInput className="size-4" />
- <span>{t("lists.merge_list")}</span>
- </DropdownMenuItem>
- <DropdownMenuItem className="flex gap-2" onClick={onClickShowArchived}>
- {showArchived ? (
- <SquareCheck className="size-4" />
- ) : (
- <Square className="size-4" />
- )}
- <span>{t("actions.toggle_show_archived")}</span>
- </DropdownMenuItem>
- <DropdownMenuItem
- className="flex gap-2"
- onClick={() => setDeleteListDialogOpen(true)}
- >
- <Trash2 className="size-4" />
- <span>{t("actions.delete")}</span>
- </DropdownMenuItem>
+ {visibleItems.map((item) => (
+ <DropdownMenuItem
+ key={item.id}
+ className={item.className ?? "flex gap-2"}
+ disabled={item.disabled}
+ onClick={item.onClick}
+ >
+ {item.icon}
+ <span>{item.title}</span>
+ </DropdownMenuItem>
+ ))}
</DropdownMenuContent>
</DropdownMenu>
);
diff --git a/apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx b/apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx
new file mode 100644
index 00000000..8e0a0602
--- /dev/null
+++ b/apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx
@@ -0,0 +1,345 @@
+"use client";
+
+import { useState } from "react";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { toast } from "@/components/ui/use-toast";
+import { useTranslation } from "@/lib/i18n/client";
+import { api } from "@/lib/trpc";
+import { Loader2, Trash2, UserPlus, Users } from "lucide-react";
+
+import { ZBookmarkList } from "@karakeep/shared/types/lists";
+
+export function ManageCollaboratorsModal({
+ open: userOpen,
+ setOpen: userSetOpen,
+ list,
+ children,
+ readOnly = false,
+}: {
+ open?: boolean;
+ setOpen?: (v: boolean) => void;
+ list: ZBookmarkList;
+ children?: React.ReactNode;
+ readOnly?: boolean;
+}) {
+ if (
+ (userOpen !== undefined && !userSetOpen) ||
+ (userOpen === undefined && userSetOpen)
+ ) {
+ throw new Error("You must provide both open and setOpen or neither");
+ }
+ const [customOpen, customSetOpen] = useState(false);
+ const [open, setOpen] = [
+ userOpen ?? customOpen,
+ userSetOpen ?? customSetOpen,
+ ];
+
+ const [newCollaboratorEmail, setNewCollaboratorEmail] = useState("");
+ const [newCollaboratorRole, setNewCollaboratorRole] = useState<
+ "viewer" | "editor"
+ >("viewer");
+
+ const { t } = useTranslation();
+ const utils = api.useUtils();
+
+ const invalidateListCaches = () =>
+ Promise.all([
+ utils.lists.getCollaborators.invalidate({ listId: list.id }),
+ utils.lists.get.invalidate({ listId: list.id }),
+ utils.lists.list.invalidate(),
+ utils.bookmarks.getBookmarks.invalidate({ listId: list.id }),
+ ]);
+
+ // Fetch collaborators
+ const { data: collaboratorsData, isLoading } =
+ api.lists.getCollaborators.useQuery({ listId: list.id }, { enabled: open });
+
+ // Mutations
+ const addCollaborator = api.lists.addCollaborator.useMutation({
+ onSuccess: async () => {
+ toast({
+ description: t("lists.collaborators.added_successfully"),
+ });
+ setNewCollaboratorEmail("");
+ await invalidateListCaches();
+ },
+ onError: (error) => {
+ toast({
+ variant: "destructive",
+ description: error.message || t("lists.collaborators.failed_to_add"),
+ });
+ },
+ });
+
+ const removeCollaborator = api.lists.removeCollaborator.useMutation({
+ onSuccess: async () => {
+ toast({
+ description: t("lists.collaborators.removed"),
+ });
+ await invalidateListCaches();
+ },
+ onError: (error) => {
+ toast({
+ variant: "destructive",
+ description: error.message || t("lists.collaborators.failed_to_remove"),
+ });
+ },
+ });
+
+ const updateCollaboratorRole = api.lists.updateCollaboratorRole.useMutation({
+ onSuccess: async () => {
+ toast({
+ description: t("lists.collaborators.role_updated"),
+ });
+ await invalidateListCaches();
+ },
+ onError: (error) => {
+ toast({
+ variant: "destructive",
+ description:
+ error.message || t("lists.collaborators.failed_to_update_role"),
+ });
+ },
+ });
+
+ const handleAddCollaborator = () => {
+ if (!newCollaboratorEmail.trim()) {
+ toast({
+ variant: "destructive",
+ description: t("lists.collaborators.please_enter_email"),
+ });
+ return;
+ }
+
+ addCollaborator.mutate({
+ listId: list.id,
+ email: newCollaboratorEmail,
+ role: newCollaboratorRole,
+ });
+ };
+
+ return (
+ <Dialog
+ open={open}
+ onOpenChange={(s) => {
+ setOpen(s);
+ }}
+ >
+ {children && <DialogTrigger asChild>{children}</DialogTrigger>}
+ <DialogContent className="max-w-2xl">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <Users className="h-5 w-5" />
+ {readOnly
+ ? t("lists.collaborators.collaborators")
+ : t("lists.collaborators.manage")}
+ <Badge className="bg-green-600 text-white hover:bg-green-600/80">
+ Beta
+ </Badge>
+ </DialogTitle>
+ <DialogDescription>
+ {readOnly
+ ? t("lists.collaborators.people_with_access")
+ : t("lists.collaborators.add_or_remove")}
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-6">
+ {/* Add Collaborator Section */}
+ {!readOnly && (
+ <div className="space-y-3">
+ <Label>{t("lists.collaborators.add")}</Label>
+ <div className="flex gap-2">
+ <div className="flex-1">
+ <Input
+ type="email"
+ placeholder={t("lists.collaborators.enter_email")}
+ value={newCollaboratorEmail}
+ onChange={(e) => setNewCollaboratorEmail(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ handleAddCollaborator();
+ }
+ }}
+ />
+ </div>
+ <Select
+ value={newCollaboratorRole}
+ onValueChange={(value) =>
+ setNewCollaboratorRole(value as "viewer" | "editor")
+ }
+ >
+ <SelectTrigger className="w-32">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="viewer">
+ {t("lists.collaborators.viewer")}
+ </SelectItem>
+ <SelectItem value="editor">
+ {t("lists.collaborators.editor")}
+ </SelectItem>
+ </SelectContent>
+ </Select>
+ <Button
+ onClick={handleAddCollaborator}
+ disabled={addCollaborator.isPending}
+ >
+ {addCollaborator.isPending ? (
+ <Loader2 className="h-4 w-4 animate-spin" />
+ ) : (
+ <UserPlus className="h-4 w-4" />
+ )}
+ </Button>
+ </div>
+ <p className="text-xs text-muted-foreground">
+ <strong>{t("lists.collaborators.viewer")}:</strong>{" "}
+ {t("lists.collaborators.viewer_description")}
+ <br />
+ <strong>{t("lists.collaborators.editor")}:</strong>{" "}
+ {t("lists.collaborators.editor_description")}
+ </p>
+ </div>
+ )}
+
+ {/* Current Collaborators */}
+ <div className="space-y-3">
+ <Label>
+ {readOnly
+ ? t("lists.collaborators.collaborators")
+ : t("lists.collaborators.current")}
+ </Label>
+ {isLoading ? (
+ <div className="flex justify-center py-8">
+ <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
+ </div>
+ ) : collaboratorsData ? (
+ <div className="space-y-2">
+ {/* Show owner first */}
+ {collaboratorsData.owner && (
+ <div
+ key={`owner-${collaboratorsData.owner.id}`}
+ className="flex items-center justify-between rounded-lg border p-3"
+ >
+ <div className="flex-1">
+ <div className="font-medium">
+ {collaboratorsData.owner.name}
+ </div>
+ <div className="text-sm text-muted-foreground">
+ {collaboratorsData.owner.email}
+ </div>
+ </div>
+ <div className="text-sm capitalize text-muted-foreground">
+ {t("lists.collaborators.owner")}
+ </div>
+ </div>
+ )}
+ {/* Show collaborators */}
+ {collaboratorsData.collaborators.length > 0 ? (
+ collaboratorsData.collaborators.map((collaborator) => (
+ <div
+ key={collaborator.id}
+ className="flex items-center justify-between rounded-lg border p-3"
+ >
+ <div className="flex-1">
+ <div className="font-medium">
+ {collaborator.user.name}
+ </div>
+ <div className="text-sm text-muted-foreground">
+ {collaborator.user.email}
+ </div>
+ </div>
+ {readOnly ? (
+ <div className="text-sm capitalize text-muted-foreground">
+ {collaborator.role}
+ </div>
+ ) : (
+ <div className="flex items-center gap-2">
+ <Select
+ value={collaborator.role}
+ onValueChange={(value) =>
+ updateCollaboratorRole.mutate({
+ listId: list.id,
+ userId: collaborator.userId,
+ role: value as "viewer" | "editor",
+ })
+ }
+ >
+ <SelectTrigger className="w-28">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="viewer">
+ {t("lists.collaborators.viewer")}
+ </SelectItem>
+ <SelectItem value="editor">
+ {t("lists.collaborators.editor")}
+ </SelectItem>
+ </SelectContent>
+ </Select>
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={() =>
+ removeCollaborator.mutate({
+ listId: list.id,
+ userId: collaborator.userId,
+ })
+ }
+ disabled={removeCollaborator.isPending}
+ >
+ <Trash2 className="h-4 w-4 text-destructive" />
+ </Button>
+ </div>
+ )}
+ </div>
+ ))
+ ) : !collaboratorsData.owner ? (
+ <div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">
+ {readOnly
+ ? t("lists.collaborators.no_collaborators_readonly")
+ : t("lists.collaborators.no_collaborators")}
+ </div>
+ ) : null}
+ </div>
+ ) : (
+ <div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">
+ {readOnly
+ ? t("lists.collaborators.no_collaborators_readonly")
+ : t("lists.collaborators.no_collaborators")}
+ </div>
+ )}
+ </div>
+ </div>
+
+ <DialogFooter className="sm:justify-end">
+ <DialogClose asChild>
+ <Button type="button" variant="secondary">
+ {t("actions.close")}
+ </Button>
+ </DialogClose>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/apps/web/components/dashboard/preview/AttachmentBox.tsx b/apps/web/components/dashboard/preview/AttachmentBox.tsx
index e24cc646..73eea640 100644
--- a/apps/web/components/dashboard/preview/AttachmentBox.tsx
+++ b/apps/web/components/dashboard/preview/AttachmentBox.tsx
@@ -27,7 +27,13 @@ import {
isAllowedToDetachAsset,
} from "@karakeep/trpc/lib/attachments";
-export default function AttachmentBox({ bookmark }: { bookmark: ZBookmark }) {
+export default function AttachmentBox({
+ bookmark,
+ readOnly = false,
+}: {
+ bookmark: ZBookmark;
+ readOnly?: boolean;
+}) {
const { t } = useTranslation();
const { mutate: attachAsset, isPending: isAttaching } =
useAttachBookmarkAsset({
@@ -122,7 +128,8 @@ export default function AttachmentBox({ bookmark }: { bookmark: ZBookmark }) {
>
<Download className="size-4" />
</Link>
- {isAllowedToAttachAsset(asset.assetType) &&
+ {!readOnly &&
+ isAllowedToAttachAsset(asset.assetType) &&
asset.assetType !== "userUploaded" && (
<FilePickerButton
title="Replace"
@@ -147,7 +154,7 @@ export default function AttachmentBox({ bookmark }: { bookmark: ZBookmark }) {
<Pencil className="size-4" />
</FilePickerButton>
)}
- {isAllowedToDetachAsset(asset.assetType) && (
+ {!readOnly && isAllowedToDetachAsset(asset.assetType) && (
<ActionConfirmingDialog
title="Delete Attachment?"
description={`Are you sure you want to delete the attachment of the bookmark?`}
@@ -175,7 +182,8 @@ export default function AttachmentBox({ bookmark }: { bookmark: ZBookmark }) {
</div>
</div>
))}
- {!bookmark.assets.some((asset) => asset.assetType == "bannerImage") &&
+ {!readOnly &&
+ !bookmark.assets.some((asset) => asset.assetType == "bannerImage") &&
bookmark.content.type != BookmarkTypes.ASSET && (
<FilePickerButton
title="Attach a Banner"
@@ -203,30 +211,32 @@ export default function AttachmentBox({ bookmark }: { bookmark: ZBookmark }) {
Attach a Banner
</FilePickerButton>
)}
- <FilePickerButton
- title="Upload File"
- loading={isAttaching}
- multiple={false}
- variant="ghost"
- size="none"
- className="flex w-full items-center justify-center gap-2"
- onFileSelect={(file) =>
- uploadAsset(file, {
- onSuccess: (resp) => {
- attachAsset({
- bookmarkId: bookmark.id,
- asset: {
- id: resp.assetId,
- assetType: "userUploaded",
- },
- });
- },
- })
- }
- >
- <Plus className="size-4" />
- Upload File
- </FilePickerButton>
+ {!readOnly && (
+ <FilePickerButton
+ title="Upload File"
+ loading={isAttaching}
+ multiple={false}
+ variant="ghost"
+ size="none"
+ className="flex w-full items-center justify-center gap-2"
+ onFileSelect={(file) =>
+ uploadAsset(file, {
+ onSuccess: (resp) => {
+ attachAsset({
+ bookmarkId: bookmark.id,
+ asset: {
+ id: resp.assetId,
+ assetType: "userUploaded",
+ },
+ });
+ },
+ })
+ }
+ >
+ <Plus className="size-4" />
+ Upload File
+ </FilePickerButton>
+ )}
</CollapsibleContent>
</Collapsible>
);
diff --git a/apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx b/apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx
index 19499d3e..e0f20ea2 100644
--- a/apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx
+++ b/apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx
@@ -95,6 +95,7 @@ interface HTMLHighlighterProps {
style?: React.CSSProperties;
className?: string;
highlights?: Highlight[];
+ readOnly?: boolean;
onHighlight?: (highlight: Highlight) => void;
onUpdateHighlight?: (highlight: Highlight) => void;
onDeleteHighlight?: (highlight: Highlight) => void;
@@ -105,6 +106,7 @@ function BookmarkHTMLHighlighter({
className,
style,
highlights = [],
+ readOnly = false,
onHighlight,
onUpdateHighlight,
onDeleteHighlight,
@@ -173,6 +175,10 @@ function BookmarkHTMLHighlighter({
}, [pendingHighlight, contentRef]);
const handlePointerUp = (e: React.PointerEvent) => {
+ if (readOnly) {
+ return;
+ }
+
const selection = window.getSelection();
// Check if we clicked on an existing highlight
diff --git a/apps/web/components/dashboard/preview/BookmarkPreview.tsx b/apps/web/components/dashboard/preview/BookmarkPreview.tsx
index 4766bd32..7e6bf814 100644
--- a/apps/web/components/dashboard/preview/BookmarkPreview.tsx
+++ b/apps/web/components/dashboard/preview/BookmarkPreview.tsx
@@ -17,6 +17,7 @@ import useRelativeTime from "@/lib/hooks/relative-time";
import { useTranslation } from "@/lib/i18n/client";
import { api } from "@/lib/trpc";
import { Building, CalendarDays, ExternalLink, User } from "lucide-react";
+import { useSession } from "next-auth/react";
import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks";
import {
@@ -117,6 +118,7 @@ export default function BookmarkPreview({
}) {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<string>("content");
+ const { data: session } = useSession();
const { data: bookmark } = api.bookmarks.getBookmark.useQuery(
{
@@ -138,6 +140,9 @@ export default function BookmarkPreview({
return <FullPageSpinner />;
}
+ // Check if the current user owns this bookmark
+ const isOwner = session?.user?.id === bookmark.userId;
+
let content;
switch (bookmark.content.type) {
case BookmarkTypes.LINK: {
@@ -186,18 +191,18 @@ export default function BookmarkPreview({
</div>
<CreationTime createdAt={bookmark.createdAt} />
<BookmarkMetadata bookmark={bookmark} />
- <SummarizeBookmarkArea bookmark={bookmark} />
+ <SummarizeBookmarkArea bookmark={bookmark} readOnly={!isOwner} />
<div className="flex items-center gap-4">
<p className="text-sm text-gray-400">{t("common.tags")}</p>
- <BookmarkTagsEditor bookmark={bookmark} />
+ <BookmarkTagsEditor bookmark={bookmark} disabled={!isOwner} />
</div>
<div className="flex gap-4">
<p className="pt-2 text-sm text-gray-400">{t("common.note")}</p>
- <NoteEditor bookmark={bookmark} />
+ <NoteEditor bookmark={bookmark} disabled={!isOwner} />
</div>
- <AttachmentBox bookmark={bookmark} />
- <HighlightsBox bookmarkId={bookmark.id} />
- <ActionBar bookmark={bookmark} />
+ <AttachmentBox bookmark={bookmark} readOnly={!isOwner} />
+ <HighlightsBox bookmarkId={bookmark.id} readOnly={!isOwner} />
+ {isOwner && <ActionBar bookmark={bookmark} />}
</div>
);
diff --git a/apps/web/components/dashboard/preview/HighlightsBox.tsx b/apps/web/components/dashboard/preview/HighlightsBox.tsx
index 4da22d04..41ab7d74 100644
--- a/apps/web/components/dashboard/preview/HighlightsBox.tsx
+++ b/apps/web/components/dashboard/preview/HighlightsBox.tsx
@@ -11,7 +11,13 @@ import { ChevronsDownUp } from "lucide-react";
import HighlightCard from "../highlights/HighlightCard";
-export default function HighlightsBox({ bookmarkId }: { bookmarkId: string }) {
+export default function HighlightsBox({
+ bookmarkId,
+ readOnly,
+}: {
+ bookmarkId: string;
+ readOnly: boolean;
+}) {
const { t } = useTranslation();
const { data: highlights, isPending: isLoading } =
@@ -30,7 +36,11 @@ export default function HighlightsBox({ bookmarkId }: { bookmarkId: string }) {
<CollapsibleContent className="group flex flex-col py-3 text-sm">
{highlights.highlights.map((highlight) => (
<Fragment key={highlight.id}>
- <HighlightCard highlight={highlight} clickable />
+ <HighlightCard
+ highlight={highlight}
+ clickable
+ readOnly={readOnly}
+ />
<Separator className="m-2 h-0.5 bg-gray-200 last:hidden" />
</Fragment>
))}
diff --git a/apps/web/components/dashboard/preview/LinkContentSection.tsx b/apps/web/components/dashboard/preview/LinkContentSection.tsx
index 53559aa0..64b62df6 100644
--- a/apps/web/components/dashboard/preview/LinkContentSection.tsx
+++ b/apps/web/components/dashboard/preview/LinkContentSection.tsx
@@ -25,6 +25,7 @@ import {
ExpandIcon,
Video,
} from "lucide-react";
+import { useSession } from "next-auth/react";
import { useQueryState } from "nuqs";
import { ErrorBoundary } from "react-error-boundary";
@@ -111,6 +112,8 @@ export default function LinkContentSection({
const [section, setSection] = useQueryState("section", {
defaultValue: defaultSection,
});
+ const { data: session } = useSession();
+ const isOwner = session?.user?.id === bookmark.userId;
if (bookmark.content.type != BookmarkTypes.LINK) {
throw new Error("Invalid content type");
@@ -133,6 +136,7 @@ export default function LinkContentSection({
<ReaderView
className="prose mx-auto dark:prose-invert"
bookmarkId={bookmark.id}
+ readOnly={!isOwner}
/>
</ScrollArea>
);
diff --git a/apps/web/components/dashboard/preview/NoteEditor.tsx b/apps/web/components/dashboard/preview/NoteEditor.tsx
index 393628b5..538aff2e 100644
--- a/apps/web/components/dashboard/preview/NoteEditor.tsx
+++ b/apps/web/components/dashboard/preview/NoteEditor.tsx
@@ -5,7 +5,13 @@ import { useClientConfig } from "@/lib/clientConfig";
import type { ZBookmark } from "@karakeep/shared/types/bookmarks";
import { useUpdateBookmark } from "@karakeep/shared-react/hooks/bookmarks";
-export function NoteEditor({ bookmark }: { bookmark: ZBookmark }) {
+export function NoteEditor({
+ bookmark,
+ disabled,
+}: {
+ bookmark: ZBookmark;
+ disabled?: boolean;
+}) {
const demoMode = !!useClientConfig().demoMode;
const updateBookmarkMutator = useUpdateBookmark({
@@ -26,7 +32,7 @@ export function NoteEditor({ bookmark }: { bookmark: ZBookmark }) {
<Textarea
className="h-44 w-full overflow-auto rounded bg-background p-2 text-sm text-gray-400 dark:text-gray-300"
defaultValue={bookmark.note ?? ""}
- disabled={demoMode}
+ disabled={demoMode || disabled}
placeholder="Write some notes ..."
onBlur={(e) => {
if (e.currentTarget.value == bookmark.note) {
diff --git a/apps/web/components/dashboard/preview/ReaderView.tsx b/apps/web/components/dashboard/preview/ReaderView.tsx
index bf4c27a5..1974626a 100644
--- a/apps/web/components/dashboard/preview/ReaderView.tsx
+++ b/apps/web/components/dashboard/preview/ReaderView.tsx
@@ -15,10 +15,12 @@ export default function ReaderView({
bookmarkId,
className,
style,
+ readOnly,
}: {
bookmarkId: string;
className?: string;
style?: React.CSSProperties;
+ readOnly: boolean;
}) {
const { data: highlights } = api.highlights.getForBookmark.useQuery({
bookmarkId,
@@ -93,6 +95,7 @@ export default function ReaderView({
style={style}
htmlContent={cachedContent || ""}
highlights={highlights?.highlights ?? []}
+ readOnly={readOnly}
onDeleteHighlight={(h) =>
deleteHighlight({
highlightId: h.id,
diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json
index 7f322a8a..5db867a2 100644
--- a/apps/web/lib/i18n/locales/en/translation.json
+++ b/apps/web/lib/i18n/locales/en/translation.json
@@ -470,6 +470,7 @@
"lists": {
"all_lists": "All Lists",
"favourites": "Favourites",
+ "shared": "Shared",
"new_list": "New List",
"edit_list": "Edit List",
"share_list": "Share List",
@@ -501,6 +502,37 @@
"title": "Public List",
"description": "Allow others to view this list",
"share_link": "Share Link"
+ },
+ "collaborators": {
+ "manage": "Manage Collaborators",
+ "view": "View Collaborators",
+ "collaborators": "Collaborators",
+ "add": "Add Collaborator",
+ "current": "Current Collaborators",
+ "enter_email": "Enter email address",
+ "please_enter_email": "Please enter an email address",
+ "added_successfully": "Collaborator added successfully",
+ "failed_to_add": "Failed to add collaborator",
+ "removed": "Collaborator removed",
+ "failed_to_remove": "Failed to remove collaborator",
+ "role_updated": "Role updated",
+ "failed_to_update_role": "Failed to update role",
+ "viewer": "Viewer",
+ "editor": "Editor",
+ "owner": "Owner",
+ "viewer_description": "Can view bookmarks in the list",
+ "editor_description": "Can add and remove bookmarks",
+ "no_collaborators": "No collaborators yet. Add someone to start collaborating!",
+ "no_collaborators_readonly": "No collaborators for this list.",
+ "people_with_access": "People who have access to this list",
+ "add_or_remove": "Add or remove people who can access this list"
+ },
+ "leave_list": {
+ "title": "Leave List",
+ "confirm_message": "Are you sure you want to leave {{icon}} {{name}}?",
+ "warning": "You will no longer be able to view or access bookmarks in this list. The list owner can add you back if needed.",
+ "action": "Leave List",
+ "success": "You have left \"{{icon}} {{name}}\""
}
},
"tags": {
diff --git a/packages/api/routes/assets.ts b/packages/api/routes/assets.ts
index 9d9a60b3..50d11c47 100644
--- a/packages/api/routes/assets.ts
+++ b/packages/api/routes/assets.ts
@@ -1,9 +1,11 @@
import { zValidator } from "@hono/zod-validator";
-import { and, eq } from "drizzle-orm";
+import { TRPCError } from "@trpc/server";
+import { eq } from "drizzle-orm";
import { Hono } from "hono";
import { z } from "zod";
import { assets } from "@karakeep/db/schema";
+import { BareBookmark } from "@karakeep/trpc/models/bookmarks";
import { authMiddleware } from "../middlewares/auth";
import { serveAsset } from "../utils/assets";
@@ -36,13 +38,38 @@ const app = new Hono()
.get("/:assetId", async (c) => {
const assetId = c.req.param("assetId");
const assetDb = await c.var.ctx.db.query.assets.findFirst({
- where: and(eq(assets.id, assetId), eq(assets.userId, c.var.ctx.user.id)),
+ where: eq(assets.id, assetId),
+ columns: {
+ id: true,
+ userId: true,
+ bookmarkId: true,
+ },
});
if (!assetDb) {
return c.json({ error: "Asset not found" }, { status: 404 });
}
- return await serveAsset(c, assetId, c.var.ctx.user.id);
+
+ // If asset is not attached to a bookmark yet, only owner can access it
+ if (!assetDb.bookmarkId) {
+ if (assetDb.userId !== c.var.ctx.user.id) {
+ return c.json({ error: "Asset not found" }, { status: 404 });
+ }
+ return await serveAsset(c, assetId, assetDb.userId);
+ }
+
+ // If asset is attached to a bookmark, check bookmark access permissions
+ try {
+ // This throws if the user doesn't have access to the bookmark
+ await BareBookmark.bareFromId(c.var.ctx, assetDb.bookmarkId);
+ } catch (e) {
+ if (e instanceof TRPCError && e.code === "FORBIDDEN") {
+ return c.json({ error: "Asset not found" }, { status: 404 });
+ }
+ throw e;
+ }
+
+ return await serveAsset(c, assetId, assetDb.userId);
});
export default app;
diff --git a/packages/db/drizzle/0065_collaborative_lists.sql b/packages/db/drizzle/0065_collaborative_lists.sql
new file mode 100644
index 00000000..81564878
--- /dev/null
+++ b/packages/db/drizzle/0065_collaborative_lists.sql
@@ -0,0 +1,16 @@
+CREATE TABLE `listCollaborators` (
+ `id` text PRIMARY KEY NOT NULL,
+ `listId` text NOT NULL,
+ `userId` text NOT NULL,
+ `role` text NOT NULL,
+ `createdAt` integer NOT NULL,
+ `addedBy` text,
+ FOREIGN KEY (`listId`) REFERENCES `bookmarkLists`(`id`) ON UPDATE no action ON DELETE cascade,
+ FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
+ FOREIGN KEY (`addedBy`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null
+);
+--> statement-breakpoint
+CREATE INDEX `listCollaborators_listId_idx` ON `listCollaborators` (`listId`);--> statement-breakpoint
+CREATE INDEX `listCollaborators_userId_idx` ON `listCollaborators` (`userId`);--> statement-breakpoint
+CREATE UNIQUE INDEX `listCollaborators_listId_userId_unique` ON `listCollaborators` (`listId`,`userId`);--> statement-breakpoint
+ALTER TABLE `bookmarksInLists` ADD `listMembershipId` text REFERENCES listCollaborators(id) ON UPDATE no action ON DELETE cascade;
diff --git a/packages/db/drizzle/meta/0065_snapshot.json b/packages/db/drizzle/meta/0065_snapshot.json
new file mode 100644
index 00000000..ce30fc7e
--- /dev/null
+++ b/packages/db/drizzle/meta/0065_snapshot.json
@@ -0,0 +1,2639 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "f14d2087-e465-4cb5-81bc-accff017ee02",
+ "prevId": "faf078cf-fa55-46e3-8404-bf74628a25e0",
+ "tables": {
+ "account": {
+ "name": "account",
+ "columns": {
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "providerAccountId": {
+ "name": "providerAccountId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_type": {
+ "name": "token_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "session_state": {
+ "name": "session_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "account_userId_user_id_fk": {
+ "name": "account_userId_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "account_provider_providerAccountId_pk": {
+ "columns": [
+ "provider",
+ "providerAccountId"
+ ],
+ "name": "account_provider_providerAccountId_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "apiKey": {
+ "name": "apiKey",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keyId": {
+ "name": "keyId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keyHash": {
+ "name": "keyHash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "apiKey_keyId_unique": {
+ "name": "apiKey_keyId_unique",
+ "columns": [
+ "keyId"
+ ],
+ "isUnique": true
+ },
+ "apiKey_name_userId_unique": {
+ "name": "apiKey_name_userId_unique",
+ "columns": [
+ "name",
+ "userId"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "apiKey_userId_user_id_fk": {
+ "name": "apiKey_userId_user_id_fk",
+ "tableFrom": "apiKey",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "assets": {
+ "name": "assets",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "assetType": {
+ "name": "assetType",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "size": {
+ "name": "size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "contentType": {
+ "name": "contentType",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "fileName": {
+ "name": "fileName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "assets_bookmarkId_idx": {
+ "name": "assets_bookmarkId_idx",
+ "columns": [
+ "bookmarkId"
+ ],
+ "isUnique": false
+ },
+ "assets_assetType_idx": {
+ "name": "assets_assetType_idx",
+ "columns": [
+ "assetType"
+ ],
+ "isUnique": false
+ },
+ "assets_userId_idx": {
+ "name": "assets_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "assets_bookmarkId_bookmarks_id_fk": {
+ "name": "assets_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "assets",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "assets_userId_user_id_fk": {
+ "name": "assets_userId_user_id_fk",
+ "tableFrom": "assets",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarkAssets": {
+ "name": "bookmarkAssets",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "assetType": {
+ "name": "assetType",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "assetId": {
+ "name": "assetId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "fileName": {
+ "name": "fileName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sourceUrl": {
+ "name": "sourceUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "bookmarkAssets_id_bookmarks_id_fk": {
+ "name": "bookmarkAssets_id_bookmarks_id_fk",
+ "tableFrom": "bookmarkAssets",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarkLinks": {
+ "name": "bookmarkLinks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "author": {
+ "name": "author",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "publisher": {
+ "name": "publisher",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "datePublished": {
+ "name": "datePublished",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "dateModified": {
+ "name": "dateModified",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "imageUrl": {
+ "name": "imageUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "favicon": {
+ "name": "favicon",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "htmlContent": {
+ "name": "htmlContent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "contentAssetId": {
+ "name": "contentAssetId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "crawledAt": {
+ "name": "crawledAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "crawlStatus": {
+ "name": "crawlStatus",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "crawlStatusCode": {
+ "name": "crawlStatusCode",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": 200
+ }
+ },
+ "indexes": {
+ "bookmarkLinks_url_idx": {
+ "name": "bookmarkLinks_url_idx",
+ "columns": [
+ "url"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "bookmarkLinks_id_bookmarks_id_fk": {
+ "name": "bookmarkLinks_id_bookmarks_id_fk",
+ "tableFrom": "bookmarkLinks",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarkLists": {
+ "name": "bookmarkLists",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "icon": {
+ "name": "icon",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "query": {
+ "name": "query",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "parentId": {
+ "name": "parentId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rssToken": {
+ "name": "rssToken",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "public": {
+ "name": "public",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {
+ "bookmarkLists_userId_idx": {
+ "name": "bookmarkLists_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "bookmarkLists_userId_id_idx": {
+ "name": "bookmarkLists_userId_id_idx",
+ "columns": [
+ "userId",
+ "id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "bookmarkLists_userId_user_id_fk": {
+ "name": "bookmarkLists_userId_user_id_fk",
+ "tableFrom": "bookmarkLists",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "bookmarkLists_parentId_bookmarkLists_id_fk": {
+ "name": "bookmarkLists_parentId_bookmarkLists_id_fk",
+ "tableFrom": "bookmarkLists",
+ "tableTo": "bookmarkLists",
+ "columnsFrom": [
+ "parentId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarkTags": {
+ "name": "bookmarkTags",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "bookmarkTags_name_idx": {
+ "name": "bookmarkTags_name_idx",
+ "columns": [
+ "name"
+ ],
+ "isUnique": false
+ },
+ "bookmarkTags_userId_idx": {
+ "name": "bookmarkTags_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "bookmarkTags_userId_name_unique": {
+ "name": "bookmarkTags_userId_name_unique",
+ "columns": [
+ "userId",
+ "name"
+ ],
+ "isUnique": true
+ },
+ "bookmarkTags_userId_id_idx": {
+ "name": "bookmarkTags_userId_id_idx",
+ "columns": [
+ "userId",
+ "id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "bookmarkTags_userId_user_id_fk": {
+ "name": "bookmarkTags_userId_user_id_fk",
+ "tableFrom": "bookmarkTags",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarkTexts": {
+ "name": "bookmarkTexts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sourceUrl": {
+ "name": "sourceUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "bookmarkTexts_id_bookmarks_id_fk": {
+ "name": "bookmarkTexts_id_bookmarks_id_fk",
+ "tableFrom": "bookmarkTexts",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarks": {
+ "name": "bookmarks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "modifiedAt": {
+ "name": "modifiedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "archived": {
+ "name": "archived",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "favourited": {
+ "name": "favourited",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "taggingStatus": {
+ "name": "taggingStatus",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "summarizationStatus": {
+ "name": "summarizationStatus",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "bookmarks_userId_idx": {
+ "name": "bookmarks_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "bookmarks_archived_idx": {
+ "name": "bookmarks_archived_idx",
+ "columns": [
+ "archived"
+ ],
+ "isUnique": false
+ },
+ "bookmarks_favourited_idx": {
+ "name": "bookmarks_favourited_idx",
+ "columns": [
+ "favourited"
+ ],
+ "isUnique": false
+ },
+ "bookmarks_createdAt_idx": {
+ "name": "bookmarks_createdAt_idx",
+ "columns": [
+ "createdAt"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "bookmarks_userId_user_id_fk": {
+ "name": "bookmarks_userId_user_id_fk",
+ "tableFrom": "bookmarks",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarksInLists": {
+ "name": "bookmarksInLists",
+ "columns": {
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "listId": {
+ "name": "listId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "addedAt": {
+ "name": "addedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "listMembershipId": {
+ "name": "listMembershipId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "bookmarksInLists_bookmarkId_idx": {
+ "name": "bookmarksInLists_bookmarkId_idx",
+ "columns": [
+ "bookmarkId"
+ ],
+ "isUnique": false
+ },
+ "bookmarksInLists_listId_idx": {
+ "name": "bookmarksInLists_listId_idx",
+ "columns": [
+ "listId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "bookmarksInLists_bookmarkId_bookmarks_id_fk": {
+ "name": "bookmarksInLists_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "bookmarksInLists",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "bookmarksInLists_listId_bookmarkLists_id_fk": {
+ "name": "bookmarksInLists_listId_bookmarkLists_id_fk",
+ "tableFrom": "bookmarksInLists",
+ "tableTo": "bookmarkLists",
+ "columnsFrom": [
+ "listId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "bookmarksInLists_listMembershipId_listCollaborators_id_fk": {
+ "name": "bookmarksInLists_listMembershipId_listCollaborators_id_fk",
+ "tableFrom": "bookmarksInLists",
+ "tableTo": "listCollaborators",
+ "columnsFrom": [
+ "listMembershipId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "bookmarksInLists_bookmarkId_listId_pk": {
+ "columns": [
+ "bookmarkId",
+ "listId"
+ ],
+ "name": "bookmarksInLists_bookmarkId_listId_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "config": {
+ "name": "config",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "customPrompts": {
+ "name": "customPrompts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "appliesTo": {
+ "name": "appliesTo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "customPrompts_userId_idx": {
+ "name": "customPrompts_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "customPrompts_userId_user_id_fk": {
+ "name": "customPrompts_userId_user_id_fk",
+ "tableFrom": "customPrompts",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "highlights": {
+ "name": "highlights",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "startOffset": {
+ "name": "startOffset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "endOffset": {
+ "name": "endOffset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'yellow'"
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "highlights_bookmarkId_idx": {
+ "name": "highlights_bookmarkId_idx",
+ "columns": [
+ "bookmarkId"
+ ],
+ "isUnique": false
+ },
+ "highlights_userId_idx": {
+ "name": "highlights_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "highlights_bookmarkId_bookmarks_id_fk": {
+ "name": "highlights_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "highlights",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "highlights_userId_user_id_fk": {
+ "name": "highlights_userId_user_id_fk",
+ "tableFrom": "highlights",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "importSessionBookmarks": {
+ "name": "importSessionBookmarks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "importSessionId": {
+ "name": "importSessionId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "importSessionBookmarks_sessionId_idx": {
+ "name": "importSessionBookmarks_sessionId_idx",
+ "columns": [
+ "importSessionId"
+ ],
+ "isUnique": false
+ },
+ "importSessionBookmarks_bookmarkId_idx": {
+ "name": "importSessionBookmarks_bookmarkId_idx",
+ "columns": [
+ "bookmarkId"
+ ],
+ "isUnique": false
+ },
+ "importSessionBookmarks_importSessionId_bookmarkId_unique": {
+ "name": "importSessionBookmarks_importSessionId_bookmarkId_unique",
+ "columns": [
+ "importSessionId",
+ "bookmarkId"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "importSessionBookmarks_importSessionId_importSessions_id_fk": {
+ "name": "importSessionBookmarks_importSessionId_importSessions_id_fk",
+ "tableFrom": "importSessionBookmarks",
+ "tableTo": "importSessions",
+ "columnsFrom": [
+ "importSessionId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "importSessionBookmarks_bookmarkId_bookmarks_id_fk": {
+ "name": "importSessionBookmarks_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "importSessionBookmarks",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "importSessions": {
+ "name": "importSessions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rootListId": {
+ "name": "rootListId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "modifiedAt": {
+ "name": "modifiedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "importSessions_userId_idx": {
+ "name": "importSessions_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "importSessions_userId_user_id_fk": {
+ "name": "importSessions_userId_user_id_fk",
+ "tableFrom": "importSessions",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "importSessions_rootListId_bookmarkLists_id_fk": {
+ "name": "importSessions_rootListId_bookmarkLists_id_fk",
+ "tableFrom": "importSessions",
+ "tableTo": "bookmarkLists",
+ "columnsFrom": [
+ "rootListId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "invites": {
+ "name": "invites",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "usedAt": {
+ "name": "usedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "invitedBy": {
+ "name": "invitedBy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "invites_token_unique": {
+ "name": "invites_token_unique",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "invites_invitedBy_user_id_fk": {
+ "name": "invites_invitedBy_user_id_fk",
+ "tableFrom": "invites",
+ "tableTo": "user",
+ "columnsFrom": [
+ "invitedBy"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "listCollaborators": {
+ "name": "listCollaborators",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "listId": {
+ "name": "listId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "addedBy": {
+ "name": "addedBy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "listCollaborators_listId_idx": {
+ "name": "listCollaborators_listId_idx",
+ "columns": [
+ "listId"
+ ],
+ "isUnique": false
+ },
+ "listCollaborators_userId_idx": {
+ "name": "listCollaborators_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "listCollaborators_listId_userId_unique": {
+ "name": "listCollaborators_listId_userId_unique",
+ "columns": [
+ "listId",
+ "userId"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "listCollaborators_listId_bookmarkLists_id_fk": {
+ "name": "listCollaborators_listId_bookmarkLists_id_fk",
+ "tableFrom": "listCollaborators",
+ "tableTo": "bookmarkLists",
+ "columnsFrom": [
+ "listId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "listCollaborators_userId_user_id_fk": {
+ "name": "listCollaborators_userId_user_id_fk",
+ "tableFrom": "listCollaborators",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "listCollaborators_addedBy_user_id_fk": {
+ "name": "listCollaborators_addedBy_user_id_fk",
+ "tableFrom": "listCollaborators",
+ "tableTo": "user",
+ "columnsFrom": [
+ "addedBy"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "passwordResetToken": {
+ "name": "passwordResetToken",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "passwordResetToken_token_unique": {
+ "name": "passwordResetToken_token_unique",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ },
+ "passwordResetTokens_userId_idx": {
+ "name": "passwordResetTokens_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "passwordResetToken_userId_user_id_fk": {
+ "name": "passwordResetToken_userId_user_id_fk",
+ "tableFrom": "passwordResetToken",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "rssFeedImports": {
+ "name": "rssFeedImports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "entryId": {
+ "name": "entryId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rssFeedId": {
+ "name": "rssFeedId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "rssFeedImports_feedIdIdx_idx": {
+ "name": "rssFeedImports_feedIdIdx_idx",
+ "columns": [
+ "rssFeedId"
+ ],
+ "isUnique": false
+ },
+ "rssFeedImports_entryIdIdx_idx": {
+ "name": "rssFeedImports_entryIdIdx_idx",
+ "columns": [
+ "entryId"
+ ],
+ "isUnique": false
+ },
+ "rssFeedImports_rssFeedId_entryId_unique": {
+ "name": "rssFeedImports_rssFeedId_entryId_unique",
+ "columns": [
+ "rssFeedId",
+ "entryId"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "rssFeedImports_rssFeedId_rssFeeds_id_fk": {
+ "name": "rssFeedImports_rssFeedId_rssFeeds_id_fk",
+ "tableFrom": "rssFeedImports",
+ "tableTo": "rssFeeds",
+ "columnsFrom": [
+ "rssFeedId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "rssFeedImports_bookmarkId_bookmarks_id_fk": {
+ "name": "rssFeedImports_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "rssFeedImports",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "rssFeeds": {
+ "name": "rssFeeds",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "importTags": {
+ "name": "importTags",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "lastFetchedAt": {
+ "name": "lastFetchedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lastFetchedStatus": {
+ "name": "lastFetchedStatus",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "rssFeeds_userId_idx": {
+ "name": "rssFeeds_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "rssFeeds_userId_user_id_fk": {
+ "name": "rssFeeds_userId_user_id_fk",
+ "tableFrom": "rssFeeds",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "ruleEngineActions": {
+ "name": "ruleEngineActions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ruleId": {
+ "name": "ruleId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "listId": {
+ "name": "listId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tagId": {
+ "name": "tagId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "ruleEngineActions_userId_idx": {
+ "name": "ruleEngineActions_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "ruleEngineActions_ruleId_idx": {
+ "name": "ruleEngineActions_ruleId_idx",
+ "columns": [
+ "ruleId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "ruleEngineActions_userId_user_id_fk": {
+ "name": "ruleEngineActions_userId_user_id_fk",
+ "tableFrom": "ruleEngineActions",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ruleEngineActions_ruleId_ruleEngineRules_id_fk": {
+ "name": "ruleEngineActions_ruleId_ruleEngineRules_id_fk",
+ "tableFrom": "ruleEngineActions",
+ "tableTo": "ruleEngineRules",
+ "columnsFrom": [
+ "ruleId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ruleEngineActions_userId_tagId_fk": {
+ "name": "ruleEngineActions_userId_tagId_fk",
+ "tableFrom": "ruleEngineActions",
+ "tableTo": "bookmarkTags",
+ "columnsFrom": [
+ "userId",
+ "tagId"
+ ],
+ "columnsTo": [
+ "userId",
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ruleEngineActions_userId_listId_fk": {
+ "name": "ruleEngineActions_userId_listId_fk",
+ "tableFrom": "ruleEngineActions",
+ "tableTo": "bookmarkLists",
+ "columnsFrom": [
+ "userId",
+ "listId"
+ ],
+ "columnsTo": [
+ "userId",
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "ruleEngineRules": {
+ "name": "ruleEngineRules",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "event": {
+ "name": "event",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "condition": {
+ "name": "condition",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "listId": {
+ "name": "listId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tagId": {
+ "name": "tagId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "ruleEngine_userId_idx": {
+ "name": "ruleEngine_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "ruleEngineRules_userId_user_id_fk": {
+ "name": "ruleEngineRules_userId_user_id_fk",
+ "tableFrom": "ruleEngineRules",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ruleEngineRules_userId_tagId_fk": {
+ "name": "ruleEngineRules_userId_tagId_fk",
+ "tableFrom": "ruleEngineRules",
+ "tableTo": "bookmarkTags",
+ "columnsFrom": [
+ "userId",
+ "tagId"
+ ],
+ "columnsTo": [
+ "userId",
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ruleEngineRules_userId_listId_fk": {
+ "name": "ruleEngineRules_userId_listId_fk",
+ "tableFrom": "ruleEngineRules",
+ "tableTo": "bookmarkLists",
+ "columnsFrom": [
+ "userId",
+ "listId"
+ ],
+ "columnsTo": [
+ "userId",
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "session": {
+ "name": "session",
+ "columns": {
+ "sessionToken": {
+ "name": "sessionToken",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "session_userId_user_id_fk": {
+ "name": "session_userId_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "subscriptions": {
+ "name": "subscriptions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "stripeCustomerId": {
+ "name": "stripeCustomerId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "stripeSubscriptionId": {
+ "name": "stripeSubscriptionId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tier": {
+ "name": "tier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'free'"
+ },
+ "priceId": {
+ "name": "priceId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cancelAtPeriodEnd": {
+ "name": "cancelAtPeriodEnd",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": false
+ },
+ "startDate": {
+ "name": "startDate",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "endDate": {
+ "name": "endDate",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "modifiedAt": {
+ "name": "modifiedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "subscriptions_userId_unique": {
+ "name": "subscriptions_userId_unique",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": true
+ },
+ "subscriptions_userId_idx": {
+ "name": "subscriptions_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "subscriptions_stripeCustomerId_idx": {
+ "name": "subscriptions_stripeCustomerId_idx",
+ "columns": [
+ "stripeCustomerId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "subscriptions_userId_user_id_fk": {
+ "name": "subscriptions_userId_user_id_fk",
+ "tableFrom": "subscriptions",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tagsOnBookmarks": {
+ "name": "tagsOnBookmarks",
+ "columns": {
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tagId": {
+ "name": "tagId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "attachedAt": {
+ "name": "attachedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "attachedBy": {
+ "name": "attachedBy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "tagsOnBookmarks_tagId_idx": {
+ "name": "tagsOnBookmarks_tagId_idx",
+ "columns": [
+ "tagId"
+ ],
+ "isUnique": false
+ },
+ "tagsOnBookmarks_bookmarkId_idx": {
+ "name": "tagsOnBookmarks_bookmarkId_idx",
+ "columns": [
+ "bookmarkId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "tagsOnBookmarks_bookmarkId_bookmarks_id_fk": {
+ "name": "tagsOnBookmarks_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "tagsOnBookmarks",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tagsOnBookmarks_tagId_bookmarkTags_id_fk": {
+ "name": "tagsOnBookmarks_tagId_bookmarkTags_id_fk",
+ "tableFrom": "tagsOnBookmarks",
+ "tableTo": "bookmarkTags",
+ "columnsFrom": [
+ "tagId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "tagsOnBookmarks_bookmarkId_tagId_pk": {
+ "columns": [
+ "bookmarkId",
+ "tagId"
+ ],
+ "name": "tagsOnBookmarks_bookmarkId_tagId_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "user": {
+ "name": "user",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "emailVerified": {
+ "name": "emailVerified",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "salt": {
+ "name": "salt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'user'"
+ },
+ "bookmarkQuota": {
+ "name": "bookmarkQuota",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "storageQuota": {
+ "name": "storageQuota",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "browserCrawlingEnabled": {
+ "name": "browserCrawlingEnabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "bookmarkClickAction": {
+ "name": "bookmarkClickAction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'open_original_link'"
+ },
+ "archiveDisplayBehaviour": {
+ "name": "archiveDisplayBehaviour",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'show'"
+ },
+ "timezone": {
+ "name": "timezone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'UTC'"
+ }
+ },
+ "indexes": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "columns": [
+ "email"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "verificationToken": {
+ "name": "verificationToken",
+ "columns": {
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "verificationToken_identifier_token_pk": {
+ "columns": [
+ "identifier",
+ "token"
+ ],
+ "name": "verificationToken_identifier_token_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "webhooks": {
+ "name": "webhooks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "events": {
+ "name": "events",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "webhooks_userId_idx": {
+ "name": "webhooks_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "webhooks_userId_user_id_fk": {
+ "name": "webhooks_userId_user_id_fk",
+ "tableFrom": "webhooks",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+} \ No newline at end of file
diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json
index 019ba456..29a877d6 100644
--- a/packages/db/drizzle/meta/_journal.json
+++ b/packages/db/drizzle/meta/_journal.json
@@ -456,6 +456,13 @@
"when": 1762115406895,
"tag": "0064_add_import_tags_to_feeds",
"breakpoints": true
+ },
+ {
+ "idx": 65,
+ "version": "6",
+ "when": 1763335572156,
+ "tag": "0065_collaborative_lists",
+ "breakpoints": true
}
]
} \ No newline at end of file
diff --git a/packages/db/schema.ts b/packages/db/schema.ts
index 5f523d21..0479bb52 100644
--- a/packages/db/schema.ts
+++ b/packages/db/schema.ts
@@ -411,6 +411,14 @@ export const bookmarksInLists = sqliteTable(
addedAt: integer("addedAt", { mode: "timestamp" }).$defaultFn(
() => new Date(),
),
+ // Tie the list's existence to the user's membership
+ // of this list.
+ listMembershipId: text("listMembershipId").references(
+ () => listCollaborators.id,
+ {
+ onDelete: "cascade",
+ },
+ ),
},
(tb) => [
primaryKey({ columns: [tb.bookmarkId, tb.listId] }),
@@ -419,6 +427,32 @@ export const bookmarksInLists = sqliteTable(
],
);
+export const listCollaborators = sqliteTable(
+ "listCollaborators",
+ {
+ id: text("id")
+ .notNull()
+ .primaryKey()
+ .$defaultFn(() => createId()),
+ listId: text("listId")
+ .notNull()
+ .references(() => bookmarkLists.id, { onDelete: "cascade" }),
+ userId: text("userId")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ role: text("role", { enum: ["viewer", "editor"] }).notNull(),
+ addedAt: createdAtField(),
+ addedBy: text("addedBy").references(() => users.id, {
+ onDelete: "set null",
+ }),
+ },
+ (lc) => [
+ unique().on(lc.listId, lc.userId),
+ index("listCollaborators_listId_idx").on(lc.listId),
+ index("listCollaborators_userId_idx").on(lc.userId),
+ ],
+);
+
export const customPrompts = sqliteTable(
"customPrompts",
{
@@ -698,6 +732,7 @@ export const userRelations = relations(users, ({ many, one }) => ({
invites: many(invites),
subscription: one(subscriptions),
importSessions: many(importSessions),
+ listCollaborations: many(listCollaborators),
}));
export const bookmarkRelations = relations(bookmarks, ({ many, one }) => ({
@@ -767,6 +802,7 @@ export const bookmarkListsRelations = relations(
bookmarkLists,
({ one, many }) => ({
bookmarksInLists: many(bookmarksInLists),
+ collaborators: many(listCollaborators),
user: one(users, {
fields: [bookmarkLists.userId],
references: [users.id],
@@ -792,6 +828,24 @@ export const bookmarksInListsRelations = relations(
}),
);
+export const listCollaboratorsRelations = relations(
+ listCollaborators,
+ ({ one }) => ({
+ list: one(bookmarkLists, {
+ fields: [listCollaborators.listId],
+ references: [bookmarkLists.id],
+ }),
+ user: one(users, {
+ fields: [listCollaborators.userId],
+ references: [users.id],
+ }),
+ addedByUser: one(users, {
+ fields: [listCollaborators.addedBy],
+ references: [users.id],
+ }),
+ }),
+);
+
export const webhooksRelations = relations(webhooksTable, ({ one }) => ({
user: one(users, {
fields: [webhooksTable.userId],
diff --git a/packages/open-api/karakeep-openapi-spec.json b/packages/open-api/karakeep-openapi-spec.json
index 4f846cef..04596bf0 100644
--- a/packages/open-api/karakeep-openapi-spec.json
+++ b/packages/open-api/karakeep-openapi-spec.json
@@ -108,6 +108,9 @@
"import"
]
},
+ "userId": {
+ "type": "string"
+ },
"tags": {
"type": "array",
"items": {
@@ -341,6 +344,7 @@
"favourited",
"taggingStatus",
"summarizationStatus",
+ "userId",
"tags",
"content",
"assets"
@@ -402,6 +406,18 @@
},
"public": {
"type": "boolean"
+ },
+ "hasCollaborators": {
+ "type": "boolean"
+ },
+ "userRole": {
+ "type": "string",
+ "enum": [
+ "owner",
+ "editor",
+ "viewer",
+ "public"
+ ]
}
},
"required": [
@@ -409,7 +425,9 @@
"name",
"icon",
"parentId",
- "public"
+ "public",
+ "hasCollaborators",
+ "userRole"
]
},
"Highlight": {
@@ -1194,6 +1212,9 @@
"rss",
"import"
]
+ },
+ "userId": {
+ "type": "string"
}
},
"required": [
@@ -1203,7 +1224,8 @@
"archived",
"favourited",
"taggingStatus",
- "summarizationStatus"
+ "summarizationStatus",
+ "userId"
]
}
}
@@ -1318,6 +1340,9 @@
"rss",
"import"
]
+ },
+ "userId": {
+ "type": "string"
}
},
"required": [
@@ -1327,7 +1352,8 @@
"archived",
"favourited",
"taggingStatus",
- "summarizationStatus"
+ "summarizationStatus",
+ "userId"
]
}
}
diff --git a/packages/shared/types/bookmarks.ts b/packages/shared/types/bookmarks.ts
index d72525d1..cbaa4574 100644
--- a/packages/shared/types/bookmarks.ts
+++ b/packages/shared/types/bookmarks.ts
@@ -107,8 +107,11 @@ export const zBareBookmarkSchema = z.object({
note: z.string().nullish(),
summary: z.string().nullish(),
source: zBookmarkSourceSchema.nullish(),
+ userId: z.string(),
});
+export type ZBareBookmark = z.infer<typeof zBareBookmarkSchema>;
+
export const zBookmarkSchema = zBareBookmarkSchema.merge(
z.object({
tags: z.array(zBookmarkTagSchema),
diff --git a/packages/shared/types/lists.ts b/packages/shared/types/lists.ts
index 59abb007..823b6b8f 100644
--- a/packages/shared/types/lists.ts
+++ b/packages/shared/types/lists.ts
@@ -57,6 +57,8 @@ export const zBookmarkListSchema = z.object({
type: z.enum(["manual", "smart"]).default("manual"),
query: z.string().nullish(),
public: z.boolean(),
+ hasCollaborators: z.boolean(),
+ userRole: z.enum(["owner", "editor", "viewer", "public"]),
});
export type ZBookmarkList = z.infer<typeof zBookmarkListSchema>;
diff --git a/packages/trpc/models/bookmarks.ts b/packages/trpc/models/bookmarks.ts
index c689f64d..e4bfdab2 100644
--- a/packages/trpc/models/bookmarks.ts
+++ b/packages/trpc/models/bookmarks.ts
@@ -15,19 +15,23 @@ import {
import invariant from "tiny-invariant";
import { z } from "zod";
+import { db as DONT_USE_db } from "@karakeep/db";
import {
assets,
AssetTypes,
bookmarkAssets,
bookmarkLinks,
+ bookmarkLists,
bookmarks,
bookmarksInLists,
bookmarkTags,
bookmarkTexts,
+ listCollaborators,
rssFeedImportsTable,
tagsOnBookmarks,
} from "@karakeep/db/schema";
-import { readAsset } from "@karakeep/shared/assetdb";
+import { SearchIndexingQueue, triggerWebhook } from "@karakeep/shared-server";
+import { deleteAsset, readAsset } from "@karakeep/shared/assetdb";
import serverConfig from "@karakeep/shared/config";
import {
createSignedToken,
@@ -37,6 +41,7 @@ import { zAssetSignedTokenSchema } from "@karakeep/shared/types/assets";
import {
BookmarkTypes,
DEFAULT_NUM_BOOKMARKS_PER_PAGE,
+ ZBareBookmark,
ZBookmark,
ZBookmarkContent,
zGetBookmarksRequestSchema,
@@ -54,26 +59,225 @@ import { mapDBAssetTypeToUserType } from "../lib/attachments";
import { List } from "./lists";
import { PrivacyAware } from "./privacy";
-export class Bookmark implements PrivacyAware {
+async function dummyDrizzleReturnType() {
+ const x = await DONT_USE_db.query.bookmarks.findFirst({
+ with: {
+ tagsOnBookmarks: {
+ with: {
+ tag: true,
+ },
+ },
+ link: true,
+ text: true,
+ asset: true,
+ assets: true,
+ },
+ });
+ if (!x) {
+ throw new Error();
+ }
+ return x;
+}
+
+type BookmarkQueryReturnType = Awaited<
+ ReturnType<typeof dummyDrizzleReturnType>
+>;
+
+export class BareBookmark implements PrivacyAware {
protected constructor(
protected ctx: AuthedContext,
- public bookmark: ZBookmark & { userId: string },
+ private bareBookmark: ZBareBookmark,
) {}
+ get id() {
+ return this.bareBookmark.id;
+ }
+
+ get createdAt() {
+ return this.bareBookmark.createdAt;
+ }
+
+ static async bareFromId(ctx: AuthedContext, bookmarkId: string) {
+ const bookmark = await ctx.db.query.bookmarks.findFirst({
+ where: eq(bookmarks.id, bookmarkId),
+ });
+
+ if (!bookmark) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Bookmark not found",
+ });
+ }
+
+ if (!(await BareBookmark.isAllowedToAccessBookmark(ctx, bookmark))) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Bookmark not found",
+ });
+ }
+
+ return new BareBookmark(ctx, bookmark);
+ }
+
+ protected static async isAllowedToAccessBookmark(
+ ctx: AuthedContext,
+ { id: bookmarkId, userId: bookmarkOwnerId }: { id: string; userId: string },
+ ): Promise<boolean> {
+ if (bookmarkOwnerId == ctx.user.id) {
+ return true;
+ }
+ const bookmarkLists = await List.forBookmark(ctx, bookmarkId);
+ return bookmarkLists.some((l) => l.canUserView());
+ }
+
+ ensureOwnership() {
+ if (this.bareBookmark.userId != this.ctx.user.id) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "User is not allowed to access resource",
+ });
+ }
+ }
+
ensureCanAccess(ctx: AuthedContext): void {
- if (this.bookmark.userId != ctx.user.id) {
+ if (this.bareBookmark.userId != ctx.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "User is not allowed to access resource",
});
}
}
+}
- static fromData(ctx: AuthedContext, data: ZBookmark) {
- return new Bookmark(ctx, {
- ...data,
- userId: ctx.user.id,
+export class Bookmark extends BareBookmark {
+ protected constructor(
+ ctx: AuthedContext,
+ private bookmark: ZBookmark,
+ ) {
+ super(ctx, bookmark);
+ }
+
+ private static async toZodSchema(
+ bookmark: BookmarkQueryReturnType,
+ includeContent: boolean,
+ ): Promise<ZBookmark> {
+ const { tagsOnBookmarks, link, text, asset, assets, ...rest } = bookmark;
+
+ let content: ZBookmarkContent = {
+ type: BookmarkTypes.UNKNOWN,
+ };
+ if (bookmark.link) {
+ content = {
+ type: BookmarkTypes.LINK,
+ screenshotAssetId: assets.find(
+ (a) => a.assetType == AssetTypes.LINK_SCREENSHOT,
+ )?.id,
+ fullPageArchiveAssetId: assets.find(
+ (a) => a.assetType == AssetTypes.LINK_FULL_PAGE_ARCHIVE,
+ )?.id,
+ precrawledArchiveAssetId: assets.find(
+ (a) => a.assetType == AssetTypes.LINK_PRECRAWLED_ARCHIVE,
+ )?.id,
+ imageAssetId: assets.find(
+ (a) => a.assetType == AssetTypes.LINK_BANNER_IMAGE,
+ )?.id,
+ videoAssetId: assets.find((a) => a.assetType == AssetTypes.LINK_VIDEO)
+ ?.id,
+ url: link.url,
+ title: link.title,
+ description: link.description,
+ imageUrl: link.imageUrl,
+ favicon: link.favicon,
+ htmlContent: includeContent
+ ? await Bookmark.getBookmarkHtmlContent(link, bookmark.userId)
+ : null,
+ crawledAt: link.crawledAt,
+ author: link.author,
+ publisher: link.publisher,
+ datePublished: link.datePublished,
+ dateModified: link.dateModified,
+ };
+ }
+ if (bookmark.text) {
+ content = {
+ type: BookmarkTypes.TEXT,
+ // It's ok to include the text content as it's usually not big and is used to render the text bookmark card.
+ text: text.text ?? "",
+ sourceUrl: text.sourceUrl,
+ };
+ }
+ if (bookmark.asset) {
+ content = {
+ type: BookmarkTypes.ASSET,
+ assetType: asset.assetType,
+ assetId: asset.assetId,
+ fileName: asset.fileName,
+ sourceUrl: asset.sourceUrl,
+ size: assets.find((a) => a.id == asset.assetId)?.size,
+ content: includeContent ? asset.content : null,
+ };
+ }
+
+ return {
+ tags: tagsOnBookmarks
+ .map((t) => ({
+ attachedBy: t.attachedBy,
+ ...t.tag,
+ }))
+ .sort((a, b) =>
+ a.attachedBy === "ai" ? 1 : b.attachedBy === "ai" ? -1 : 0,
+ ),
+ content,
+ assets: assets.map((a) => ({
+ id: a.id,
+ assetType: mapDBAssetTypeToUserType(a.assetType),
+ fileName: a.fileName,
+ })),
+ ...rest,
+ };
+ }
+
+ static async fromId(
+ ctx: AuthedContext,
+ bookmarkId: string,
+ includeContent: boolean,
+ ) {
+ const bookmark = await ctx.db.query.bookmarks.findFirst({
+ where: eq(bookmarks.id, bookmarkId),
+ with: {
+ tagsOnBookmarks: {
+ with: {
+ tag: true,
+ },
+ },
+ link: true,
+ text: true,
+ asset: true,
+ assets: true,
+ },
});
+
+ if (!bookmark) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Bookmark not found",
+ });
+ }
+
+ if (!(await BareBookmark.isAllowedToAccessBookmark(ctx, bookmark))) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Bookmark not found",
+ });
+ }
+ return Bookmark.fromData(
+ ctx,
+ await Bookmark.toZodSchema(bookmark, includeContent),
+ );
+ }
+
+ static fromData(ctx: AuthedContext, data: ZBookmark) {
+ return new Bookmark(ctx, data);
}
static async loadMulti(
@@ -103,7 +307,42 @@ export class Bookmark implements PrivacyAware {
.from(bookmarks)
.where(
and(
- eq(bookmarks.userId, ctx.user.id),
+ // Access control: User can access bookmarks if they either:
+ // 1. Own the bookmark (always)
+ // 2. The bookmark is in a specific shared list being viewed
+ // When listId is specified, we need special handling to show all bookmarks in that list
+ input.listId !== undefined
+ ? // If querying a specific list, check if user has access to that list
+ or(
+ eq(bookmarks.userId, ctx.user.id),
+ // User is the owner of the list being queried
+ exists(
+ ctx.db
+ .select()
+ .from(bookmarkLists)
+ .where(
+ and(
+ eq(bookmarkLists.id, input.listId),
+ eq(bookmarkLists.userId, ctx.user.id),
+ ),
+ ),
+ ),
+ // User is a collaborator on the list being queried
+ exists(
+ ctx.db
+ .select()
+ .from(listCollaborators)
+ .where(
+ and(
+ eq(listCollaborators.listId, input.listId),
+ eq(listCollaborators.userId, ctx.user.id),
+ ),
+ ),
+ ),
+ )
+ : // If not querying a specific list, only show bookmarks the user owns
+ // Shared bookmarks should only appear when viewing the specific shared list
+ eq(bookmarks.userId, ctx.user.id),
input.archived !== undefined
? eq(bookmarks.archived, input.archived)
: undefined,
@@ -317,7 +556,7 @@ export class Bookmark implements PrivacyAware {
) {
try {
const asset = await readAsset({
- userId: ctx.user.id,
+ userId: bookmark.userId,
assetId: bookmark.content.contentAssetId,
});
bookmark.content.htmlContent = asset.asset.toString("utf8");
@@ -365,7 +604,18 @@ export class Bookmark implements PrivacyAware {
}
asZBookmark(): ZBookmark {
- return this.bookmark;
+ if (this.bookmark.userId === this.ctx.user.id) {
+ return this.bookmark;
+ }
+
+ // Collaborators shouldn't see owner-specific state such as favourites,
+ // archived flag, or personal notes.
+ return {
+ ...this.bookmark,
+ archived: false,
+ favourited: false,
+ note: null,
+ };
}
asPublicBookmark(): ZPublicBookmark {
@@ -512,4 +762,41 @@ export class Bookmark implements PrivacyAware {
}
return htmlToPlainText(content);
}
+
+ private async cleanupAssets() {
+ const assetIds: Set<string> = new Set<string>(
+ this.bookmark.assets.map((a) => a.id),
+ );
+ // Todo: Remove when the bookmark asset is also in the assets table
+ if (this.bookmark.content.type == BookmarkTypes.ASSET) {
+ assetIds.add(this.bookmark.content.assetId);
+ }
+ await Promise.all(
+ Array.from(assetIds).map((assetId) =>
+ deleteAsset({ userId: this.bookmark.userId, assetId }),
+ ),
+ );
+ }
+
+ async delete() {
+ this.ensureOwnership();
+ const deleted = await this.ctx.db
+ .delete(bookmarks)
+ .where(
+ and(
+ eq(bookmarks.userId, this.ctx.user.id),
+ eq(bookmarks.id, this.bookmark.id),
+ ),
+ );
+
+ await SearchIndexingQueue.enqueue({
+ bookmarkId: this.bookmark.id,
+ type: "delete",
+ });
+
+ await triggerWebhook(this.bookmark.id, "deleted", this.ctx.user.id);
+ if (deleted.changes > 0) {
+ await this.cleanupAssets();
+ }
+ }
}
diff --git a/packages/trpc/models/highlights.ts b/packages/trpc/models/highlights.ts
index 326f97f3..260c4b8a 100644
--- a/packages/trpc/models/highlights.ts
+++ b/packages/trpc/models/highlights.ts
@@ -11,6 +11,7 @@ import {
import { zCursorV2 } from "@karakeep/shared/types/pagination";
import { AuthedContext } from "..";
+import { BareBookmark } from "./bookmarks";
import { PrivacyAware } from "./privacy";
export class Highlight implements PrivacyAware {
@@ -64,13 +65,10 @@ export class Highlight implements PrivacyAware {
static async getForBookmark(
ctx: AuthedContext,
- bookmarkId: string,
+ bookmark: BareBookmark,
): Promise<Highlight[]> {
const results = await ctx.db.query.highlights.findMany({
- where: and(
- eq(highlights.bookmarkId, bookmarkId),
- eq(highlights.userId, ctx.user.id),
- ),
+ where: eq(highlights.bookmarkId, bookmark.id),
orderBy: [desc(highlights.createdAt), desc(highlights.id)],
});
diff --git a/packages/trpc/models/lists.ts b/packages/trpc/models/lists.ts
index 48da5ed1..28473c12 100644
--- a/packages/trpc/models/lists.ts
+++ b/packages/trpc/models/lists.ts
@@ -1,11 +1,16 @@
import crypto from "node:crypto";
import { TRPCError } from "@trpc/server";
-import { and, count, eq, or } from "drizzle-orm";
+import { and, count, eq, inArray, or } from "drizzle-orm";
import invariant from "tiny-invariant";
import { z } from "zod";
import { SqliteError } from "@karakeep/db";
-import { bookmarkLists, bookmarksInLists } from "@karakeep/db/schema";
+import {
+ bookmarkLists,
+ bookmarksInLists,
+ listCollaborators,
+ users,
+} from "@karakeep/db/schema";
import { triggerRuleEngineOnEvent } from "@karakeep/shared-server";
import { parseSearchQuery } from "@karakeep/shared/searchQueryParser";
import { ZSortOrder } from "@karakeep/shared/types/bookmarks";
@@ -15,6 +20,7 @@ import {
zNewBookmarkListSchema,
} from "@karakeep/shared/types/lists";
import { ZCursor } from "@karakeep/shared/types/pagination";
+import { switchCase } from "@karakeep/shared/utils/switch";
import { AuthedContext, Context } from "..";
import { buildImpersonatingAuthedContext } from "../lib/impersonate";
@@ -22,20 +28,54 @@ import { getBookmarkIdsFromMatcher } from "../lib/search";
import { Bookmark } from "./bookmarks";
import { PrivacyAware } from "./privacy";
+interface ListCollaboratorEntry {
+ membershipId: string;
+}
+
export abstract class List implements PrivacyAware {
protected constructor(
protected ctx: AuthedContext,
- public list: ZBookmarkList & { userId: string },
+ protected list: ZBookmarkList & { userId: string },
) {}
+ get id() {
+ return this.list.id;
+ }
+
+ asZBookmarkList() {
+ if (this.list.userId === this.ctx.user.id) {
+ return this.list;
+ }
+
+ // There's some privacy implications here, so we need to think twice
+ // about the values that we return.
+ return {
+ id: this.list.id,
+ name: this.list.name,
+ description: this.list.description,
+ userId: this.list.userId,
+ icon: this.list.icon,
+ type: this.list.type,
+ query: this.list.query,
+ userRole: this.list.userRole,
+ hasCollaborators: this.list.hasCollaborators,
+
+ // Hide parentId as it is not relevant to the user
+ parentId: null,
+ // Hide whether the list is public or not.
+ public: false,
+ };
+ }
+
private static fromData(
ctx: AuthedContext,
data: ZBookmarkList & { userId: string },
+ collaboratorEntry: ListCollaboratorEntry | null,
) {
if (data.type === "smart") {
return new SmartList(ctx, data);
} else {
- return new ManualList(ctx, data);
+ return new ManualList(ctx, data, collaboratorEntry);
}
}
@@ -43,12 +83,64 @@ export abstract class List implements PrivacyAware {
ctx: AuthedContext,
id: string,
): Promise<ManualList | SmartList> {
- const list = await ctx.db.query.bookmarkLists.findFirst({
- where: and(
- eq(bookmarkLists.id, id),
- eq(bookmarkLists.userId, ctx.user.id),
- ),
- });
+ // First try to find the list owned by the user
+ let list = await (async (): Promise<
+ (ZBookmarkList & { userId: string }) | undefined
+ > => {
+ const l = await ctx.db.query.bookmarkLists.findFirst({
+ columns: {
+ rssToken: false,
+ },
+ where: and(
+ eq(bookmarkLists.id, id),
+ eq(bookmarkLists.userId, ctx.user.id),
+ ),
+ with: {
+ collaborators: {
+ columns: {
+ id: true,
+ },
+ limit: 1,
+ },
+ },
+ });
+ return l
+ ? {
+ ...l,
+ userRole: "owner",
+ hasCollaborators: l.collaborators.length > 0,
+ }
+ : l;
+ })();
+
+ // If not found, check if the user is a collaborator
+ let collaboratorEntry: ListCollaboratorEntry | null = null;
+ if (!list) {
+ const collaborator = await ctx.db.query.listCollaborators.findFirst({
+ where: and(
+ eq(listCollaborators.listId, id),
+ eq(listCollaborators.userId, ctx.user.id),
+ ),
+ with: {
+ list: {
+ columns: {
+ rssToken: false,
+ },
+ },
+ },
+ });
+
+ if (collaborator) {
+ list = {
+ ...collaborator.list,
+ userRole: collaborator.role,
+ hasCollaborators: true, // If you're a collaborator, the list has collaborators
+ };
+ collaboratorEntry = {
+ membershipId: collaborator.id,
+ };
+ }
+ }
if (!list) {
throw new TRPCError({
@@ -59,7 +151,7 @@ export abstract class List implements PrivacyAware {
if (list.type === "smart") {
return new SmartList(ctx, list);
} else {
- return new ManualList(ctx, list);
+ return new ManualList(ctx, list, collaboratorEntry);
}
}
@@ -124,8 +216,17 @@ export abstract class List implements PrivacyAware {
// an impersonating context for the list owner as long as
// we don't leak the context.
const authedCtx = await buildImpersonatingAuthedContext(listdb.userId);
- const list = List.fromData(authedCtx, listdb);
- const bookmarkIds = await list.getBookmarkIds();
+ const listObj = List.fromData(
+ authedCtx,
+ {
+ ...listdb,
+ userRole: "public",
+ hasCollaborators: false, // Public lists don't expose collaborators
+ },
+ null,
+ );
+ const bookmarkIds = await listObj.getBookmarkIds();
+ const list = listObj.asZBookmarkList();
const bookmarks = await Bookmark.loadMulti(authedCtx, {
ids: bookmarkIds,
@@ -137,9 +238,9 @@ export abstract class List implements PrivacyAware {
return {
list: {
- icon: list.list.icon,
- name: list.list.name,
- description: list.list.description,
+ icon: list.icon,
+ name: list.name,
+ description: list.description,
ownerName: listdb.user.name,
numItems: bookmarkIds.length,
},
@@ -164,32 +265,127 @@ export abstract class List implements PrivacyAware {
query: input.query,
})
.returning();
- return this.fromData(ctx, result);
+ return this.fromData(
+ ctx,
+ {
+ ...result,
+ userRole: "owner",
+ hasCollaborators: false, // Newly created lists have no collaborators
+ },
+ null,
+ );
+ }
+
+ static async getAll(ctx: AuthedContext) {
+ const [ownedLists, sharedLists] = await Promise.all([
+ this.getAllOwned(ctx),
+ this.getSharedWithUser(ctx),
+ ]);
+ return [...ownedLists, ...sharedLists];
}
- static async getAll(ctx: AuthedContext): Promise<(ManualList | SmartList)[]> {
+ static async getAllOwned(
+ ctx: AuthedContext,
+ ): Promise<(ManualList | SmartList)[]> {
const lists = await ctx.db.query.bookmarkLists.findMany({
columns: {
rssToken: false,
},
where: and(eq(bookmarkLists.userId, ctx.user.id)),
+ with: {
+ collaborators: {
+ columns: {
+ id: true,
+ },
+ limit: 1,
+ },
+ },
});
- return lists.map((l) => this.fromData(ctx, l));
+ return lists.map((l) =>
+ this.fromData(
+ ctx,
+ {
+ ...l,
+ userRole: "owner",
+ hasCollaborators: l.collaborators.length > 0,
+ },
+ null /* this is an owned list */,
+ ),
+ );
}
static async forBookmark(ctx: AuthedContext, bookmarkId: string) {
const lists = await ctx.db.query.bookmarksInLists.findMany({
- where: and(eq(bookmarksInLists.bookmarkId, bookmarkId)),
+ where: eq(bookmarksInLists.bookmarkId, bookmarkId),
with: {
list: {
columns: {
rssToken: false,
},
+ with: {
+ collaborators: {
+ where: eq(listCollaborators.userId, ctx.user.id),
+ columns: {
+ id: true,
+ role: true,
+ },
+ },
+ },
},
},
});
- invariant(lists.map((l) => l.list.userId).every((id) => id == ctx.user.id));
- return lists.map((l) => this.fromData(ctx, l.list));
+
+ // For owner lists, we need to check if they actually have collaborators
+ // by querying the collaborators table separately (without user filter)
+ const ownerListIds = lists
+ .filter((l) => l.list.userId === ctx.user.id)
+ .map((l) => l.list.id);
+
+ const listsWithCollaborators = new Set<string>();
+ if (ownerListIds.length > 0) {
+ // Use a single query with inArray instead of N queries
+ const collaborators = await ctx.db.query.listCollaborators.findMany({
+ where: inArray(listCollaborators.listId, ownerListIds),
+ columns: {
+ listId: true,
+ },
+ });
+ collaborators.forEach((c) => {
+ listsWithCollaborators.add(c.listId);
+ });
+ }
+
+ return lists.flatMap((l) => {
+ let userRole: "owner" | "editor" | "viewer" | null;
+ let collaboratorEntry: ListCollaboratorEntry | null = null;
+ if (l.list.collaborators.length > 0) {
+ invariant(l.list.collaborators.length == 1);
+ userRole = l.list.collaborators[0].role;
+ collaboratorEntry = {
+ membershipId: l.list.collaborators[0].id,
+ };
+ } else if (l.list.userId === ctx.user.id) {
+ userRole = "owner";
+ } else {
+ userRole = null;
+ }
+ return userRole
+ ? [
+ this.fromData(
+ ctx,
+ {
+ ...l.list,
+ userRole,
+ hasCollaborators:
+ userRole !== "owner"
+ ? true
+ : listsWithCollaborators.has(l.list.id),
+ },
+ collaboratorEntry,
+ ),
+ ]
+ : [];
+ });
}
ensureCanAccess(ctx: AuthedContext): void {
@@ -201,7 +397,81 @@ export abstract class List implements PrivacyAware {
}
}
+ /**
+ * Check if the user can view this list and its bookmarks.
+ */
+ canUserView(): boolean {
+ return switchCase(this.list.userRole, {
+ owner: true,
+ editor: true,
+ viewer: true,
+ public: true,
+ });
+ }
+
+ /**
+ * Check if the user can edit this list (add/remove bookmarks).
+ */
+ canUserEdit(): boolean {
+ return switchCase(this.list.userRole, {
+ owner: true,
+ editor: true,
+ viewer: false,
+ public: false,
+ });
+ }
+
+ /**
+ * Check if the user can manage this list (edit metadata, delete, manage collaborators).
+ * Only the owner can manage the list.
+ */
+ canUserManage(): boolean {
+ return switchCase(this.list.userRole, {
+ owner: true,
+ editor: false,
+ viewer: false,
+ public: false,
+ });
+ }
+
+ /**
+ * Ensure the user can view this list. Throws if they cannot.
+ */
+ ensureCanView(): void {
+ if (!this.canUserView()) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "User is not allowed to view this list",
+ });
+ }
+ }
+
+ /**
+ * Ensure the user can edit this list. Throws if they cannot.
+ */
+ ensureCanEdit(): void {
+ if (!this.canUserEdit()) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "User is not allowed to edit this list",
+ });
+ }
+ }
+
+ /**
+ * Ensure the user can manage this list. Throws if they cannot.
+ */
+ ensureCanManage(): void {
+ if (!this.canUserManage()) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "User is not allowed to manage this list",
+ });
+ }
+ }
+
async delete() {
+ this.ensureCanManage();
const res = await this.ctx.db
.delete(bookmarkLists)
.where(
@@ -216,22 +486,23 @@ export abstract class List implements PrivacyAware {
}
async getChildren(): Promise<(ManualList | SmartList)[]> {
- const lists = await List.getAll(this.ctx);
- const listById = new Map(lists.map((l) => [l.list.id, l]));
+ const lists = await List.getAllOwned(this.ctx);
+ const listById = new Map(lists.map((l) => [l.id, l]));
const adjecencyList = new Map<string, string[]>();
// Initialize all lists with empty arrays first
lists.forEach((l) => {
- adjecencyList.set(l.list.id, []);
+ adjecencyList.set(l.id, []);
});
// Then populate the parent-child relationships
lists.forEach((l) => {
- if (l.list.parentId) {
- const currentChildren = adjecencyList.get(l.list.parentId) ?? [];
- currentChildren.push(l.list.id);
- adjecencyList.set(l.list.parentId, currentChildren);
+ const parentId = l.asZBookmarkList().parentId;
+ if (parentId) {
+ const currentChildren = adjecencyList.get(parentId) ?? [];
+ currentChildren.push(l.id);
+ adjecencyList.set(parentId, currentChildren);
}
});
@@ -253,6 +524,7 @@ export abstract class List implements PrivacyAware {
async update(
input: z.infer<typeof zEditBookmarkListSchemaWithValidation>,
): Promise<void> {
+ this.ensureCanManage();
const result = await this.ctx.db
.update(bookmarkLists)
.set({
@@ -273,7 +545,21 @@ export abstract class List implements PrivacyAware {
if (result.length == 0) {
throw new TRPCError({ code: "NOT_FOUND" });
}
- this.list = result[0];
+ invariant(result[0].userId === this.ctx.user.id);
+ // Fetch current collaborators count to update hasCollaborators
+ const collaboratorsCount =
+ await this.ctx.db.query.listCollaborators.findMany({
+ where: eq(listCollaborators.listId, this.list.id),
+ columns: {
+ id: true,
+ },
+ limit: 1,
+ });
+ this.list = {
+ ...result[0],
+ userRole: "owner",
+ hasCollaborators: collaboratorsCount.length > 0,
+ };
}
private async setRssToken(token: string | null) {
@@ -294,6 +580,7 @@ export abstract class List implements PrivacyAware {
}
async getRssToken(): Promise<string | null> {
+ this.ensureCanManage();
const [result] = await this.ctx.db
.select({ rssToken: bookmarkLists.rssToken })
.from(bookmarkLists)
@@ -308,13 +595,237 @@ export abstract class List implements PrivacyAware {
}
async regenRssToken() {
+ this.ensureCanManage();
return await this.setRssToken(crypto.randomBytes(32).toString("hex"));
}
async clearRssToken() {
+ this.ensureCanManage();
await this.setRssToken(null);
}
+ /**
+ * Add a collaborator to this list by email.
+ */
+ async addCollaboratorByEmail(
+ email: string,
+ role: "viewer" | "editor",
+ ): Promise<void> {
+ this.ensureCanManage();
+
+ // Look up the user by email
+ const user = await this.ctx.db.query.users.findFirst({
+ where: (users, { eq }) => eq(users.email, email),
+ });
+
+ if (!user) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "No user found with that email address",
+ });
+ }
+
+ // Check that the user is not adding themselves
+ if (user.id === this.list.userId) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Cannot add the list owner as a collaborator",
+ });
+ }
+
+ // Check that the collaborator is not already added
+ const existing = await this.ctx.db.query.listCollaborators.findFirst({
+ where: and(
+ eq(listCollaborators.listId, this.list.id),
+ eq(listCollaborators.userId, user.id),
+ ),
+ });
+
+ if (existing) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "User is already a collaborator on this list",
+ });
+ }
+
+ // Only manual lists can be collaborative
+ if (this.list.type !== "manual") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Only manual lists can have collaborators",
+ });
+ }
+
+ await this.ctx.db.insert(listCollaborators).values({
+ listId: this.list.id,
+ userId: user.id,
+ role,
+ addedBy: this.ctx.user.id,
+ });
+ }
+
+ /**
+ * Remove a collaborator from this list.
+ * Only the list owner can remove collaborators.
+ * This also removes all bookmarks that the collaborator added to the list.
+ */
+ async removeCollaborator(userId: string): Promise<void> {
+ this.ensureCanManage();
+
+ const result = await this.ctx.db
+ .delete(listCollaborators)
+ .where(
+ and(
+ eq(listCollaborators.listId, this.list.id),
+ eq(listCollaborators.userId, userId),
+ ),
+ );
+
+ if (result.changes === 0) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Collaborator not found",
+ });
+ }
+ }
+
+ /**
+ * Allow a user to leave a list (remove themselves as a collaborator).
+ * This bypasses the owner check since users should be able to leave lists they're collaborating on.
+ * This also removes all bookmarks that the user added to the list.
+ */
+ async leaveList(): Promise<void> {
+ if (this.list.userRole === "owner") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message:
+ "List owners cannot leave their own list. Delete the list instead.",
+ });
+ }
+
+ const result = await this.ctx.db
+ .delete(listCollaborators)
+ .where(
+ and(
+ eq(listCollaborators.listId, this.list.id),
+ eq(listCollaborators.userId, this.ctx.user.id),
+ ),
+ );
+
+ if (result.changes === 0) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Collaborator not found",
+ });
+ }
+ }
+
+ /**
+ * Update a collaborator's role.
+ */
+ async updateCollaboratorRole(
+ userId: string,
+ role: "viewer" | "editor",
+ ): Promise<void> {
+ this.ensureCanManage();
+
+ const result = await this.ctx.db
+ .update(listCollaborators)
+ .set({ role })
+ .where(
+ and(
+ eq(listCollaborators.listId, this.list.id),
+ eq(listCollaborators.userId, userId),
+ ),
+ );
+
+ if (result.changes === 0) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Collaborator not found",
+ });
+ }
+ }
+
+ /**
+ * Get all collaborators for this list.
+ */
+ async getCollaborators() {
+ this.ensureCanView();
+
+ const collaborators = await this.ctx.db.query.listCollaborators.findMany({
+ where: eq(listCollaborators.listId, this.list.id),
+ with: {
+ user: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ },
+ },
+ },
+ });
+
+ // Get the owner information
+ const owner = await this.ctx.db.query.users.findFirst({
+ where: eq(users.id, this.list.userId),
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ },
+ });
+
+ return {
+ collaborators: collaborators.map((c) => ({
+ id: c.id,
+ userId: c.userId,
+ role: c.role,
+ addedAt: c.addedAt,
+ user: c.user,
+ })),
+ owner: owner
+ ? {
+ id: owner.id,
+ name: owner.name,
+ email: owner.email,
+ }
+ : null,
+ };
+ }
+
+ /**
+ * Get all lists shared with the user (as a collaborator).
+ */
+ static async getSharedWithUser(
+ ctx: AuthedContext,
+ ): Promise<(ManualList | SmartList)[]> {
+ const collaborations = await ctx.db.query.listCollaborators.findMany({
+ where: eq(listCollaborators.userId, ctx.user.id),
+ with: {
+ list: {
+ columns: {
+ rssToken: false,
+ },
+ },
+ },
+ });
+
+ return collaborations.map((c) =>
+ this.fromData(
+ ctx,
+ {
+ ...c.list,
+ userRole: c.role,
+ hasCollaborators: true, // If you're a collaborator, the list has collaborators
+ },
+ {
+ membershipId: c.id,
+ },
+ ),
+ );
+ }
+
abstract get type(): "manual" | "smart";
abstract getBookmarkIds(ctx: AuthedContext): Promise<string[]>;
abstract getSize(ctx: AuthedContext): Promise<number>;
@@ -392,7 +903,11 @@ export class SmartList extends List {
}
export class ManualList extends List {
- constructor(ctx: AuthedContext, list: ZBookmarkList & { userId: string }) {
+ constructor(
+ ctx: AuthedContext,
+ list: ZBookmarkList & { userId: string },
+ private collaboratorEntry: ListCollaboratorEntry | null,
+ ) {
super(ctx, list);
}
@@ -418,10 +933,13 @@ export class ManualList extends List {
}
async addBookmark(bookmarkId: string): Promise<void> {
+ this.ensureCanEdit();
+
try {
await this.ctx.db.insert(bookmarksInLists).values({
listId: this.list.id,
bookmarkId,
+ listMembershipId: this.collaboratorEntry?.membershipId,
});
await triggerRuleEngineOnEvent(bookmarkId, [
{
@@ -444,6 +962,9 @@ export class ManualList extends List {
}
async removeBookmark(bookmarkId: string): Promise<void> {
+ // Check that the user can edit this list
+ this.ensureCanEdit();
+
const deleted = await this.ctx.db
.delete(bookmarksInLists)
.where(
@@ -480,6 +1001,8 @@ export class ManualList extends List {
targetList: List,
deleteSourceAfterMerge: boolean,
): Promise<void> {
+ this.ensureCanManage();
+ targetList.ensureCanManage();
if (targetList.type !== "manual") {
throw new TRPCError({
code: "BAD_REQUEST",
@@ -495,7 +1018,7 @@ export class ManualList extends List {
.values(
bookmarkIds.map((id) => ({
bookmarkId: id,
- listId: targetList.list.id,
+ listId: targetList.id,
})),
)
.onConflictDoNothing();
diff --git a/packages/trpc/routers/bookmarks.test.ts b/packages/trpc/routers/bookmarks.test.ts
index 87f34b31..c272e015 100644
--- a/packages/trpc/routers/bookmarks.test.ts
+++ b/packages/trpc/routers/bookmarks.test.ts
@@ -361,20 +361,20 @@ describe("Bookmark Routes", () => {
// All interactions with the wrong user should fail
await expect(() =>
apiCallers[0].bookmarks.deleteBookmark({ bookmarkId: user2Bookmark.id }),
- ).rejects.toThrow(/User is not allowed to access resource/);
+ ).rejects.toThrow(/Bookmark not found/);
await expect(() =>
apiCallers[0].bookmarks.getBookmark({ bookmarkId: user2Bookmark.id }),
- ).rejects.toThrow(/User is not allowed to access resource/);
+ ).rejects.toThrow(/Bookmark not found/);
await expect(() =>
apiCallers[0].bookmarks.updateBookmark({ bookmarkId: user2Bookmark.id }),
- ).rejects.toThrow(/User is not allowed to access resource/);
+ ).rejects.toThrow(/Bookmark not found/);
await expect(() =>
apiCallers[0].bookmarks.updateTags({
bookmarkId: user2Bookmark.id,
attach: [],
detach: [],
}),
- ).rejects.toThrow(/User is not allowed to access resource/);
+ ).rejects.toThrow(/Bookmark not found/);
// Get bookmarks should only show the correct one
expect(
diff --git a/packages/trpc/routers/bookmarks.ts b/packages/trpc/routers/bookmarks.ts
index 72c6c1d1..389f026c 100644
--- a/packages/trpc/routers/bookmarks.ts
+++ b/packages/trpc/routers/bookmarks.ts
@@ -3,12 +3,8 @@ import { and, eq, gt, inArray, lt, or } from "drizzle-orm";
import invariant from "tiny-invariant";
import { z } from "zod";
-import type {
- ZBookmark,
- ZBookmarkContent,
-} from "@karakeep/shared/types/bookmarks";
+import type { ZBookmarkContent } from "@karakeep/shared/types/bookmarks";
import type { ZBookmarkTags } from "@karakeep/shared/types/tags";
-import { db as DONT_USE_db } from "@karakeep/db";
import {
assets,
AssetTypes,
@@ -25,15 +21,11 @@ import {
LinkCrawlerQueue,
OpenAIQueue,
QuotaService,
- SearchIndexingQueue,
triggerRuleEngineOnEvent,
triggerSearchReindex,
triggerWebhook,
} from "@karakeep/shared-server";
-import {
- deleteAsset,
- SUPPORTED_BOOKMARK_ASSET_TYPES,
-} from "@karakeep/shared/assetdb";
+import { SUPPORTED_BOOKMARK_ASSET_TYPES } from "@karakeep/shared/assetdb";
import serverConfig from "@karakeep/shared/config";
import { InferenceClientFactory } from "@karakeep/shared/inference";
import { buildSummaryPrompt } from "@karakeep/shared/prompts";
@@ -54,74 +46,48 @@ import {
} from "@karakeep/shared/types/bookmarks";
import { normalizeTagName } from "@karakeep/shared/utils/tag";
-import type { AuthedContext, Context } from "../index";
+import type { AuthedContext } from "../index";
import { authedProcedure, createRateLimitMiddleware, router } from "../index";
-import { mapDBAssetTypeToUserType } from "../lib/attachments";
import { getBookmarkIdsFromMatcher } from "../lib/search";
-import { Bookmark } from "../models/bookmarks";
+import { BareBookmark, Bookmark } from "../models/bookmarks";
import { ImportSession } from "../models/importSessions";
import { ensureAssetOwnership } from "./assets";
export const ensureBookmarkOwnership = experimental_trpcMiddleware<{
- ctx: Context;
+ ctx: AuthedContext;
input: { bookmarkId: string };
}>().create(async (opts) => {
- const bookmark = await opts.ctx.db.query.bookmarks.findFirst({
- where: eq(bookmarks.id, opts.input.bookmarkId),
- columns: {
- userId: true,
+ const bookmark = await BareBookmark.bareFromId(
+ opts.ctx,
+ opts.input.bookmarkId,
+ );
+ bookmark.ensureOwnership();
+
+ return opts.next({
+ ctx: {
+ ...opts.ctx,
+ bookmark,
},
});
- if (!opts.ctx.user) {
- throw new TRPCError({
- code: "UNAUTHORIZED",
- message: "User is not authorized",
- });
- }
- if (!bookmark) {
- throw new TRPCError({
- code: "NOT_FOUND",
- message: "Bookmark not found",
- });
- }
- if (bookmark.userId != opts.ctx.user.id) {
- throw new TRPCError({
- code: "FORBIDDEN",
- message: "User is not allowed to access resource",
- });
- }
-
- return opts.next();
});
-async function getBookmark(
- ctx: AuthedContext,
- bookmarkId: string,
- includeContent: boolean,
-) {
- const bookmark = await ctx.db.query.bookmarks.findFirst({
- where: and(eq(bookmarks.userId, ctx.user.id), eq(bookmarks.id, bookmarkId)),
- with: {
- tagsOnBookmarks: {
- with: {
- tag: true,
- },
- },
- link: true,
- text: true,
- asset: true,
- assets: true,
+export const ensureBookmarkAccess = experimental_trpcMiddleware<{
+ ctx: AuthedContext;
+ input: { bookmarkId: string };
+}>().create(async (opts) => {
+ // Throws if bookmark doesn't exist or user doesn't have access
+ const bookmark = await BareBookmark.bareFromId(
+ opts.ctx,
+ opts.input.bookmarkId,
+ );
+
+ return opts.next({
+ ctx: {
+ ...opts.ctx,
+ bookmark,
},
});
- if (!bookmark) {
- throw new TRPCError({
- code: "NOT_FOUND",
- message: "Bookmark not found",
- });
- }
-
- return await toZodSchema(bookmark, includeContent);
-}
+});
async function attemptToDedupLink(ctx: AuthedContext, url: string) {
const result = await ctx.db
@@ -135,128 +101,9 @@ async function attemptToDedupLink(ctx: AuthedContext, url: string) {
if (result.length == 0) {
return null;
}
- return getBookmark(ctx, result[0].id, /* includeContent: */ false);
-}
-
-async function dummyDrizzleReturnType() {
- const x = await DONT_USE_db.query.bookmarks.findFirst({
- with: {
- tagsOnBookmarks: {
- with: {
- tag: true,
- },
- },
- link: true,
- text: true,
- asset: true,
- assets: true,
- },
- });
- if (!x) {
- throw new Error();
- }
- return x;
-}
-
-type BookmarkQueryReturnType = Awaited<
- ReturnType<typeof dummyDrizzleReturnType>
->;
-
-async function cleanupAssetForBookmark(
- bookmark: Pick<BookmarkQueryReturnType, "asset" | "userId" | "assets">,
-) {
- const assetIds: Set<string> = new Set<string>(
- bookmark.assets.map((a) => a.id),
- );
- // Todo: Remove when the bookmark asset is also in the assets table
- if (bookmark.asset) {
- assetIds.add(bookmark.asset.assetId);
- }
- await Promise.all(
- Array.from(assetIds).map((assetId) =>
- deleteAsset({ userId: bookmark.userId, assetId }),
- ),
- );
-}
-
-async function toZodSchema(
- bookmark: BookmarkQueryReturnType,
- includeContent: boolean,
-): Promise<ZBookmark> {
- const { tagsOnBookmarks, link, text, asset, assets, ...rest } = bookmark;
-
- let content: ZBookmarkContent = {
- type: BookmarkTypes.UNKNOWN,
- };
- if (bookmark.link) {
- content = {
- type: BookmarkTypes.LINK,
- screenshotAssetId: assets.find(
- (a) => a.assetType == AssetTypes.LINK_SCREENSHOT,
- )?.id,
- fullPageArchiveAssetId: assets.find(
- (a) => a.assetType == AssetTypes.LINK_FULL_PAGE_ARCHIVE,
- )?.id,
- precrawledArchiveAssetId: assets.find(
- (a) => a.assetType == AssetTypes.LINK_PRECRAWLED_ARCHIVE,
- )?.id,
- imageAssetId: assets.find(
- (a) => a.assetType == AssetTypes.LINK_BANNER_IMAGE,
- )?.id,
- videoAssetId: assets.find((a) => a.assetType == AssetTypes.LINK_VIDEO)
- ?.id,
- url: link.url,
- title: link.title,
- description: link.description,
- imageUrl: link.imageUrl,
- favicon: link.favicon,
- htmlContent: includeContent
- ? await Bookmark.getBookmarkHtmlContent(link, bookmark.userId)
- : null,
- crawledAt: link.crawledAt,
- author: link.author,
- publisher: link.publisher,
- datePublished: link.datePublished,
- dateModified: link.dateModified,
- };
- }
- if (bookmark.text) {
- content = {
- type: BookmarkTypes.TEXT,
- // It's ok to include the text content as it's usually not big and is used to render the text bookmark card.
- text: text.text ?? "",
- sourceUrl: text.sourceUrl,
- };
- }
- if (bookmark.asset) {
- content = {
- type: BookmarkTypes.ASSET,
- assetType: asset.assetType,
- assetId: asset.assetId,
- fileName: asset.fileName,
- sourceUrl: asset.sourceUrl,
- size: assets.find((a) => a.id == asset.assetId)?.size,
- content: includeContent ? asset.content : null,
- };
- }
-
- return {
- tags: tagsOnBookmarks
- .map((t) => ({
- attachedBy: t.attachedBy,
- ...t.tag,
- }))
- .sort((a, b) =>
- a.attachedBy === "ai" ? 1 : b.attachedBy === "ai" ? -1 : 0,
- ),
- content,
- assets: assets.map((a) => ({
- id: a.id,
- assetType: mapDBAssetTypeToUserType(a.assetType),
- fileName: a.fileName,
- })),
- ...rest,
- };
+ return (
+ await Bookmark.fromId(ctx, result[0].id, /* includeContent: */ false)
+ ).asZBookmark();
}
export const bookmarksAppRouter = router({
@@ -620,11 +467,13 @@ export const bookmarksAppRouter = router({
});
// Refetch the updated bookmark data to return the full object
- const updatedBookmark = await getBookmark(
- ctx,
- input.bookmarkId,
- /* includeContent: */ false,
- );
+ const updatedBookmark = (
+ await Bookmark.fromId(
+ ctx,
+ input.bookmarkId,
+ /* includeContent: */ false,
+ )
+ ).asZBookmark();
if (input.favourited === true || input.archived === true) {
await triggerRuleEngineOnEvent(
@@ -686,37 +535,8 @@ export const bookmarksAppRouter = router({
.input(z.object({ bookmarkId: z.string() }))
.use(ensureBookmarkOwnership)
.mutation(async ({ input, ctx }) => {
- const bookmark = await ctx.db.query.bookmarks.findFirst({
- where: and(
- eq(bookmarks.id, input.bookmarkId),
- eq(bookmarks.userId, ctx.user.id),
- ),
- with: {
- asset: true,
- link: true,
- assets: true,
- },
- });
- const deleted = await ctx.db
- .delete(bookmarks)
- .where(
- and(
- eq(bookmarks.userId, ctx.user.id),
- eq(bookmarks.id, input.bookmarkId),
- ),
- );
- await SearchIndexingQueue.enqueue({
- bookmarkId: input.bookmarkId,
- type: "delete",
- });
- await triggerWebhook(input.bookmarkId, "deleted", ctx.user.id);
- if (deleted.changes > 0 && bookmark) {
- await cleanupAssetForBookmark({
- asset: bookmark.asset,
- userId: ctx.user.id,
- assets: bookmark.assets,
- });
- }
+ const bookmark = await Bookmark.fromId(ctx, input.bookmarkId, false);
+ await bookmark.delete();
}),
recrawlBookmark: authedProcedure
.use(
@@ -754,9 +574,11 @@ export const bookmarksAppRouter = router({
}),
)
.output(zBookmarkSchema)
- .use(ensureBookmarkOwnership)
+ .use(ensureBookmarkAccess)
.query(async ({ input, ctx }) => {
- return await getBookmark(ctx, input.bookmarkId, input.includeContent);
+ return (
+ await Bookmark.fromId(ctx, input.bookmarkId, input.includeContent)
+ ).asZBookmark();
}),
searchBookmarks: authedProcedure
.input(zSearchBookmarksRequestSchema)
@@ -818,25 +640,11 @@ export const bookmarksAppRouter = router({
acc[r.id] = r.score || 0;
return acc;
}, {});
- const results = await ctx.db.query.bookmarks.findMany({
- where: and(
- eq(bookmarks.userId, ctx.user.id),
- inArray(
- bookmarks.id,
- resp.hits.map((h) => h.id),
- ),
- ),
- with: {
- tagsOnBookmarks: {
- with: {
- tag: true,
- },
- },
- link: true,
- text: true,
- asset: true,
- assets: true,
- },
+
+ const { bookmarks: results } = await Bookmark.loadMulti(ctx, {
+ ids: resp.hits.map((h) => h.id),
+ includeContent: input.includeContent,
+ sortOrder: "desc", // Doesn't matter, we're sorting again afterwards and the list contain all data
});
switch (true) {
@@ -852,9 +660,7 @@ export const bookmarksAppRouter = router({
}
return {
- bookmarks: await Promise.all(
- results.map((b) => toZodSchema(b, input.includeContent)),
- ),
+ bookmarks: results.map((b) => b.asZBookmark()),
nextCursor:
resp.hits.length + (input.cursor?.offset || 0) >= resp.totalHits
? null
diff --git a/packages/trpc/routers/highlights.ts b/packages/trpc/routers/highlights.ts
index cb9b5e79..65d99880 100644
--- a/packages/trpc/routers/highlights.ts
+++ b/packages/trpc/routers/highlights.ts
@@ -10,7 +10,7 @@ import {
import { authedProcedure, router } from "../index";
import { Highlight } from "../models/highlights";
-import { ensureBookmarkOwnership } from "./bookmarks";
+import { ensureBookmarkAccess, ensureBookmarkOwnership } from "./bookmarks";
export const highlightsAppRouter = router({
create: authedProcedure
@@ -24,9 +24,9 @@ export const highlightsAppRouter = router({
getForBookmark: authedProcedure
.input(z.object({ bookmarkId: z.string() }))
.output(z.object({ highlights: z.array(zHighlightSchema) }))
- .use(ensureBookmarkOwnership)
- .query(async ({ input, ctx }) => {
- const highlights = await Highlight.getForBookmark(ctx, input.bookmarkId);
+ .use(ensureBookmarkAccess)
+ .query(async ({ ctx }) => {
+ const highlights = await Highlight.getForBookmark(ctx, ctx.bookmark);
return { highlights: highlights.map((h) => h.asPublicHighlight()) };
}),
get: authedProcedure
diff --git a/packages/trpc/routers/lists.ts b/packages/trpc/routers/lists.ts
index 7118c608..c9a19f30 100644
--- a/packages/trpc/routers/lists.ts
+++ b/packages/trpc/routers/lists.ts
@@ -9,14 +9,15 @@ import {
} from "@karakeep/shared/types/lists";
import type { AuthedContext } from "../index";
-import { authedProcedure, router } from "../index";
+import { authedProcedure, createRateLimitMiddleware, router } from "../index";
import { List } from "../models/lists";
import { ensureBookmarkOwnership } from "./bookmarks";
-export const ensureListOwnership = experimental_trpcMiddleware<{
+export const ensureListAtLeastViewer = experimental_trpcMiddleware<{
ctx: AuthedContext;
input: { listId: string };
}>().create(async (opts) => {
+ // This would throw if the user can't view the list
const list = await List.fromId(opts.ctx, opts.input.listId);
return opts.next({
ctx: {
@@ -26,20 +27,41 @@ export const ensureListOwnership = experimental_trpcMiddleware<{
});
});
+export const ensureListAtLeastEditor = experimental_trpcMiddleware<{
+ ctx: AuthedContext & { list: List };
+ input: { listId: string };
+}>().create(async (opts) => {
+ opts.ctx.list.ensureCanEdit();
+ return opts.next({
+ ctx: opts.ctx,
+ });
+});
+
+export const ensureListAtLeastOwner = experimental_trpcMiddleware<{
+ ctx: AuthedContext & { list: List };
+ input: { listId: string };
+}>().create(async (opts) => {
+ opts.ctx.list.ensureCanManage();
+ return opts.next({
+ ctx: opts.ctx,
+ });
+});
+
export const listsAppRouter = router({
create: authedProcedure
.input(zNewBookmarkListSchema)
.output(zBookmarkListSchema)
.mutation(async ({ input, ctx }) => {
- return await List.create(ctx, input).then((l) => l.list);
+ return await List.create(ctx, input).then((l) => l.asZBookmarkList());
}),
edit: authedProcedure
.input(zEditBookmarkListSchemaWithValidation)
.output(zBookmarkListSchema)
- .use(ensureListOwnership)
+ .use(ensureListAtLeastViewer)
+ .use(ensureListAtLeastOwner)
.mutation(async ({ input, ctx }) => {
await ctx.list.update(input);
- return ctx.list.list;
+ return ctx.list.asZBookmarkList();
}),
merge: authedProcedure
.input(zMergeListSchema)
@@ -48,6 +70,8 @@ export const listsAppRouter = router({
List.fromId(ctx, input.sourceId),
List.fromId(ctx, input.targetId),
]);
+ sourceList.ensureCanManage();
+ targetList.ensureCanManage();
return await sourceList.mergeInto(
targetList,
input.deleteSourceAfterMerge,
@@ -60,7 +84,8 @@ export const listsAppRouter = router({
deleteChildren: z.boolean().optional().default(false),
}),
)
- .use(ensureListOwnership)
+ .use(ensureListAtLeastViewer)
+ .use(ensureListAtLeastOwner)
.mutation(async ({ ctx, input }) => {
if (input.deleteChildren) {
const children = await ctx.list.getChildren();
@@ -75,7 +100,8 @@ export const listsAppRouter = router({
bookmarkId: z.string(),
}),
)
- .use(ensureListOwnership)
+ .use(ensureListAtLeastViewer)
+ .use(ensureListAtLeastEditor)
.use(ensureBookmarkOwnership)
.mutation(async ({ input, ctx }) => {
await ctx.list.addBookmark(input.bookmarkId);
@@ -87,8 +113,8 @@ export const listsAppRouter = router({
bookmarkId: z.string(),
}),
)
- .use(ensureListOwnership)
- .use(ensureBookmarkOwnership)
+ .use(ensureListAtLeastViewer)
+ .use(ensureListAtLeastEditor)
.mutation(async ({ input, ctx }) => {
await ctx.list.removeBookmark(input.bookmarkId);
}),
@@ -99,9 +125,9 @@ export const listsAppRouter = router({
}),
)
.output(zBookmarkListSchema)
- .use(ensureListOwnership)
- .query(({ ctx }) => {
- return ctx.list.list;
+ .use(ensureListAtLeastViewer)
+ .query(async ({ ctx }) => {
+ return ctx.list.asZBookmarkList();
}),
list: authedProcedure
.output(
@@ -111,7 +137,7 @@ export const listsAppRouter = router({
)
.query(async ({ ctx }) => {
const results = await List.getAll(ctx);
- return { lists: results.map((l) => l.list) };
+ return { lists: results.map((l) => l.asZBookmarkList()) };
}),
getListsOfBookmark: authedProcedure
.input(z.object({ bookmarkId: z.string() }))
@@ -123,7 +149,7 @@ export const listsAppRouter = router({
.use(ensureBookmarkOwnership)
.query(async ({ input, ctx }) => {
const lists = await List.forBookmark(ctx, input.bookmarkId);
- return { lists: lists.map((l) => l.list) };
+ return { lists: lists.map((l) => l.asZBookmarkList()) };
}),
stats: authedProcedure
.output(
@@ -134,7 +160,7 @@ export const listsAppRouter = router({
.query(async ({ ctx }) => {
const lists = await List.getAll(ctx);
const sizes = await Promise.all(lists.map((l) => l.getSize()));
- return { stats: new Map(lists.map((l, i) => [l.list.id, sizes[i]])) };
+ return { stats: new Map(lists.map((l, i) => [l.id, sizes[i]])) };
}),
// Rss endpoints
@@ -149,7 +175,8 @@ export const listsAppRouter = router({
token: z.string(),
}),
)
- .use(ensureListOwnership)
+ .use(ensureListAtLeastViewer)
+ .use(ensureListAtLeastOwner)
.mutation(async ({ ctx }) => {
const token = await ctx.list.regenRssToken();
return { token: token! };
@@ -160,7 +187,8 @@ export const listsAppRouter = router({
listId: z.string(),
}),
)
- .use(ensureListOwnership)
+ .use(ensureListAtLeastViewer)
+ .use(ensureListAtLeastOwner)
.mutation(async ({ ctx }) => {
await ctx.list.clearRssToken();
}),
@@ -175,8 +203,101 @@ export const listsAppRouter = router({
token: z.string().nullable(),
}),
)
- .use(ensureListOwnership)
+ .use(ensureListAtLeastViewer)
+ .use(ensureListAtLeastOwner)
.query(async ({ ctx }) => {
return { token: await ctx.list.getRssToken() };
}),
+
+ // Collaboration endpoints
+ addCollaborator: authedProcedure
+ .input(
+ z.object({
+ listId: z.string(),
+ email: z.string().email(),
+ role: z.enum(["viewer", "editor"]),
+ }),
+ )
+ .use(
+ createRateLimitMiddleware({
+ name: "lists.addCollaborator",
+ windowMs: 15 * 60 * 1000,
+ maxRequests: 20,
+ }),
+ )
+ .use(ensureListAtLeastViewer)
+ .use(ensureListAtLeastOwner)
+ .mutation(async ({ input, ctx }) => {
+ await ctx.list.addCollaboratorByEmail(input.email, input.role);
+ }),
+ removeCollaborator: authedProcedure
+ .input(
+ z.object({
+ listId: z.string(),
+ userId: z.string(),
+ }),
+ )
+ .use(ensureListAtLeastViewer)
+ .use(ensureListAtLeastOwner)
+ .mutation(async ({ input, ctx }) => {
+ await ctx.list.removeCollaborator(input.userId);
+ }),
+ updateCollaboratorRole: authedProcedure
+ .input(
+ z.object({
+ listId: z.string(),
+ userId: z.string(),
+ role: z.enum(["viewer", "editor"]),
+ }),
+ )
+ .use(ensureListAtLeastViewer)
+ .use(ensureListAtLeastOwner)
+ .mutation(async ({ input, ctx }) => {
+ await ctx.list.updateCollaboratorRole(input.userId, input.role);
+ }),
+ getCollaborators: authedProcedure
+ .input(
+ z.object({
+ listId: z.string(),
+ }),
+ )
+ .output(
+ z.object({
+ collaborators: z.array(
+ z.object({
+ id: z.string(),
+ userId: z.string(),
+ role: z.enum(["viewer", "editor"]),
+ addedAt: z.date(),
+ user: z.object({
+ id: z.string(),
+ name: z.string(),
+ email: z.string(),
+ }),
+ }),
+ ),
+ owner: z
+ .object({
+ id: z.string(),
+ name: z.string(),
+ email: z.string(),
+ })
+ .nullable(),
+ }),
+ )
+ .use(ensureListAtLeastViewer)
+ .query(async ({ ctx }) => {
+ return await ctx.list.getCollaborators();
+ }),
+
+ leaveList: authedProcedure
+ .input(
+ z.object({
+ listId: z.string(),
+ }),
+ )
+ .use(ensureListAtLeastViewer)
+ .mutation(async ({ ctx }) => {
+ await ctx.list.leaveList();
+ }),
});
diff --git a/packages/trpc/routers/sharedLists.test.ts b/packages/trpc/routers/sharedLists.test.ts
new file mode 100644
index 00000000..3b95a033
--- /dev/null
+++ b/packages/trpc/routers/sharedLists.test.ts
@@ -0,0 +1,1922 @@
+import { beforeEach, describe, expect, test } from "vitest";
+
+import { BookmarkTypes } from "@karakeep/shared/types/bookmarks";
+
+import type { CustomTestContext } from "../testUtils";
+import { defaultBeforeEach } from "../testUtils";
+
+beforeEach<CustomTestContext>(defaultBeforeEach(true));
+
+describe("Shared Lists", () => {
+ describe("List Collaboration Management", () => {
+ test<CustomTestContext>("should allow owner to add a collaborator by email", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ // Create a list as owner
+ const list = await ownerApi.lists.create({
+ name: "Test Shared List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ // Get collaborator email
+ const collaboratorUser = await collaboratorApi.users.whoami();
+ const collaboratorEmail = collaboratorUser.email!;
+
+ // Add collaborator
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // Verify collaborator was added
+ const { collaborators, owner } = await ownerApi.lists.getCollaborators({
+ listId: list.id,
+ });
+
+ expect(collaborators).toHaveLength(1);
+ expect(collaborators[0].user.email).toBe(collaboratorEmail);
+ expect(collaborators[0].role).toBe("viewer");
+
+ // Verify owner is included
+ const ownerUser = await ownerApi.users.whoami();
+ expect(owner).toBeDefined();
+ expect(owner?.email).toBe(ownerUser.email);
+ });
+
+ test<CustomTestContext>("should not allow adding owner as collaborator", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+
+ const list = await ownerApi.lists.create({
+ name: "Test List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const ownerUser = await ownerApi.users.whoami();
+
+ await expect(
+ ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: ownerUser.email!,
+ role: "viewer",
+ }),
+ ).rejects.toThrow("Cannot add the list owner as a collaborator");
+ });
+
+ test<CustomTestContext>("should not allow adding duplicate collaborator", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Test List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // Try to add same collaborator again
+ await expect(
+ ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "editor",
+ }),
+ ).rejects.toThrow("User is already a collaborator on this list");
+ });
+
+ test<CustomTestContext>("should allow owner to update collaborator role", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Test List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const collaboratorUser = await collaboratorApi.users.whoami();
+
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorUser.email!,
+ role: "viewer",
+ });
+
+ // Update role to editor
+ await ownerApi.lists.updateCollaboratorRole({
+ listId: list.id,
+ userId: collaboratorUser.id,
+ role: "editor",
+ });
+
+ // Verify role was updated
+ const { collaborators, owner } = await ownerApi.lists.getCollaborators({
+ listId: list.id,
+ });
+
+ expect(collaborators[0].role).toBe("editor");
+ expect(owner).toBeDefined();
+ });
+
+ test<CustomTestContext>("should allow owner to remove collaborator", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Test List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const collaboratorUser = await collaboratorApi.users.whoami();
+
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorUser.email!,
+ role: "viewer",
+ });
+
+ // Remove collaborator
+ await ownerApi.lists.removeCollaborator({
+ listId: list.id,
+ userId: collaboratorUser.id,
+ });
+
+ // Verify collaborator was removed
+ const { collaborators, owner } = await ownerApi.lists.getCollaborators({
+ listId: list.id,
+ });
+
+ expect(collaborators).toHaveLength(0);
+ expect(owner).toBeDefined();
+ });
+
+ test<CustomTestContext>("should include owner information in getCollaborators response", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+
+ const list = await ownerApi.lists.create({
+ name: "Test List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const ownerUser = await ownerApi.users.whoami();
+
+ const { collaborators, owner } = await ownerApi.lists.getCollaborators({
+ listId: list.id,
+ });
+
+ // Verify owner information is present
+ expect(owner).toBeDefined();
+ expect(owner?.id).toBe(ownerUser.id);
+ expect(owner?.name).toBe(ownerUser.name);
+ expect(owner?.email).toBe(ownerUser.email);
+
+ // List with no collaborators should still have owner
+ expect(collaborators).toHaveLength(0);
+ });
+
+ test<CustomTestContext>("should remove collaborator's bookmarks when they are removed", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Test List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ // Owner adds a bookmark
+ const ownerBookmark = await ownerApi.bookmarks.createBookmark({
+ type: BookmarkTypes.TEXT,
+ text: "Owner's bookmark",
+ });
+
+ await ownerApi.lists.addToList({
+ listId: list.id,
+ bookmarkId: ownerBookmark.id,
+ });
+
+ const collaboratorUser = await collaboratorApi.users.whoami();
+
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorUser.email!,
+ role: "editor",
+ });
+
+ // Collaborator adds their own bookmark
+ const collabBookmark = await collaboratorApi.bookmarks.createBookmark({
+ type: BookmarkTypes.TEXT,
+ text: "Collaborator's bookmark",
+ });
+
+ await collaboratorApi.lists.addToList({
+ listId: list.id,
+ bookmarkId: collabBookmark.id,
+ });
+
+ // Verify both bookmarks are in the list
+ const bookmarksBefore = await ownerApi.bookmarks.getBookmarks({
+ listId: list.id,
+ });
+ expect(bookmarksBefore.bookmarks).toHaveLength(2);
+
+ // Remove collaborator
+ await ownerApi.lists.removeCollaborator({
+ listId: list.id,
+ userId: collaboratorUser.id,
+ });
+
+ // Verify only owner's bookmark remains in the list
+ const bookmarksAfter = await ownerApi.bookmarks.getBookmarks({
+ listId: list.id,
+ });
+ expect(bookmarksAfter.bookmarks).toHaveLength(1);
+ expect(bookmarksAfter.bookmarks[0].id).toBe(ownerBookmark.id);
+
+ // Verify collaborator's bookmark still exists (just not in the list)
+ const collabBookmarkStillExists =
+ await collaboratorApi.bookmarks.getBookmark({
+ bookmarkId: collabBookmark.id,
+ });
+ expect(collabBookmarkStillExists.id).toBe(collabBookmark.id);
+ });
+
+ test<CustomTestContext>("should allow collaborator to leave list", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Test List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // Collaborator leaves the list
+ await collaboratorApi.lists.leaveList({
+ listId: list.id,
+ });
+
+ // Verify collaborator was removed
+ const { collaborators, owner } = await ownerApi.lists.getCollaborators({
+ listId: list.id,
+ });
+
+ expect(collaborators).toHaveLength(0);
+ expect(owner).toBeDefined();
+
+ // Verify list no longer appears in shared lists
+ const { lists: allLists } = await collaboratorApi.lists.list();
+ const sharedLists = allLists.filter(
+ (l) => l.userRole === "viewer" || l.userRole === "editor",
+ );
+ expect(sharedLists.find((l) => l.id === list.id)).toBeUndefined();
+ });
+
+ test<CustomTestContext>("should remove collaborator's bookmarks when they leave list", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Test List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ // Owner adds a bookmark
+ const ownerBookmark = await ownerApi.bookmarks.createBookmark({
+ type: BookmarkTypes.TEXT,
+ text: "Owner's bookmark",
+ });
+
+ await ownerApi.lists.addToList({
+ listId: list.id,
+ bookmarkId: ownerBookmark.id,
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "editor",
+ });
+
+ // Collaborator adds their own bookmark
+ const collabBookmark = await collaboratorApi.bookmarks.createBookmark({
+ type: BookmarkTypes.TEXT,
+ text: "Collaborator's bookmark",
+ });
+
+ await collaboratorApi.lists.addToList({
+ listId: list.id,
+ bookmarkId: collabBookmark.id,
+ });
+
+ // Verify both bookmarks are in the list
+ const bookmarksBefore = await ownerApi.bookmarks.getBookmarks({
+ listId: list.id,
+ });
+ expect(bookmarksBefore.bookmarks).toHaveLength(2);
+
+ // Collaborator leaves the list
+ await collaboratorApi.lists.leaveList({
+ listId: list.id,
+ });
+
+ // Verify only owner's bookmark remains in the list
+ const bookmarksAfter = await ownerApi.bookmarks.getBookmarks({
+ listId: list.id,
+ });
+ expect(bookmarksAfter.bookmarks).toHaveLength(1);
+ expect(bookmarksAfter.bookmarks[0].id).toBe(ownerBookmark.id);
+
+ // Verify collaborator's bookmark still exists (just not in the list)
+ const collabBookmarkStillExists =
+ await collaboratorApi.bookmarks.getBookmark({
+ bookmarkId: collabBookmark.id,
+ });
+ expect(collabBookmarkStillExists.id).toBe(collabBookmark.id);
+ });
+
+ test<CustomTestContext>("should not allow owner to leave their own list", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+
+ const list = await ownerApi.lists.create({
+ name: "Test List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ await expect(
+ ownerApi.lists.leaveList({
+ listId: list.id,
+ }),
+ ).rejects.toThrow(
+ "List owners cannot leave their own list. Delete the list instead.",
+ );
+ });
+
+ test<CustomTestContext>("should not allow non-collaborator to manage collaborators", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const thirdUserApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Test List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const thirdUser = await thirdUserApi.users.whoami();
+
+ // Third user tries to add themselves as collaborator
+ await expect(
+ thirdUserApi.lists.addCollaborator({
+ listId: list.id,
+ email: thirdUser.email!,
+ role: "viewer",
+ }),
+ ).rejects.toThrow("List not found");
+ });
+ });
+
+ describe("List Access and Visibility", () => {
+ test<CustomTestContext>("should show shared list in list endpoint", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Shared List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ const { lists: allLists } = await collaboratorApi.lists.list();
+ const sharedLists = allLists.filter(
+ (l) => l.userRole === "viewer" || l.userRole === "editor",
+ );
+
+ expect(sharedLists).toHaveLength(1);
+ expect(sharedLists[0].id).toBe(list.id);
+ expect(sharedLists[0].name).toBe("Shared List");
+ });
+
+ test<CustomTestContext>("should allow collaborator to get list details", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Shared List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ const retrievedList = await collaboratorApi.lists.get({
+ listId: list.id,
+ });
+
+ expect(retrievedList.id).toBe(list.id);
+ expect(retrievedList.name).toBe("Shared List");
+ expect(retrievedList.userRole).toBe("viewer");
+ });
+
+ test<CustomTestContext>("should not allow non-collaborator to access list", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+ const thirdUserApi = apiCallers[2];
+
+ const list = await ownerApi.lists.create({
+ name: "Private List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ await expect(
+ thirdUserApi.lists.get({
+ listId: list.id,
+ }),
+ ).rejects.toThrow("List not found");
+ });
+
+ test<CustomTestContext>("should show correct userRole for owner", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+
+ const list = await ownerApi.lists.create({
+ name: "My List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const retrievedList = await ownerApi.lists.get({
+ listId: list.id,
+ });
+
+ expect(retrievedList.userRole).toBe("owner");
+ });
+
+ test<CustomTestContext>("should show correct userRole for editor", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Shared List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "editor",
+ });
+
+ const retrievedList = await collaboratorApi.lists.get({
+ listId: list.id,
+ });
+
+ expect(retrievedList.userRole).toBe("editor");
+ });
+ });
+
+ describe("Bookmark Access in Shared Lists", () => {
+ test<CustomTestContext>("should allow collaborator to view bookmarks in shared list", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ // Owner creates list and bookmark
+ const list = await ownerApi.lists.create({
+ name: "Shared List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const bookmark = await ownerApi.bookmarks.createBookmark({
+ type: BookmarkTypes.TEXT,
+ text: "Shared bookmark",
+ });
+
+ await ownerApi.lists.addToList({
+ listId: list.id,
+ bookmarkId: bookmark.id,
+ });
+
+ // Share list with collaborator
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // Collaborator fetches bookmarks from shared list
+ const bookmarks = await collaboratorApi.bookmarks.getBookmarks({
+ listId: list.id,
+ });
+
+ expect(bookmarks.bookmarks).toHaveLength(1);
+ expect(bookmarks.bookmarks[0].id).toBe(bookmark.id);
+ });
+
+ test<CustomTestContext>("should hide owner-specific bookmark state from collaborators", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Shared List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const bookmark = await ownerApi.bookmarks.createBookmark({
+ type: BookmarkTypes.TEXT,
+ text: "Shared bookmark",
+ });
+
+ await ownerApi.bookmarks.updateBookmark({
+ bookmarkId: bookmark.id,
+ archived: true,
+ favourited: true,
+ note: "Private note",
+ });
+
+ await ownerApi.lists.addToList({
+ listId: list.id,
+ bookmarkId: bookmark.id,
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ const ownerView = await ownerApi.bookmarks.getBookmarks({
+ listId: list.id,
+ });
+
+ const collaboratorView = await collaboratorApi.bookmarks.getBookmarks({
+ listId: list.id,
+ });
+
+ const ownerBookmark = ownerView.bookmarks.find(
+ (b) => b.id === bookmark.id,
+ );
+ expect(ownerBookmark?.favourited).toBe(true);
+ expect(ownerBookmark?.archived).toBe(true);
+ expect(ownerBookmark?.note).toBe("Private note");
+
+ const collaboratorBookmark = collaboratorView.bookmarks.find(
+ (b) => b.id === bookmark.id,
+ );
+ expect(collaboratorBookmark?.favourited).toBe(false);
+ expect(collaboratorBookmark?.archived).toBe(false);
+ expect(collaboratorBookmark?.note).toBeNull();
+ });
+
+ // Note: Asset handling for shared bookmarks is tested via the REST API in e2e tests
+ // This is because tRPC tests don't have easy access to file upload functionality
+
+ test<CustomTestContext>("should allow collaborator to view individual shared bookmark", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Shared List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const bookmark = await ownerApi.bookmarks.createBookmark({
+ type: BookmarkTypes.TEXT,
+ text: "Shared bookmark",
+ });
+
+ await ownerApi.lists.addToList({
+ listId: list.id,
+ bookmarkId: bookmark.id,
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // Collaborator gets individual bookmark
+ const response = await collaboratorApi.bookmarks.getBookmark({
+ bookmarkId: bookmark.id,
+ });
+
+ expect(response.id).toBe(bookmark.id);
+ });
+
+ test<CustomTestContext>("should not show shared bookmarks on collaborator's homepage", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Shared List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const sharedBookmark = await ownerApi.bookmarks.createBookmark({
+ type: BookmarkTypes.TEXT,
+ text: "Shared bookmark",
+ });
+
+ await ownerApi.lists.addToList({
+ listId: list.id,
+ bookmarkId: sharedBookmark.id,
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // Collaborator creates their own bookmark
+ const ownBookmark = await collaboratorApi.bookmarks.createBookmark({
+ type: BookmarkTypes.TEXT,
+ text: "My own bookmark",
+ });
+
+ // Fetch all bookmarks (no listId filter)
+ const allBookmarks = await collaboratorApi.bookmarks.getBookmarks({});
+
+ // Should only see own bookmark, not shared one
+ expect(allBookmarks.bookmarks).toHaveLength(1);
+ expect(allBookmarks.bookmarks[0].id).toBe(ownBookmark.id);
+ });
+
+ test<CustomTestContext>("should not allow non-collaborator to access shared bookmark", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+ const thirdUserApi = apiCallers[2]; // User 3 will be the non-collaborator
+
+ const list = await ownerApi.lists.create({
+ name: "Shared List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const bookmark = await ownerApi.bookmarks.createBookmark({
+ type: BookmarkTypes.TEXT,
+ text: "Shared bookmark",
+ });
+
+ await ownerApi.lists.addToList({
+ listId: list.id,
+ bookmarkId: bookmark.id,
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // Don't add thirdUserApi as a collaborator
+ // Third user tries to access the bookmark
+ await expect(
+ thirdUserApi.bookmarks.getBookmark({
+ bookmarkId: bookmark.id,
+ }),
+ ).rejects.toThrow("Bookmark not found");
+ });
+
+ test<CustomTestContext>("should show all bookmarks in shared list regardless of owner", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Shared List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ // Owner adds a bookmark
+ const ownerBookmark = await ownerApi.bookmarks.createBookmark({
+ type: BookmarkTypes.TEXT,
+ text: "Owner's bookmark",
+ });
+
+ await ownerApi.lists.addToList({
+ listId: list.id,
+ bookmarkId: ownerBookmark.id,
+ });
+
+ // Share list with collaborator as editor
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "editor",
+ });
+
+ // Collaborator adds their own bookmark
+ const collabBookmark = await collaboratorApi.bookmarks.createBookmark({
+ type: BookmarkTypes.TEXT,
+ text: "Collaborator's bookmark",
+ });
+
+ await collaboratorApi.lists.addToList({
+ listId: list.id,
+ bookmarkId: collabBookmark.id,
+ });
+
+ // Both users should see both bookmarks in the list
+ const ownerView = await ownerApi.bookmarks.getBookmarks({
+ listId: list.id,
+ });
+
+ const collabView = await collaboratorApi.bookmarks.getBookmarks({
+ listId: list.id,
+ });
+
+ expect(ownerView.bookmarks).toHaveLength(2);
+ expect(collabView.bookmarks).toHaveLength(2);
+ });
+ });
+
+ describe("Bookmark Editing Permissions", () => {
+ test<CustomTestContext>("should not allow viewer to add bookmarks to list", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Shared List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // Viewer creates their own bookmark
+ const bookmark = await collaboratorApi.bookmarks.createBookmark({
+ type: BookmarkTypes.TEXT,
+ text: "My bookmark",
+ });
+
+ // Viewer tries to add it to shared list
+ await expect(
+ collaboratorApi.lists.addToList({
+ listId: list.id,
+ bookmarkId: bookmark.id,
+ }),
+ ).rejects.toThrow("User is not allowed to edit this list");
+ });
+
+ test<CustomTestContext>("should allow editor to add bookmarks to list", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Shared List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "editor",
+ });
+
+ // Editor creates their own bookmark
+ const bookmark = await collaboratorApi.bookmarks.createBookmark({
+ type: BookmarkTypes.TEXT,
+ text: "My bookmark",
+ });
+
+ // Editor adds it to shared list
+ await collaboratorApi.lists.addToList({
+ listId: list.id,
+ bookmarkId: bookmark.id,
+ });
+
+ // Verify bookmark was added
+ const bookmarks = await ownerApi.bookmarks.getBookmarks({
+ listId: list.id,
+ });
+
+ expect(bookmarks.bookmarks).toHaveLength(1);
+ expect(bookmarks.bookmarks[0].id).toBe(bookmark.id);
+ });
+
+ test<CustomTestContext>("should not allow viewer to remove bookmarks from list", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Shared List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const bookmark = await ownerApi.bookmarks.createBookmark({
+ type: BookmarkTypes.TEXT,
+ text: "Test bookmark",
+ });
+
+ await ownerApi.lists.addToList({
+ listId: list.id,
+ bookmarkId: bookmark.id,
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // Viewer tries to remove bookmark
+ await expect(
+ collaboratorApi.lists.removeFromList({
+ listId: list.id,
+ bookmarkId: bookmark.id,
+ }),
+ ).rejects.toThrow("User is not allowed to edit this list");
+ });
+
+ test<CustomTestContext>("should allow editor to remove bookmarks from list", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Shared List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const bookmark = await ownerApi.bookmarks.createBookmark({
+ type: BookmarkTypes.TEXT,
+ text: "Test bookmark",
+ });
+
+ await ownerApi.lists.addToList({
+ listId: list.id,
+ bookmarkId: bookmark.id,
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "editor",
+ });
+
+ // Editor removes bookmark
+ await collaboratorApi.lists.removeFromList({
+ listId: list.id,
+ bookmarkId: bookmark.id,
+ });
+
+ // Verify bookmark was removed
+ const bookmarks = await ownerApi.bookmarks.getBookmarks({
+ listId: list.id,
+ });
+
+ expect(bookmarks.bookmarks).toHaveLength(0);
+ });
+
+ test<CustomTestContext>("should not allow collaborator to edit bookmark they don't own", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Shared List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const bookmark = await ownerApi.bookmarks.createBookmark({
+ type: BookmarkTypes.TEXT,
+ text: "Owner's bookmark",
+ });
+
+ await ownerApi.lists.addToList({
+ listId: list.id,
+ bookmarkId: bookmark.id,
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "editor",
+ });
+
+ // Collaborator tries to edit owner's bookmark
+ await expect(
+ collaboratorApi.bookmarks.updateBookmark({
+ bookmarkId: bookmark.id,
+ title: "Modified title",
+ }),
+ ).rejects.toThrow("User is not allowed to access resource");
+ });
+
+ test<CustomTestContext>("should not allow collaborator to delete bookmark they don't own", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Shared List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const bookmark = await ownerApi.bookmarks.createBookmark({
+ type: BookmarkTypes.TEXT,
+ text: "Owner's bookmark",
+ });
+
+ await ownerApi.lists.addToList({
+ listId: list.id,
+ bookmarkId: bookmark.id,
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "editor",
+ });
+
+ // Collaborator tries to delete owner's bookmark
+ await expect(
+ collaboratorApi.bookmarks.deleteBookmark({
+ bookmarkId: bookmark.id,
+ }),
+ ).rejects.toThrow("User is not allowed to access resource");
+ });
+ });
+
+ describe("List Management Permissions", () => {
+ test<CustomTestContext>("should not allow collaborator to edit list metadata", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Shared List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "editor",
+ });
+
+ // Collaborator tries to edit list
+ await expect(
+ collaboratorApi.lists.edit({
+ listId: list.id,
+ name: "Modified Name",
+ }),
+ ).rejects.toThrow("User is not allowed to manage this list");
+ });
+
+ test<CustomTestContext>("should not allow collaborator to delete list", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Shared List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "editor",
+ });
+
+ // Collaborator tries to delete list
+ await expect(
+ collaboratorApi.lists.delete({
+ listId: list.id,
+ }),
+ ).rejects.toThrow("User is not allowed to manage this list");
+ });
+
+ test<CustomTestContext>("should not allow collaborator to manage other collaborators", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+ const thirdUserApi = apiCallers[2];
+
+ const list = await ownerApi.lists.create({
+ name: "Shared List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "editor",
+ });
+
+ const thirdUserEmail = (await thirdUserApi.users.whoami()).email!;
+
+ // Collaborator tries to add another user
+ await expect(
+ collaboratorApi.lists.addCollaborator({
+ listId: list.id,
+ email: thirdUserEmail,
+ role: "viewer",
+ }),
+ ).rejects.toThrow("User is not allowed to manage this list");
+ });
+
+ test<CustomTestContext>("should only allow collaborators to view collaborator list", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+ const thirdUserApi = apiCallers[2];
+
+ const list = await ownerApi.lists.create({
+ name: "Shared List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // Collaborator can view collaborators
+ const { collaborators, owner } =
+ await collaboratorApi.lists.getCollaborators({
+ listId: list.id,
+ });
+
+ expect(collaborators).toHaveLength(1);
+ expect(owner).toBeDefined();
+
+ // Non-collaborator cannot view
+ await expect(
+ thirdUserApi.lists.getCollaborators({
+ listId: list.id,
+ }),
+ ).rejects.toThrow("List not found");
+ });
+ });
+
+ describe("Access After Removal", () => {
+ test<CustomTestContext>("should revoke access after removing collaborator", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Shared List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const bookmark = await ownerApi.bookmarks.createBookmark({
+ type: BookmarkTypes.TEXT,
+ text: "Shared bookmark",
+ });
+
+ await ownerApi.lists.addToList({
+ listId: list.id,
+ bookmarkId: bookmark.id,
+ });
+
+ const collaboratorUser = await collaboratorApi.users.whoami();
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorUser.email!,
+ role: "viewer",
+ });
+
+ // Verify collaborator has access to list
+ const bookmarksBefore = await collaboratorApi.bookmarks.getBookmarks({
+ listId: list.id,
+ });
+ expect(bookmarksBefore.bookmarks).toHaveLength(1);
+
+ // Verify collaborator has access to individual bookmark
+ const bookmarkBefore = await collaboratorApi.bookmarks.getBookmark({
+ bookmarkId: bookmark.id,
+ });
+ expect(bookmarkBefore.id).toBe(bookmark.id);
+
+ // Remove collaborator
+ await ownerApi.lists.removeCollaborator({
+ listId: list.id,
+ userId: collaboratorUser.id,
+ });
+
+ // Verify list access is revoked
+ await expect(
+ collaboratorApi.bookmarks.getBookmarks({
+ listId: list.id,
+ }),
+ ).rejects.toThrow("List not found");
+
+ // Verify bookmark access is revoked
+ await expect(
+ collaboratorApi.bookmarks.getBookmark({
+ bookmarkId: bookmark.id,
+ }),
+ ).rejects.toThrow("Bookmark not found");
+ });
+
+ test<CustomTestContext>("should revoke access after leaving list", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Shared List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // Collaborator leaves
+ await collaboratorApi.lists.leaveList({
+ listId: list.id,
+ });
+
+ // Verify access is revoked
+ await expect(
+ collaboratorApi.lists.get({
+ listId: list.id,
+ }),
+ ).rejects.toThrow("List not found");
+ });
+ });
+
+ describe("Smart Lists", () => {
+ test<CustomTestContext>("should not allow adding collaborators to smart lists", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Smart List",
+ icon: "🔍",
+ type: "smart",
+ query: "is:fav",
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+
+ await expect(
+ ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ }),
+ ).rejects.toThrow("Only manual lists can have collaborators");
+ });
+ });
+
+ describe("List Operations Privacy", () => {
+ test<CustomTestContext>("should not allow collaborator to merge lists", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list1 = await ownerApi.lists.create({
+ name: "List 1",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const list2 = await ownerApi.lists.create({
+ name: "List 2",
+ icon: "📖",
+ type: "manual",
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+ await ownerApi.lists.addCollaborator({
+ listId: list1.id,
+ email: collaboratorEmail,
+ role: "editor",
+ });
+ await ownerApi.lists.addCollaborator({
+ listId: list2.id,
+ email: collaboratorEmail,
+ role: "editor",
+ });
+
+ // Collaborator tries to merge the shared list into another list
+ await expect(
+ collaboratorApi.lists.merge({
+ sourceId: list1.id,
+ targetId: list2.id,
+ deleteSourceAfterMerge: false,
+ }),
+ ).rejects.toThrow("User is not allowed to manage this list");
+ });
+
+ test<CustomTestContext>("should not allow collaborator to access RSS token operations", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Shared List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "editor",
+ });
+
+ // Collaborator tries to generate RSS token
+ await expect(
+ collaboratorApi.lists.regenRssToken({
+ listId: list.id,
+ }),
+ ).rejects.toThrow("User is not allowed to manage this list");
+
+ // Collaborator tries to get RSS token
+ await expect(
+ collaboratorApi.lists.getRssToken({
+ listId: list.id,
+ }),
+ ).rejects.toThrow("User is not allowed to manage this list");
+
+ // Owner generates token first
+ await ownerApi.lists.regenRssToken({
+ listId: list.id,
+ });
+
+ // Collaborator tries to clear RSS token
+ await expect(
+ collaboratorApi.lists.clearRssToken({
+ listId: list.id,
+ }),
+ ).rejects.toThrow("User is not allowed to manage this list");
+ });
+
+ test<CustomTestContext>("should not allow collaborator to access getListsOfBookmark for bookmark they don't own", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Shared List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const bookmark = await ownerApi.bookmarks.createBookmark({
+ type: BookmarkTypes.TEXT,
+ text: "Owner's bookmark",
+ });
+
+ await ownerApi.lists.addToList({
+ listId: list.id,
+ bookmarkId: bookmark.id,
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // Collaborator cannot use getListsOfBookmark for owner's bookmark
+ // This is expected - only bookmark owners can query which lists contain their bookmarks
+ await expect(
+ collaboratorApi.lists.getListsOfBookmark({
+ bookmarkId: bookmark.id,
+ }),
+ ).rejects.toThrow();
+ });
+
+ test<CustomTestContext>("should allow collaborator to use getListsOfBookmark for their own bookmarks in shared lists", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Shared List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "editor",
+ });
+
+ // Collaborator creates their own bookmark and adds it to the shared list
+ const bookmark = await collaboratorApi.bookmarks.createBookmark({
+ type: BookmarkTypes.TEXT,
+ text: "Collaborator's bookmark",
+ });
+
+ await collaboratorApi.lists.addToList({
+ listId: list.id,
+ bookmarkId: bookmark.id,
+ });
+
+ // Collaborator can see the shared list in getListsOfBookmark for their own bookmark
+ const { lists } = await collaboratorApi.lists.getListsOfBookmark({
+ bookmarkId: bookmark.id,
+ });
+
+ expect(lists).toHaveLength(1);
+ expect(lists[0].id).toBe(list.id);
+ expect(lists[0].userRole).toBe("editor");
+ expect(lists[0].hasCollaborators).toBe(true);
+ });
+
+ test<CustomTestContext>("should show hasCollaborators=true for owner when their bookmark is in a list with collaborators", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ // Owner creates a list
+ const list = await ownerApi.lists.create({
+ name: "Shared List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ // Owner creates and adds a bookmark
+ const bookmark = await ownerApi.bookmarks.createBookmark({
+ type: BookmarkTypes.TEXT,
+ text: "Owner's bookmark",
+ });
+
+ await ownerApi.lists.addToList({
+ listId: list.id,
+ bookmarkId: bookmark.id,
+ });
+
+ // Add a collaborator
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // Owner queries which lists contain their bookmark
+ const { lists } = await ownerApi.lists.getListsOfBookmark({
+ bookmarkId: bookmark.id,
+ });
+
+ expect(lists).toHaveLength(1);
+ expect(lists[0].id).toBe(list.id);
+ expect(lists[0].userRole).toBe("owner");
+ expect(lists[0].hasCollaborators).toBe(true);
+ });
+
+ test<CustomTestContext>("should show hasCollaborators=false for owner when their bookmark is in a list without collaborators", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+
+ // Owner creates a list
+ const list = await ownerApi.lists.create({
+ name: "Private List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ // Owner creates and adds a bookmark
+ const bookmark = await ownerApi.bookmarks.createBookmark({
+ type: BookmarkTypes.TEXT,
+ text: "Owner's bookmark",
+ });
+
+ await ownerApi.lists.addToList({
+ listId: list.id,
+ bookmarkId: bookmark.id,
+ });
+
+ // Owner queries which lists contain their bookmark
+ const { lists } = await ownerApi.lists.getListsOfBookmark({
+ bookmarkId: bookmark.id,
+ });
+
+ expect(lists).toHaveLength(1);
+ expect(lists[0].id).toBe(list.id);
+ expect(lists[0].userRole).toBe("owner");
+ expect(lists[0].hasCollaborators).toBe(false);
+ });
+
+ test<CustomTestContext>("should include shared lists in stats", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Shared List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ // Add bookmarks to the list
+ const bookmark1 = await ownerApi.bookmarks.createBookmark({
+ type: BookmarkTypes.TEXT,
+ text: "Bookmark 1",
+ });
+ const bookmark2 = await ownerApi.bookmarks.createBookmark({
+ type: BookmarkTypes.TEXT,
+ text: "Bookmark 2",
+ });
+
+ await ownerApi.lists.addToList({
+ listId: list.id,
+ bookmarkId: bookmark1.id,
+ });
+ await ownerApi.lists.addToList({
+ listId: list.id,
+ bookmarkId: bookmark2.id,
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // Collaborator gets stats
+ const { stats } = await collaboratorApi.lists.stats();
+
+ // Shared list should appear in stats with correct count
+ expect(stats.get(list.id)).toBe(2);
+ });
+
+ test<CustomTestContext>("should allow editor to add their own bookmark to shared list via addToList", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const editorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Shared List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const editorEmail = (await editorApi.users.whoami()).email!;
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: editorEmail,
+ role: "editor",
+ });
+
+ // Editor creates their own bookmark
+ const bookmark = await editorApi.bookmarks.createBookmark({
+ type: BookmarkTypes.TEXT,
+ text: "Editor's bookmark",
+ });
+
+ // Editor should be able to add their bookmark to the shared list
+ await editorApi.lists.addToList({
+ listId: list.id,
+ bookmarkId: bookmark.id,
+ });
+
+ // Verify bookmark was added
+ const bookmarks = await ownerApi.bookmarks.getBookmarks({
+ listId: list.id,
+ });
+
+ expect(bookmarks.bookmarks).toHaveLength(1);
+ expect(bookmarks.bookmarks[0].id).toBe(bookmark.id);
+ });
+
+ test<CustomTestContext>("should not allow viewer to add their own bookmark to shared list via addToList", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const viewerApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Shared List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const viewerEmail = (await viewerApi.users.whoami()).email!;
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: viewerEmail,
+ role: "viewer",
+ });
+
+ // Viewer creates their own bookmark
+ const bookmark = await viewerApi.bookmarks.createBookmark({
+ type: BookmarkTypes.TEXT,
+ text: "Viewer's bookmark",
+ });
+
+ // Viewer should not be able to add their bookmark to the shared list
+ await expect(
+ viewerApi.lists.addToList({
+ listId: list.id,
+ bookmarkId: bookmark.id,
+ }),
+ ).rejects.toThrow();
+ });
+
+ test<CustomTestContext>("should not allow editor to add someone else's bookmark to shared list", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const editorApi = apiCallers[1];
+ const thirdUserApi = apiCallers[2];
+
+ const list = await ownerApi.lists.create({
+ name: "Shared List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const editorEmail = (await editorApi.users.whoami()).email!;
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: editorEmail,
+ role: "editor",
+ });
+
+ // Third user creates a bookmark (or owner if only 2 users)
+ const bookmark = await thirdUserApi.bookmarks.createBookmark({
+ type: BookmarkTypes.TEXT,
+ text: "Someone else's bookmark",
+ });
+
+ // Editor should not be able to add someone else's bookmark
+ await expect(
+ editorApi.lists.addToList({
+ listId: list.id,
+ bookmarkId: bookmark.id,
+ }),
+ ).rejects.toThrow(/Bookmark not found/);
+ });
+
+ test<CustomTestContext>("should not allow collaborator to update list metadata fields", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const editorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Shared List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const editorEmail = (await editorApi.users.whoami()).email!;
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: editorEmail,
+ role: "editor",
+ });
+
+ // Editor tries to change list name
+ await expect(
+ editorApi.lists.edit({
+ listId: list.id,
+ name: "Modified Name",
+ }),
+ ).rejects.toThrow();
+
+ // Editor tries to make list public
+ await expect(
+ editorApi.lists.edit({
+ listId: list.id,
+ public: true,
+ }),
+ ).rejects.toThrow();
+ });
+ });
+
+ describe("hasCollaborators Field", () => {
+ test<CustomTestContext>("should be false for newly created list", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+
+ const list = await ownerApi.lists.create({
+ name: "New List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ expect(list.hasCollaborators).toBe(false);
+ });
+
+ test<CustomTestContext>("should be true for owner after adding a collaborator", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Shared List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // Fetch the list again to get updated hasCollaborators
+ const updatedList = await ownerApi.lists.get({
+ listId: list.id,
+ });
+
+ expect(updatedList.hasCollaborators).toBe(true);
+ });
+
+ test<CustomTestContext>("should be true for collaborator viewing shared list", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Shared List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // Collaborator fetches the list
+ const sharedList = await collaboratorApi.lists.get({
+ listId: list.id,
+ });
+
+ expect(sharedList.hasCollaborators).toBe(true);
+ });
+
+ test<CustomTestContext>("should be false for owner after removing all collaborators", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Shared List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const collaboratorUser = await collaboratorApi.users.whoami();
+
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorUser.email!,
+ role: "viewer",
+ });
+
+ // Remove the collaborator
+ await ownerApi.lists.removeCollaborator({
+ listId: list.id,
+ userId: collaboratorUser.id,
+ });
+
+ // Fetch the list again
+ const updatedList = await ownerApi.lists.get({
+ listId: list.id,
+ });
+
+ expect(updatedList.hasCollaborators).toBe(false);
+ });
+
+ test<CustomTestContext>("should show correct value in lists.list() endpoint", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ // Create list without collaborators
+ const list1 = await ownerApi.lists.create({
+ name: "Private List",
+ icon: "🔒",
+ type: "manual",
+ });
+
+ // Create list with collaborators
+ const list2 = await ownerApi.lists.create({
+ name: "Shared List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+
+ await ownerApi.lists.addCollaborator({
+ listId: list2.id,
+ email: collaboratorEmail,
+ role: "viewer",
+ });
+
+ // Get all lists
+ const { lists } = await ownerApi.lists.list();
+
+ const privateList = lists.find((l) => l.id === list1.id);
+ const sharedList = lists.find((l) => l.id === list2.id);
+
+ expect(privateList?.hasCollaborators).toBe(false);
+ expect(sharedList?.hasCollaborators).toBe(true);
+ });
+
+ test<CustomTestContext>("should show true for collaborator in lists.list() endpoint", async ({
+ apiCallers,
+ }) => {
+ const ownerApi = apiCallers[0];
+ const collaboratorApi = apiCallers[1];
+
+ const list = await ownerApi.lists.create({
+ name: "Shared List",
+ icon: "📚",
+ type: "manual",
+ });
+
+ const collaboratorEmail = (await collaboratorApi.users.whoami()).email!;
+
+ await ownerApi.lists.addCollaborator({
+ listId: list.id,
+ email: collaboratorEmail,
+ role: "editor",
+ });
+
+ // Collaborator gets all lists
+ const { lists } = await collaboratorApi.lists.list();
+
+ const sharedList = lists.find((l) => l.id === list.id);
+
+ expect(sharedList?.hasCollaborators).toBe(true);
+ expect(sharedList?.userRole).toBe("editor");
+ });
+ });
+});
diff --git a/packages/trpc/testUtils.ts b/packages/trpc/testUtils.ts
index b8fe3c30..744b85af 100644
--- a/packages/trpc/testUtils.ts
+++ b/packages/trpc/testUtils.ts
@@ -24,6 +24,10 @@ export async function seedUsers(db: TestDB) {
name: "Test User 2",
email: "test2@test.com",
},
+ {
+ name: "Test User 3",
+ email: "test3@test.com",
+ },
])
.returning();
}