diff options
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(); } |
