diff --git a/.env.example b/.env.example index 0368021805..4e5f8d9129 100644 --- a/.env.example +++ b/.env.example @@ -53,6 +53,9 @@ POSTMARK_TOKEN= # for custom drawio server DRAWIO_URL= +# Gotenberg URL for server-side PDF export +GOTENBERG_URL= + DISABLE_TELEMETRY=false # Enable debug logging in production (default: false) diff --git a/apps/client/package.json b/apps/client/package.json index ac3e3dc28c..479e75a958 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -1,7 +1,7 @@ { "name": "client", "private": true, - "version": "0.71.0", + "version": "0.80.0", "scripts": { "dev": "vite", "build": "tsc && vite build", @@ -25,7 +25,7 @@ "@tabler/icons-react": "^3.40.0", "@tanstack/react-query": "5.90.17", "alfaaz": "^1.1.0", - "axios": "1.13.6", + "axios": "1.15.0", "blueimp-load-image": "^5.16.0", "clsx": "^2.1.1", "emoji-mart": "^5.6.0", @@ -70,7 +70,7 @@ "@types/node": "22.19.1", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", - "@vitejs/plugin-react": "^6.0.0", + "@vitejs/plugin-react": "^6.0.1", "eslint": "^9.28.0", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", @@ -83,6 +83,6 @@ "prettier": "^3.8.1", "typescript": "^5.9.3", "typescript-eslint": "^8.57.1", - "vite": "^8.0.1" + "vite": "8.0.5" } } diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index 5a58351451..28f7563f59 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -22,6 +22,7 @@ import PasswordReset from "./pages/auth/password-reset"; import OidcCallbackPage from "@/pages/auth/oidc-callback.tsx"; import { isCloud } from "@/lib/config.ts"; import SharedPage from "@/pages/share/shared-page.tsx"; +import PdfRenderPage from "@/ee/pdf-export/pdf-render-page.tsx"; import Shares from "@/pages/settings/shares/shares.tsx"; import ShareLayout from "@/features/share/components/share-layout.tsx"; import ShareRedirect from "@/pages/share/share-redirect.tsx"; @@ -62,12 +63,21 @@ export default function App() { } /> + } /> } /> } /> }> } /> + } /> + } /> } /> + } /> + } /> + } + /> } /> } /> } /> diff --git a/apps/client/src/components/common/copy.tsx b/apps/client/src/components/common/copy.tsx index 81a7077114..745fc4ba66 100644 --- a/apps/client/src/components/common/copy.tsx +++ b/apps/client/src/components/common/copy.tsx @@ -1,4 +1,4 @@ -import { ActionIcon, Tooltip } from "@mantine/core"; +import { ActionIcon, MantineColor, MantineSize, Tooltip } from "@mantine/core"; import { CopyButton } from "@/components/common/copy-button"; import { IconCheck, IconCopy } from "@tabler/icons-react"; import React from "react"; @@ -6,8 +6,10 @@ import { useTranslation } from "react-i18next"; interface CopyProps { text: string; + size?: MantineSize; + color?: MantineColor; } -export default function CopyTextButton({ text }: CopyProps) { +export default function CopyTextButton({ text, size }: CopyProps) { const { t } = useTranslation(); return ( @@ -22,6 +24,7 @@ export default function CopyTextButton({ text }: CopyProps) { color={copied ? "teal" : "gray"} variant="subtle" onClick={copy} + size={size} > {copied ? : } diff --git a/apps/client/src/components/common/recent-changes.tsx b/apps/client/src/components/common/recent-changes.tsx index b0bdfec7d7..277ceb8113 100644 --- a/apps/client/src/components/common/recent-changes.tsx +++ b/apps/client/src/components/common/recent-changes.tsx @@ -5,6 +5,7 @@ import { Badge, Table, ActionIcon, + Button, } from "@mantine/core"; import { Link } from "react-router-dom"; import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx"; @@ -23,7 +24,8 @@ interface Props { export default function RecentChanges({ spaceId }: Props) { const { t } = useTranslation(); - const { data: pages, isLoading, isError } = useRecentChangesQuery(spaceId); + const { data, isLoading, isError, hasNextPage, fetchNextPage, isFetchingNextPage } = useRecentChangesQuery(spaceId); + const pages = data?.pages.flatMap((p) => p.items) ?? []; if (isLoading) { return ; @@ -33,58 +35,72 @@ export default function RecentChanges({ spaceId }: Props) { return {t("Failed to fetch recent pages")}; } - return pages && pages.items.length > 0 ? ( - - - - {pages.items.map((page) => ( - - - - - {page.icon || ( - - - - )} - - - {page.title || t("Untitled")} - - - - - {!spaceId && ( + return pages.length > 0 ? ( + <> + +
+ + {pages.map((page) => ( + - + + {page.icon || ( + + + + )} + + + {page.title || t("Untitled")} + + + + + {!spaceId && ( + + + {page?.space.name} + + + )} + + - {page?.space.name} - + {formattedDate(page.updatedAt)} + - )} - - - {formattedDate(page.updatedAt)} - - - - ))} - -
-
+ + ))} + + + + {hasNextPage && ( + + )} + ) : ( ( @@ -47,29 +58,25 @@ export function AppHeader() { <> - {!hideSidebar && ( - <> - - - + + + - - - - - )} + + + + {aiChatEnabled && ( + <> + { + if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) { + return; + } + if (isPageRoute) { + e.preventDefault(); + toggleAside("chat"); + } + }} + > + {t("AI Chat")} + + + { + if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) { + return; + } + if (isPageRoute) { + e.preventDefault(); + toggleAside("chat"); + } + }} + > + + + + + )} diff --git a/apps/client/src/components/layouts/global/aside.tsx b/apps/client/src/components/layouts/global/aside.tsx index 324bf3bfb4..4f2cf5927d 100644 --- a/apps/client/src/components/layouts/global/aside.tsx +++ b/apps/client/src/components/layouts/global/aside.tsx @@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next"; import { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx"; import { useAtomValue } from "jotai"; import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts"; +import AsideChatPanel from "@/ee/ai-chat/components/aside-chat-panel"; export default function Aside() { const [{ tab }] = useAtom(asideStateAtom); @@ -25,6 +26,10 @@ export default function Aside() { component = ; title = "Table of contents"; break; + case "chat": + component = ; + title = "AI Chat"; + break; default: component = null; title = null; @@ -34,12 +39,14 @@ export default function Aside() { {component && ( <> - - {t(title)} - + {tab !== "chat" && ( + + {t(title)} + + )} - {tab === "comments" ? ( - + {tab === "comments" || tab === "chat" ? ( + component ) : ( - {!hideSidebar && ( - + + {isSpaceRoute && (
- {isSpaceRoute && } - {isSettingsRoute && } - - )} + )} + {isSpaceRoute && } + {isSettingsRoute && } + {isAiRoute && } + {showGlobalSidebar && } + {isSettingsRoute ? ( - {children} + {children} ) : ( children )} diff --git a/apps/client/src/components/layouts/global/global-sidebar.module.css b/apps/client/src/components/layouts/global/global-sidebar.module.css new file mode 100644 index 0000000000..37cb60b6f1 --- /dev/null +++ b/apps/client/src/components/layouts/global/global-sidebar.module.css @@ -0,0 +1,89 @@ +.navbar { + height: 100%; + width: 100%; + padding: var(--mantine-spacing-md); + display: flex; + flex-direction: column; +} + +.section { + padding-bottom: var(--mantine-spacing-xs); +} + +.link { + cursor: pointer; + display: flex; + align-items: center; + text-decoration: none; + font-size: var(--mantine-font-size-sm); + color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1)); + padding-left: var(--mantine-spacing-xs); + min-height: 30px; + border-radius: var(--mantine-radius-sm); + font-weight: 500; + user-select: none; + + @mixin hover { + background-color: light-dark( + var(--mantine-color-gray-1), + var(--mantine-color-dark-6) + ); + color: light-dark(var(--mantine-color-black), var(--mantine-color-white)); + } + + &[data-active] { + &, + & :hover { + background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6)); + color: light-dark(var(--mantine-color-black), var(--mantine-color-white)); + } + } +} + +.linkIcon { + color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2)); + margin-right: var(--mantine-spacing-sm); + width: rem(16px); + height: rem(16px); +} + +.sectionHeader { + padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm); + font-size: var(--mantine-font-size-xs); + color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-3)); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.spacer { + flex: 1; +} + +.bottomSection { + padding-top: var(--mantine-spacing-xs); + border-top: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); +} + +.spaceItem { + cursor: pointer; + display: flex; + align-items: center; + gap: var(--mantine-spacing-sm); + text-decoration: none; + font-size: var(--mantine-font-size-sm); + color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1)); + padding-left: var(--mantine-spacing-xs); + min-height: 30px; + border-radius: var(--mantine-radius-sm); + font-weight: 500; + user-select: none; + + @mixin hover { + background-color: light-dark( + var(--mantine-color-gray-1), + var(--mantine-color-dark-6) + ); + color: light-dark(var(--mantine-color-black), var(--mantine-color-white)); + } +} diff --git a/apps/client/src/components/layouts/global/global-sidebar.tsx b/apps/client/src/components/layouts/global/global-sidebar.tsx new file mode 100644 index 0000000000..a46974aed6 --- /dev/null +++ b/apps/client/src/components/layouts/global/global-sidebar.tsx @@ -0,0 +1,158 @@ +import { useEffect, useState } from "react"; +import { ScrollArea, Text, Divider, Modal } from "@mantine/core"; +import { + IconHome, + IconClock, + IconStar, + IconLayoutGrid, + IconSettings, + IconUserPlus, +} from "@tabler/icons-react"; +import { Link, useLocation } from "react-router-dom"; +import classes from "./global-sidebar.module.css"; +import { useTranslation } from "react-i18next"; +import { useAtom } from "jotai"; +import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom"; +import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar"; +import { useFavoritesQuery } from "@/features/favorite/queries/favorite-query"; +import { getSpaceUrl } from "@/lib/config"; +import { useDisclosure } from "@mantine/hooks"; +import { WorkspaceInviteForm } from "@/features/workspace/components/members/components/workspace-invite-form"; +import { CustomAvatar } from "@/components/ui/custom-avatar"; +import { AvatarIconType } from "@/features/attachments/types/attachment.types"; + +const mainNavItems = [ + { label: "Home", icon: IconHome, path: "/home" }, + { label: "Favorites", icon: IconStar, path: "/favorites" }, + { label: "Spaces", icon: IconLayoutGrid, path: "/spaces" }, +]; + +export default function GlobalSidebar() { + const { t } = useTranslation(); + const location = useLocation(); + const [active, setActive] = useState(location.pathname); + const [mobileSidebarOpened] = useAtom(mobileSidebarAtom); + const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom); + const { data: favoriteSpacesData, isPending: isFavoritesPending } = useFavoritesQuery("space"); + const favoriteSpaces = favoriteSpacesData?.pages.flatMap((p) => p.items) ?? []; + const sortedFavoriteSpaces = [...favoriteSpaces] + .filter((fav) => fav.space) + .sort((a, b) => { + const cmp = (a.space!.name ?? "").localeCompare(b.space!.name ?? "", undefined, { sensitivity: "base" }); + return cmp !== 0 ? cmp : a.id.localeCompare(b.id); + }); + const [inviteOpened, { open: openInvite, close: closeInvite }] = + useDisclosure(false); + + useEffect(() => { + setActive(location.pathname); + }, [location.pathname]); + + const handleNavClick = () => { + if (mobileSidebarOpened) { + toggleMobileSidebar(); + } + }; + + return ( +
+ +
+ {mainNavItems.map((item) => ( + + + {t(item.label)} + + ))} +
+ + +
+ {t("Favorite spaces")} + {!isFavoritesPending && sortedFavoriteSpaces.length === 0 ? ( + + {t("Favorite spaces appear here")} + + ) : ( + <> + {sortedFavoriteSpaces.slice(0, 10).map((fav) => ( + + + + {fav.space!.name} + + + ))} + {sortedFavoriteSpaces.length > 10 && ( + + + {t("View all")} + + + )} + + )} +
+ +
+ + + + + + + + + +
+ ); +} diff --git a/apps/client/src/components/settings/settings-sidebar.tsx b/apps/client/src/components/settings/settings-sidebar.tsx index 7634fb076d..57e864d6bc 100644 --- a/apps/client/src/components/settings/settings-sidebar.tsx +++ b/apps/client/src/components/settings/settings-sidebar.tsx @@ -84,6 +84,12 @@ const groupedData: DataGroup[] = [ { label: "Groups", icon: IconUsersGroup, path: "/settings/groups" }, { label: "Spaces", icon: IconSpaces, path: "/settings/spaces" }, { label: "Public sharing", icon: IconWorld, path: "/settings/sharing" }, + { + label: "Verified pages", + icon: IconShieldCheck, + path: "/settings/verifications", + feature: Feature.PAGE_VERIFICATION, + }, { label: "API management", icon: IconKey, diff --git a/apps/client/src/components/ui/card-carousel.module.css b/apps/client/src/components/ui/card-carousel.module.css new file mode 100644 index 0000000000..4ec513f234 --- /dev/null +++ b/apps/client/src/components/ui/card-carousel.module.css @@ -0,0 +1,68 @@ +.root { + position: relative; +} + +.track { + display: flex; + gap: var(--mantine-spacing-md); + overflow-x: auto; + scroll-snap-type: x mandatory; + scroll-behavior: smooth; + scrollbar-width: none; + -ms-overflow-style: none; + padding: 2px; + margin: -2px; +} + +.track::-webkit-scrollbar { + display: none; +} + +.track > * { + scroll-snap-align: start; + flex: 0 0 auto; +} + +.arrow { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); + background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6)); + color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1)); + cursor: pointer; + padding: 0; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + opacity: 0; + pointer-events: none; + transition: opacity 120ms ease, background-color 120ms ease, transform 120ms ease; + z-index: 2; +} + +.root:hover .arrow.visible, +.arrow.visible:focus-visible { + opacity: 1; + pointer-events: auto; +} + +.arrow:hover { + background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5)); +} + +.arrow:active { + transform: translateY(-50%) scale(0.95); +} + +.arrowLeft { + left: -14px; +} + +.arrowRight { + right: -14px; +} diff --git a/apps/client/src/components/ui/card-carousel.tsx b/apps/client/src/components/ui/card-carousel.tsx new file mode 100644 index 0000000000..60d13abaf1 --- /dev/null +++ b/apps/client/src/components/ui/card-carousel.tsx @@ -0,0 +1,77 @@ +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import classes from "./card-carousel.module.css"; + +type Props = { + children: ReactNode; + ariaLabel?: string; +}; + +export default function CardCarousel({ children, ariaLabel }: Props) { + const { t } = useTranslation(); + const trackRef = useRef(null); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(false); + + const updateScrollState = useCallback(() => { + const el = trackRef.current; + if (!el) return; + const maxScroll = el.scrollWidth - el.clientWidth; + setCanScrollLeft(el.scrollLeft > 1); + setCanScrollRight(el.scrollLeft < maxScroll - 1); + }, []); + + useEffect(() => { + updateScrollState(); + const el = trackRef.current; + if (!el) return; + + const observer = new ResizeObserver(updateScrollState); + observer.observe(el); + for (const child of Array.from(el.children)) { + observer.observe(child); + } + + return () => observer.disconnect(); + }, [updateScrollState, children]); + + const scrollBy = (direction: 1 | -1) => { + const el = trackRef.current; + if (!el) return; + el.scrollBy({ left: direction * el.clientWidth * 0.85, behavior: "smooth" }); + }; + + return ( +
+
+ {children} +
+ + + + +
+ ); +} diff --git a/apps/client/src/components/ui/destination-picker/destination-picker-modal.tsx b/apps/client/src/components/ui/destination-picker/destination-picker-modal.tsx new file mode 100644 index 0000000000..04e9ef7dd9 --- /dev/null +++ b/apps/client/src/components/ui/destination-picker/destination-picker-modal.tsx @@ -0,0 +1,67 @@ +import { useState, useEffect } from "react"; +import { Modal, Button, Group } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { DestinationPicker } from "./destination-picker"; +import { + DestinationPickerModalProps, + DestinationSelection, +} from "./destination-picker.types"; + +export function DestinationPickerModal({ + opened, + onClose, + title, + actionLabel, + onSelect, + loading, + excludePageId, + pageLimit, +}: DestinationPickerModalProps) { + const { t } = useTranslation(); + const [selection, setSelection] = useState(null); + + useEffect(() => { + if (!opened) { + setSelection(null); + } + }, [opened]); + + return ( + e.stopPropagation()} + > + + + + {title} + + + + + + + + + + + + + ); +} diff --git a/apps/client/src/components/ui/destination-picker/destination-picker.module.css b/apps/client/src/components/ui/destination-picker/destination-picker.module.css new file mode 100644 index 0000000000..ec598ac504 --- /dev/null +++ b/apps/client/src/components/ui/destination-picker/destination-picker.module.css @@ -0,0 +1,128 @@ +.searchInput { + margin-bottom: var(--mantine-spacing-sm); +} + +.scrollArea { + max-height: 50vh; +} + +.row { + padding: 6px 12px; + border-radius: var(--mantine-radius-sm); + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + transition: background-color 150ms ease; + user-select: none; + + @mixin hover { + background-color: light-dark( + var(--mantine-color-gray-0), + var(--mantine-color-dark-6) + ); + } +} + +.selected { + background-color: light-dark( + var(--mantine-color-blue-0), + var(--mantine-color-dark-5) + ); + border-left: 2px solid var(--mantine-primary-color-filled); +} + +.spaceRow { + composes: row; + font-weight: 500; +} + +.pageRow { + composes: row; + font-weight: 400; +} + +.disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; +} + +.chevron { + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--mantine-radius-sm); + flex-shrink: 0; + transition: transform 150ms ease; + color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3)); + + @mixin hover { + background-color: light-dark( + var(--mantine-color-gray-1), + var(--mantine-color-dark-5) + ); + } +} + +.chevronExpanded { + transform: rotate(90deg); +} + +.loadMore { + text-align: center; + padding: 6px; + color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2)); + font-size: var(--mantine-font-size-sm); + cursor: pointer; + + @mixin hover { + text-decoration: underline; + } +} + +.selectedIndicator { + padding: 8px 12px; + font-size: var(--mantine-font-size-sm); + color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2)); + border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4)); + margin-top: var(--mantine-spacing-xs); +} + +.emptyState { + padding: 12px; + text-align: center; + font-size: var(--mantine-font-size-sm); + color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2)); +} + +.searchResult { + composes: row; +} + +.pageTitle { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--mantine-font-size-sm); +} + +.spaceName { + color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3)); + font-size: var(--mantine-font-size-xs); + flex-shrink: 0; +} + +.iconWrapper { + width: 22px; + height: 22px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + font-size: 16px; + line-height: 1; +} diff --git a/apps/client/src/components/ui/destination-picker/destination-picker.tsx b/apps/client/src/components/ui/destination-picker/destination-picker.tsx new file mode 100644 index 0000000000..1ddc747dd8 --- /dev/null +++ b/apps/client/src/components/ui/destination-picker/destination-picker.tsx @@ -0,0 +1,168 @@ +import { useState, useCallback } from "react"; +import { TextInput, ScrollArea, Loader } from "@mantine/core"; +import { useDebouncedValue } from "@mantine/hooks"; +import { IconSearch, IconFile } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { useGetSpacesQuery } from "@/features/space/queries/space-query"; +import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query"; +import { ISpace } from "@/features/space/types/space.types"; +import { IPage } from "@/features/page/types/page.types"; +import { DestinationSelection } from "./destination-picker.types"; +import { SpaceRow } from "./space-row"; +import classes from "./destination-picker.module.css"; + +type DestinationPickerProps = { + onSelectionChange: (selection: DestinationSelection | null) => void; + excludePageId?: string; + pageLimit?: number; +}; + +export function DestinationPicker({ + onSelectionChange, + excludePageId, + pageLimit = 15, +}: DestinationPickerProps) { + const { t } = useTranslation(); + const [searchQuery, setSearchQuery] = useState(""); + const [selection, setSelection] = useState(null); + const [debouncedQuery] = useDebouncedValue(searchQuery, 300); + + const { data: spacesData, isLoading: spacesLoading } = useGetSpacesQuery({ + limit: 100, + }); + + const searchEnabled = debouncedQuery && debouncedQuery.length >= 2; + + const { data: searchData, isLoading: searchLoading } = + useSearchSuggestionsQuery({ + query: searchEnabled ? debouncedQuery : "", + includePages: true, + limit: 20, + }); + + const isSearching = !!searchEnabled; + + const selectedId = + selection?.type === "space" ? selection.spaceId : selection?.pageId ?? null; + + const updateSelection = useCallback( + (next: DestinationSelection | null) => { + setSelection(next); + onSelectionChange(next); + }, + [onSelectionChange], + ); + + const handleSearchResultClick = (page: Partial) => { + if (!page.space || !page.id) return; + + updateSelection({ + type: "page", + spaceId: page.space.id, + pageId: page.id, + page, + space: page.space, + }); + setSearchQuery(""); + }; + + const handleSelectSpace = useCallback( + (space: ISpace) => { + updateSelection({ type: "space", spaceId: space.id, space }); + }, + [updateSelection], + ); + + const handleSelectPage = useCallback( + (page: Partial, space: ISpace) => { + if (!page.id) return; + updateSelection({ + type: "page", + spaceId: page.spaceId ?? space.id, + pageId: page.id, + page, + space, + }); + }, + [updateSelection], + ); + + return ( + <> + } + placeholder={t("Search pages and spaces...")} + variant="filled" + value={searchQuery} + onChange={(e) => setSearchQuery(e.currentTarget.value)} + className={classes.searchInput} + /> + + + {isSearching ? ( + searchLoading ? ( +
+ +
+ ) : searchData?.pages && searchData.pages.length > 0 ? ( + searchData.pages.map( + (page) => + page && ( +
handleSearchResultClick(page)} + > +
+ {page.icon ? ( + page.icon + ) : ( + + )} +
+
+ {page.title || t("Untitled")} +
+ {page.space && ( +
+ {page.space.name} +
+ )} +
+ ), + ) + ) : ( +
{t("No results found")}
+ ) + ) : spacesLoading ? ( +
+ +
+ ) : ( + spacesData?.items?.map((space) => ( + + )) + )} +
+ + {selection && ( +
+ {selection.type === "space" + ? selection.space.name + : `${selection.space.name} / ${selection.page.title || t("Untitled")}`} +
+ )} + + ); +} diff --git a/apps/client/src/components/ui/destination-picker/destination-picker.types.ts b/apps/client/src/components/ui/destination-picker/destination-picker.types.ts new file mode 100644 index 0000000000..005259b3a9 --- /dev/null +++ b/apps/client/src/components/ui/destination-picker/destination-picker.types.ts @@ -0,0 +1,23 @@ +import { ISpace } from "@/features/space/types/space.types"; +import { IPage } from "@/features/page/types/page.types"; + +export type DestinationSelection = + | { type: "space"; spaceId: string; space: ISpace } + | { + type: "page"; + spaceId: string; + pageId: string; + page: Partial; + space: Partial; + }; + +export type DestinationPickerModalProps = { + opened: boolean; + onClose: () => void; + title: string; + actionLabel: string; + onSelect: (selection: DestinationSelection) => void | Promise; + loading?: boolean; + excludePageId?: string; + pageLimit?: number; +}; diff --git a/apps/client/src/components/ui/destination-picker/page-children.tsx b/apps/client/src/components/ui/destination-picker/page-children.tsx new file mode 100644 index 0000000000..9db5fd7153 --- /dev/null +++ b/apps/client/src/components/ui/destination-picker/page-children.tsx @@ -0,0 +1,83 @@ +import { useInfiniteQuery } from "@tanstack/react-query"; +import { Loader } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { getSidebarPages } from "@/features/page/services/page-service"; +import { IPage } from "@/features/page/types/page.types"; +import { IPagination } from "@/lib/types"; +import { PageRow } from "./page-row"; +import classes from "./destination-picker.module.css"; + +type PageChildrenProps = { + spaceId: string; + pageId?: string; + depth: number; + limit: number; + selectedId: string | null; + excludePageId?: string; + onSelectPage: (page: Partial) => void; +}; + +export function PageChildren({ + spaceId, + pageId, + depth, + limit, + selectedId, + excludePageId, + onSelectPage, +}: PageChildrenProps) { + const { t } = useTranslation(); + + const { data, isLoading, hasNextPage, fetchNextPage } = useInfiniteQuery({ + queryKey: ["destination-pages", spaceId, pageId ?? "root"], + queryFn: ({ pageParam }) => + getSidebarPages({ + spaceId, + pageId, + limit, + cursor: pageParam, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage: IPagination) => + lastPage.meta?.nextCursor ?? undefined, + }); + + const pages = data?.pages.flatMap((page) => page.items) ?? []; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (pages.length === 0) { + return ( +
+ {pageId ? t("No pages inside") : t("No pages in this space")} +
+ ); + } + + return ( + <> + {pages.map((page) => ( + + ))} + {hasNextPage && ( +
fetchNextPage()}> + {t("Load more")} +
+ )} + + ); +} diff --git a/apps/client/src/components/ui/destination-picker/page-row.tsx b/apps/client/src/components/ui/destination-picker/page-row.tsx new file mode 100644 index 0000000000..58c7a0a87e --- /dev/null +++ b/apps/client/src/components/ui/destination-picker/page-row.tsx @@ -0,0 +1,89 @@ +import { useState } from "react"; +import { IconChevronRight, IconFile } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { IPage } from "@/features/page/types/page.types"; +import { PageChildren } from "./page-children"; +import classes from "./destination-picker.module.css"; + +type PageRowProps = { + page: Partial; + depth: number; + limit: number; + selectedId: string | null; + excludePageId?: string; + onSelect: (page: Partial) => void; +}; + +export function PageRow({ + page, + depth, + limit, + selectedId, + excludePageId, + onSelect, +}: PageRowProps) { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); + + const isExcluded = page.id === excludePageId; + const isSelected = page.id === selectedId; + + const rowClasses = [ + classes.pageRow, + isSelected && classes.selected, + isExcluded && classes.disabled, + ] + .filter(Boolean) + .join(" "); + + return ( + <> +
!isExcluded && onSelect(page)} + > + {page.hasChildren ? ( +
{ + e.stopPropagation(); + setExpanded(!expanded); + }} + > + +
+ ) : ( +
+ )} + +
+ {page.icon ? ( + page.icon + ) : ( + + )} +
+ +
+ {page.title || t("Untitled")} +
+
+ + {expanded && page.hasChildren && ( + + )} + + ); +} diff --git a/apps/client/src/components/ui/destination-picker/space-row.tsx b/apps/client/src/components/ui/destination-picker/space-row.tsx new file mode 100644 index 0000000000..59273af7e8 --- /dev/null +++ b/apps/client/src/components/ui/destination-picker/space-row.tsx @@ -0,0 +1,108 @@ +import { useState } from "react"; +import { Tooltip } from "@mantine/core"; +import { IconChevronRight, IconLock } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { ISpace } from "@/features/space/types/space.types"; +import { IPage } from "@/features/page/types/page.types"; +import { SpaceRole } from "@/lib/types"; +import { CustomAvatar } from "@/components/ui/custom-avatar"; +import { AvatarIconType } from "@/features/attachments/types/attachment.types"; +import { PageChildren } from "./page-children"; +import classes from "./destination-picker.module.css"; + +type SpaceRowProps = { + space: ISpace; + limit: number; + selectedId: string | null; + excludePageId?: string; + onSelectSpace: (space: ISpace) => void; + onSelectPage: (page: Partial, space: ISpace) => void; +}; + +export function SpaceRow({ + space, + limit, + selectedId, + excludePageId, + onSelectSpace, + onSelectPage, +}: SpaceRowProps) { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); + + const writable = + !!space.membership?.role && space.membership.role !== SpaceRole.READER; + const isSelected = space.id === selectedId; + + const rowClasses = [ + classes.spaceRow, + isSelected && classes.selected, + !writable && classes.disabled, + ] + .filter(Boolean) + .join(" "); + + const rowContent = ( +
writable && onSelectSpace(space)} + > + {writable ? ( +
{ + e.stopPropagation(); + setExpanded(!expanded); + }} + > + +
+ ) : ( +
+ )} + + + +
{space.name}
+ + {!writable && ( + + )} +
+ ); + + return ( + <> + {writable ? ( + rowContent + ) : ( + +
{rowContent}
+
+ )} + + {expanded && writable && ( + onSelectPage(page, space)} + /> + )} + + ); +} diff --git a/apps/client/src/features/editor/components/mention/mention-list.tsx b/apps/client/src/features/editor/components/mention/mention-list.tsx index 58ca07b67c..2e53dea9d5 100644 --- a/apps/client/src/features/editor/components/mention/mention-list.tsx +++ b/apps/client/src/features/editor/components/mention/mention-list.tsx @@ -62,7 +62,7 @@ const MentionList = forwardRef((props, ref) => { query: props.query, includeUsers: true, includePages: true, - spaceId: space.id, + spaceId: space?.id, limit: props.query ? 10 : 5, preload: true, }); diff --git a/apps/client/src/features/editor/components/mention/mention-suggestion.ts b/apps/client/src/features/editor/components/mention/mention-suggestion.ts index 658fd182d5..894e720453 100644 --- a/apps/client/src/features/editor/components/mention/mention-suggestion.ts +++ b/apps/client/src/features/editor/components/mention/mention-suggestion.ts @@ -53,8 +53,8 @@ const mentionRenderItems = () => { const editorDom = props.editor?.view?.dom; const asideEl = editorDom?.closest(".mantine-AppShell-aside"); const dialogEl = editorDom?.closest("[data-comment-dialog]"); - const isInCommentContext = !!(asideEl || dialogEl); - // const isInCommentContext = !!asideEl; + const chatInput = editorDom?.closest("[data-chat-input]"); + const isInCommentContext = !!(asideEl || dialogEl || chatInput); component = new ReactRenderer(MentionList, { props: { ...props, isInCommentContext }, diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.ts index 7f867ced36..559272e6e1 100644 --- a/apps/client/src/features/editor/components/slash-menu/menu-items.ts +++ b/apps/client/src/features/editor/components/slash-menu/menu-items.ts @@ -464,7 +464,7 @@ const CommandGroups: SlashMenuGroupedItemsType = { .run(), }, { - title: "Draw.io (diagrams.net) ", + title: "Draw.io (diagrams.net)", description: "Insert and design Drawio diagrams", searchTerms: ["drawio", "diagrams", "charts", "uml", "whiteboard"], icon: IconDrawio, @@ -742,8 +742,10 @@ const CommandGroups: SlashMenuGroupedItemsType = { export const getSuggestionItems = ({ query, + excludeItems, }: { query: string; + excludeItems?: Set; }): SlashMenuGroupedItemsType => { const search = query.toLowerCase(); const filteredGroups: SlashMenuGroupedItemsType = {}; @@ -760,6 +762,7 @@ export const getSuggestionItems = ({ for (const [group, items] of Object.entries(CommandGroups)) { const filteredItems = items.filter((item) => { + if (excludeItems?.has(item.title)) return false; return ( fuzzyMatch(search, item.title) || item.description.toLowerCase().includes(search) || diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index d1a264dc55..1a086f055b 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -153,6 +153,25 @@ export const mainExtensions = [ }), ]; }, + addKeyboardShortcuts() { + return { + Enter: ({ editor }) => { + const { from, to } = editor.state.selection; + if (from !== to) return false; + if (!editor.isActive("code")) return false; + + const $from = editor.state.doc.resolve(from); + const codeType = editor.state.schema.marks.code; + const nodeAfter = $from.nodeAfter; + + if (nodeAfter && codeType.isInSet(nodeAfter.marks)) { + return false; + } + + return editor.chain().unsetCode().splitBlock().run(); + }, + }; + }, }), SharedStorage, Heading, @@ -401,6 +420,27 @@ export const mainExtensions = [ type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[]; +const TEMPLATE_EXCLUDED_SLASH_ITEMS = new Set([ + "Image", + "Video", + "File attachment", + "Draw.io (diagrams.net)", + "Excalidraw diagram", +]); + +const TemplateSlashCommand = Command.configure({ + suggestion: { + items: ({ query }: { query: string }) => + getSuggestionItems({ query, excludeItems: TEMPLATE_EXCLUDED_SLASH_ITEMS }), + render: renderItems, + }, +}); + +export const templateExtensions = [ + ...mainExtensions.filter((ext: any) => ext !== SlashCommand), + TemplateSlashCommand, +] as any; + export const collabExtensions: CollabExtensions = (provider, user) => [ Collaboration.configure({ document: provider.document, diff --git a/apps/client/src/features/editor/extensions/slash-command.ts b/apps/client/src/features/editor/extensions/slash-command.ts index 160cb924dd..339f88cab0 100644 --- a/apps/client/src/features/editor/extensions/slash-command.ts +++ b/apps/client/src/features/editor/extensions/slash-command.ts @@ -47,4 +47,5 @@ const SlashCommand = Command.configure({ }, }); +export { Command as SlashCommandExtension }; export default SlashCommand; diff --git a/apps/client/src/features/editor/full-editor.tsx b/apps/client/src/features/editor/full-editor.tsx index 89eb7ca837..6ebb8669cd 100644 --- a/apps/client/src/features/editor/full-editor.tsx +++ b/apps/client/src/features/editor/full-editor.tsx @@ -2,13 +2,31 @@ import classes from "@/features/editor/styles/editor.module.css"; import React from "react"; import { TitleEditor } from "@/features/editor/title-editor"; import PageEditor from "@/features/editor/page-editor"; -import { Container } from "@mantine/core"; +import { + Container, + Divider, + Group, + Popover, + Stack, + Text, + UnstyledButton, +} from "@mantine/core"; import { useAtom } from "jotai"; import { userAtom } from "@/features/user/atoms/current-user-atom.ts"; +import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; +import { PageVerificationBadge } from "@/ee/page-verification"; +import { useTranslation } from "react-i18next"; +import { IContributor } from "@/features/page/types/page.types.ts"; const MemoizedTitleEditor = React.memo(TitleEditor); const MemoizedPageEditor = React.memo(PageEditor); +type PageCreator = { + id: string; + name: string; + avatarUrl: string; +}; + export interface FullEditorProps { pageId: string; slugId: string; @@ -16,6 +34,8 @@ export interface FullEditorProps { content: string; spaceSlug: string; editable: boolean; + creator?: PageCreator; + contributors?: IContributor[]; canComment?: boolean; } @@ -26,6 +46,8 @@ export function FullEditor({ content, spaceSlug, editable, + creator, + contributors, canComment, }: FullEditorProps) { const [user] = useAtom(userAtom); @@ -44,6 +66,11 @@ export function FullEditor({ spaceSlug={spaceSlug} editable={editable} /> + ); } + +type PageBylineProps = { + creator?: PageCreator; + contributors?: IContributor[]; + readOnly?: boolean; +}; + +function PageByline({ + creator, + contributors, + readOnly, +}: PageBylineProps) { + const { t } = useTranslation(); + + const otherContributors = (contributors ?? []).filter( + (c) => c.id !== creator?.id, + ); + + return ( + + {creator && ( + + + + + + + {t("By {{name}}", { name: creator.name })} + + + + + + + + +
+ + {creator.name} + + + {otherContributors.length === 0 + ? t("Owner, no contributors") + : t("Owner")} + +
+
+ + {otherContributors.length > 0 && ( + <> + + + {t("Contributors")} + + + {otherContributors.map((contributor) => ( + + + {contributor.name} + + ))} + + + )} +
+
+
+ )} + +
+ ); +} diff --git a/apps/client/src/features/favorite/components/star-button.tsx b/apps/client/src/features/favorite/components/star-button.tsx new file mode 100644 index 0000000000..6a99e01e95 --- /dev/null +++ b/apps/client/src/features/favorite/components/star-button.tsx @@ -0,0 +1,76 @@ +import { ActionIcon, Tooltip } from "@mantine/core"; +import { IconStar, IconStarFilled } from "@tabler/icons-react"; +import { + useFavoriteIds, + useAddFavoriteMutation, + useRemoveFavoriteMutation, +} from "../queries/favorite-query"; +import { FavoriteType } from "../types/favorite.types"; +import { ToggleFavoriteParams } from "../services/favorite-service"; +import { useTranslation } from "react-i18next"; + +type StarButtonProps = { + type: FavoriteType; + pageId?: string; + spaceId?: string; + templateId?: string; + size?: number; +}; + +function getEntityId(props: StarButtonProps): string | undefined { + if (props.type === "page") return props.pageId; + if (props.type === "space") return props.spaceId; + if (props.type === "template") return props.templateId; + return undefined; +} + +export default function StarButton(props: StarButtonProps) { + const { type, size = 18 } = props; + const { t } = useTranslation(); + const favoriteIds = useFavoriteIds(type); + const addMutation = useAddFavoriteMutation(); + const removeMutation = useRemoveFavoriteMutation(); + + const entityId = getEntityId(props); + const isFavorited = entityId ? favoriteIds.has(entityId) : false; + const isPending = addMutation.isPending || removeMutation.isPending; + + const handleToggle = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + + const params: ToggleFavoriteParams = { + type, + pageId: props.pageId, + spaceId: props.spaceId, + templateId: props.templateId, + }; + + if (isFavorited) { + removeMutation.mutate(params); + } else { + addMutation.mutate(params); + } + }; + + return ( + + + {isFavorited ? ( + + ) : ( + + )} + + + ); +} diff --git a/apps/client/src/features/favorite/queries/favorite-query.ts b/apps/client/src/features/favorite/queries/favorite-query.ts new file mode 100644 index 0000000000..886f2f5b9f --- /dev/null +++ b/apps/client/src/features/favorite/queries/favorite-query.ts @@ -0,0 +1,92 @@ +import { useMemo } from "react"; +import { + useQuery, + useInfiniteQuery, + useMutation, + useQueryClient, +} from "@tanstack/react-query"; +import { + addFavorite, + removeFavorite, + getFavorites, + getFavoriteIds, + ToggleFavoriteParams, +} from "../services/favorite-service"; +import { FavoriteType } from "../types/favorite.types"; + +export function useFavoritesQuery(type?: FavoriteType, spaceId?: string) { + return useInfiniteQuery({ + queryKey: ["favorites", type, spaceId], + queryFn: ({ pageParam }) => + getFavorites({ type, spaceId, cursor: pageParam, limit: 15 }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => + lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined, + refetchOnMount: true, + }); +} + +export function useFavoriteIds(type: FavoriteType, spaceId?: string): Set { + const { data } = useQuery({ + queryKey: ["favorite-ids", type, spaceId], + queryFn: () => getFavoriteIds(type, spaceId), + refetchOnMount: true, + }); + + const items = data?.items; + return useMemo(() => new Set(items ?? []), [items]); +} + +function getEntityId(variables: ToggleFavoriteParams): string | undefined { + if (variables.type === "page") return variables.pageId; + if (variables.type === "space") return variables.spaceId; + if (variables.type === "template") return variables.templateId; + return undefined; +} + +export function useAddFavoriteMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data) => addFavorite(data), + onSuccess: (_result, variables) => { + const entityId = getEntityId(variables); + if (entityId) { + queryClient.setQueriesData<{ items: string[]; meta: any }>( + { queryKey: ["favorite-ids", variables.type] }, + (old) => { + if (!old) return old; + if (old.items.includes(entityId)) return old; + return { ...old, items: [...old.items, entityId] }; + }, + ); + } + queryClient.invalidateQueries({ + queryKey: ["favorites", variables.type], + }); + }, + }); +} + +export function useRemoveFavoriteMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data) => removeFavorite(data), + onSuccess: (_result, variables) => { + const entityId = getEntityId(variables); + if (entityId) { + queryClient.setQueriesData<{ items: string[]; meta: any }>( + { queryKey: ["favorite-ids", variables.type] }, + (old) => { + if (!old) return old; + return { ...old, items: old.items.filter((id) => id !== entityId) }; + }, + ); + } + queryClient.invalidateQueries({ + queryKey: ["favorites", variables.type], + }); + }, + }); +} diff --git a/apps/client/src/features/favorite/services/favorite-service.ts b/apps/client/src/features/favorite/services/favorite-service.ts new file mode 100644 index 0000000000..dbc68492bc --- /dev/null +++ b/apps/client/src/features/favorite/services/favorite-service.ts @@ -0,0 +1,37 @@ +import api from "@/lib/api-client"; +import { IPagination } from "@/lib/types.ts"; +import { IFavorite, FavoriteType } from "../types/favorite.types"; + +export type ToggleFavoriteParams = { + type: FavoriteType; + pageId?: string; + spaceId?: string; + templateId?: string; +}; + +export async function addFavorite( + params: ToggleFavoriteParams, +): Promise { + await api.post("/favorites/add", params); +} + +export async function removeFavorite( + params: ToggleFavoriteParams, +): Promise { + await api.post("/favorites/remove", params); +} + +export async function getFavoriteIds(type: FavoriteType, spaceId?: string): Promise> { + const req = await api.post>("/favorites/ids", { type, spaceId }); + return req.data; +} + +export async function getFavorites(params?: { + type?: FavoriteType; + spaceId?: string; + limit?: number; + cursor?: string; +}): Promise> { + const req = await api.post("/favorites", params); + return req.data; +} diff --git a/apps/client/src/features/favorite/types/favorite.types.ts b/apps/client/src/features/favorite/types/favorite.types.ts new file mode 100644 index 0000000000..7bea026a0b --- /dev/null +++ b/apps/client/src/features/favorite/types/favorite.types.ts @@ -0,0 +1,32 @@ +export type FavoriteType = "page" | "space" | "template"; + +export type IFavorite = { + id: string; + userId: string; + pageId: string | null; + spaceId: string | null; + templateId: string | null; + type: FavoriteType; + workspaceId: string; + createdAt: string; + page?: { + id: string; + slugId: string; + title: string; + icon: string | null; + spaceId: string; + }; + space?: { + id: string; + name: string; + slug: string; + logo: string | null; + }; + template?: { + id: string; + title: string; + description: string | null; + icon: string | null; + spaceId: string | null; + }; +}; diff --git a/apps/client/src/features/home/atoms/home-tab-atom.ts b/apps/client/src/features/home/atoms/home-tab-atom.ts new file mode 100644 index 0000000000..617608e1f7 --- /dev/null +++ b/apps/client/src/features/home/atoms/home-tab-atom.ts @@ -0,0 +1,3 @@ +import { atomWithStorage } from "jotai/utils"; + +export const homeTabAtom = atomWithStorage("home-tab", "recent"); diff --git a/apps/client/src/features/home/components/created-by-me.tsx b/apps/client/src/features/home/components/created-by-me.tsx new file mode 100644 index 0000000000..70137b1058 --- /dev/null +++ b/apps/client/src/features/home/components/created-by-me.tsx @@ -0,0 +1,126 @@ +import { + Text, + Group, + UnstyledButton, + Badge, + Table, + ActionIcon, + Button, +} from "@mantine/core"; +import { Link } from "react-router-dom"; +import PageListSkeleton from "@/components/ui/page-list-skeleton"; +import { buildPageUrl } from "@/features/page/page.utils"; +import { formattedDate } from "@/lib/time"; +import { useCreatedByQuery } from "@/features/page/queries/page-query"; +import { IconFileDescription, IconFiles } from "@tabler/icons-react"; +import { EmptyState } from "@/components/ui/empty-state"; +import { getSpaceUrl } from "@/lib/config"; +import { useTranslation } from "react-i18next"; +import { getInitialsColor } from "@/lib/get-initials-color"; + +type Props = { + spaceId?: string; +}; + +export default function CreatedByMe({ spaceId }: Props) { + const { t } = useTranslation(); + const { + data, + isLoading, + isError, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + } = useCreatedByQuery({ spaceId }); + + const pages = data?.pages.flatMap((p) => p.items) ?? []; + + if (isLoading) { + return ; + } + + if (isError) { + return {t("Failed to fetch pages")}; + } + + return pages.length > 0 ? ( + <> + + + + {pages.map((page) => ( + + + + + {page.icon || ( + + + + )} + + {page.title || t("Untitled")} + + + + + {!spaceId && ( + + + {page?.space.name} + + + )} + + + {formattedDate(page.createdAt)} + + + + ))} + +
+
+ {hasNextPage && ( + + )} + + ) : ( + + ); +} diff --git a/apps/client/src/features/home/components/favorites-pages.tsx b/apps/client/src/features/home/components/favorites-pages.tsx new file mode 100644 index 0000000000..eb87216e03 --- /dev/null +++ b/apps/client/src/features/home/components/favorites-pages.tsx @@ -0,0 +1,130 @@ +import { + Text, + Group, + UnstyledButton, + Badge, + Table, + ActionIcon, + Button, +} from "@mantine/core"; +import { Link } from "react-router-dom"; +import PageListSkeleton from "@/components/ui/page-list-skeleton"; +import { buildPageUrl } from "@/features/page/page.utils"; +import { formattedDate } from "@/lib/time"; +import { useFavoritesQuery } from "@/features/favorite/queries/favorite-query"; +import { IconFileDescription, IconStar } from "@tabler/icons-react"; +import { EmptyState } from "@/components/ui/empty-state"; +import { getSpaceUrl } from "@/lib/config"; +import { useTranslation } from "react-i18next"; +import { getInitialsColor } from "@/lib/get-initials-color"; + +interface Props { + spaceId?: string; +} + +export default function FavoritesPages({ spaceId }: Props) { + const { t } = useTranslation(); + const { + data, + isLoading, + isError, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + } = useFavoritesQuery("page", spaceId); + + const favorites = data?.pages.flatMap((p) => p.items) ?? []; + + if (isLoading) { + return ; + } + + if (isError) { + return {t("Failed to fetch starred pages")}; + } + + return favorites.length > 0 ? ( + <> + + + + {favorites.map((fav) => + fav.page ? ( + + + + + {fav.page.icon || ( + + + + )} + + {fav.page.title || t("Untitled")} + + + + + {!spaceId && ( + + {fav.space && ( + + {fav.space.name} + + )} + + )} + + + {formattedDate(new Date(fav.createdAt))} + + + + ) : null, + )} + +
+
+ {hasNextPage && ( + + )} + + ) : ( + + ); +} diff --git a/apps/client/src/features/home/components/home-ai-prompt.module.css b/apps/client/src/features/home/components/home-ai-prompt.module.css new file mode 100644 index 0000000000..e6d8160678 --- /dev/null +++ b/apps/client/src/features/home/components/home-ai-prompt.module.css @@ -0,0 +1,28 @@ +.wrapper { + display: flex; + flex-direction: column; + align-items: center; + padding: var(--mantine-spacing-xl) var(--mantine-spacing-md) var(--mantine-spacing-lg); +} + +.heading { + font-size: 1.75rem; + font-weight: 600; + color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0)); + text-align: center; + margin: 0; + line-height: 1.2; +} + +.subtitle { + font-size: var(--mantine-font-size-sm); + color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2)); + text-align: center; + margin-top: 6px; + margin-bottom: var(--mantine-spacing-lg); +} + +.inputContainer { + width: 100%; + max-width: 640px; +} diff --git a/apps/client/src/features/home/components/home-ai-prompt.tsx b/apps/client/src/features/home/components/home-ai-prompt.tsx new file mode 100644 index 0000000000..c3c6939862 --- /dev/null +++ b/apps/client/src/features/home/components/home-ai-prompt.tsx @@ -0,0 +1,60 @@ +import { useAtomValue } from "jotai"; +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import ChatInput from "@/ee/ai-chat/components/chat-input"; +import type { + ChatAttachment, + PageMention, +} from "@/ee/ai-chat/types/ai-chat.types"; +import classes from "./home-ai-prompt.module.css"; + +export type HomeAiPromptInitialState = { + initialContent: string; + initialMentions: PageMention[]; + initialAttachments: ChatAttachment[]; +}; + +export default function HomeAiPrompt() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const workspace = useAtomValue(workspaceAtom); + + const aiChatEnabled = workspace?.settings?.ai?.chat === true; + if (!aiChatEnabled) return null; + + const handleSend = ( + content: string, + mentions: PageMention[], + attachments: ChatAttachment[], + ) => { + if (!content.trim() && attachments.length === 0) return; + const state: HomeAiPromptInitialState = { + initialContent: content, + initialMentions: mentions, + initialAttachments: attachments, + }; + navigate("/ai", { state }); + }; + + return ( +
+

+ {t("Welcome to {{name}}", { name: workspace?.name ?? "Docmost" })} +

+
+ {t("Ask anything or search your workspace")} +
+ +
+ {}} + placeholder={t("Ask anything... Use @ to mention pages")} + autofocus={false} + /> +
+
+ ); +} diff --git a/apps/client/src/features/home/components/home-tabs.tsx b/apps/client/src/features/home/components/home-tabs.tsx index a20fa2b8f4..c899a24d91 100644 --- a/apps/client/src/features/home/components/home-tabs.tsx +++ b/apps/client/src/features/home/components/home-tabs.tsx @@ -1,19 +1,40 @@ import { Text, Tabs, Space } from "@mantine/core"; -import { IconClockHour3 } from "@tabler/icons-react"; -import RecentChanges from "@/components/common/recent-changes.tsx"; +import { IconClockHour3, IconStar, IconUser } from "@tabler/icons-react"; +import RecentChanges from "@/components/common/recent-changes"; +import FavoritesPages from "./favorites-pages"; +import CreatedByMe from "./created-by-me"; import { useTranslation } from "react-i18next"; +import { useAtom } from "jotai"; +import { homeTabAtom } from "@/features/home/atoms/home-tab-atom"; export default function HomeTabs() { const { t } = useTranslation(); + const [activeTab, setActiveTab] = useAtom(homeTabAtom); return ( - - + { + if (value) setActiveTab(value); + }} + > + }> {t("Recently updated")} + }> + + {t("Favorites")} + + + }> + + {t("Created by me")} + + @@ -21,6 +42,12 @@ export default function HomeTabs() { + + + + + + ); } diff --git a/apps/client/src/features/notification/components/notification-item.tsx b/apps/client/src/features/notification/components/notification-item.tsx index 0fd4f44b6d..75e73d2351 100644 --- a/apps/client/src/features/notification/components/notification-item.tsx +++ b/apps/client/src/features/notification/components/notification-item.tsx @@ -6,10 +6,12 @@ import { UnstyledButton, } from "@mantine/core"; import { + IconBell, IconCheck, IconFileDescription, IconPointFilled, } from "@tabler/icons-react"; +import { Avatar } from "@mantine/core"; import { CustomAvatar } from "@/components/ui/custom-avatar"; import { INotification } from "../types/notification.types"; import { Trans, useTranslation } from "react-i18next"; @@ -51,6 +53,16 @@ export function NotificationItem({ : "{{name}} gave you view access to a page"; case "page.updated": return "{{name}} updated a page"; + case "page.verified": + return "{{name}} verified a page"; + case "page.approval_requested": + return "{{name}} submitted a page for your approval"; + case "page.approval_rejected": + return "{{name}} returned a page for revision"; + case "page.verification_expiring": + return "Page verification expires soon"; + case "page.verification_expired": + return "Page verification has expired"; default: return ""; } @@ -96,11 +108,17 @@ export function NotificationItem({ className={classes.notificationItem} > - + {notification.actor ? ( + + ) : ( + + + + )}
diff --git a/apps/client/src/features/notification/types/notification.types.ts b/apps/client/src/features/notification/types/notification.types.ts index f64e3648b0..266b9a6e48 100644 --- a/apps/client/src/features/notification/types/notification.types.ts +++ b/apps/client/src/features/notification/types/notification.types.ts @@ -4,7 +4,12 @@ export type NotificationType = | "comment.resolved" | "page.user_mention" | "page.permission_granted" - | "page.updated"; + | "page.updated" + | "page.verification_expiring" + | "page.verification_expired" + | "page.verified" + | "page.approval_requested" + | "page.approval_rejected"; export type INotification = { id: string; diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx index 769ca60417..f21128e606 100644 --- a/apps/client/src/features/page/components/header/page-header-menu.tsx +++ b/apps/client/src/features/page/components/header/page-header-menu.tsx @@ -146,8 +146,16 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) { movePageModalOpened, { open: openMovePageModal, close: closeMoveSpaceModal }, ] = useDisclosure(false); + const [ + verificationOpened, + { open: openVerificationModal, close: closeVerificationModal }, + ] = useDisclosure(false); const [pageEditor] = useAtom(pageEditorAtom); const pageUpdatedAt = useTimeAgo(page?.updatedAt); + const favoriteIds = useFavoriteIds("page", page?.spaceId); + const addFavoriteMutation = useAddFavoriteMutation(); + const removeFavoriteMutation = useRemoveFavoriteMutation(); + const isFavorited = page?.id ? favoriteIds.has(page.id) : false; const { data: watchStatus } = useWatchStatusQuery(page?.id); const watchPage = useWatchPageMutation(); const unwatchPage = useUnwatchPageMutation(); @@ -224,6 +232,19 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) { {t("Copy as Markdown")} + + ) : ( + + ) + } + onClick={handleToggleFavorite} + > + {isFavorited ? t("Remove from favorites") : t("Add to favorites")} + + + {!readOnly && ( + + )} + {!readOnly && ( @@ -371,6 +399,12 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) { onClose={closeMoveSpaceModal} open={movePageModalOpened} /> + + ); } diff --git a/apps/client/src/features/page/queries/page-query.ts b/apps/client/src/features/page/queries/page-query.ts index d86a332df6..0646a0e5e9 100644 --- a/apps/client/src/features/page/queries/page-query.ts +++ b/apps/client/src/features/page/queries/page-query.ts @@ -17,6 +17,7 @@ import { movePage, getPageBreadcrumbs, getRecentChanges, + getCreatedByPages, getAllSidebarPages, getDeletedPages, restorePage, @@ -258,7 +259,7 @@ export function useGetSidebarPagesQuery( return useInfiniteQuery({ queryKey: ["sidebar-pages", data], enabled: !!data?.pageId || !!data?.spaceId, - queryFn: ({ pageParam }) => getSidebarPages({ ...data, cursor: pageParam }), + queryFn: ({ pageParam }) => getSidebarPages({ ...data, cursor: pageParam, limit: 100 }), initialPageParam: undefined, getNextPageParam: (lastPage) => lastPage.meta?.nextCursor ?? undefined, @@ -269,7 +270,7 @@ export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) { return useInfiniteQuery({ queryKey: ["root-sidebar-pages", data.spaceId], queryFn: async ({ pageParam }) => { - return getSidebarPages({ spaceId: data.spaceId, cursor: pageParam }); + return getSidebarPages({ spaceId: data.spaceId, cursor: pageParam, limit: 100 }); }, initialPageParam: undefined, getNextPageParam: (lastPage) => @@ -299,12 +300,26 @@ export async function fetchAllAncestorChildren(params: SidebarPagesParams) { return buildTree(allItems); } -export function useRecentChangesQuery( - spaceId?: string, -): UseQueryResult, Error> { - return useQuery({ +export function useRecentChangesQuery(spaceId?: string) { + return useInfiniteQuery({ queryKey: ["recent-changes", spaceId], - queryFn: () => getRecentChanges(spaceId), + queryFn: ({ pageParam }) => + getRecentChanges({ spaceId, cursor: pageParam, limit: 15 }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => + lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined, + refetchOnMount: true, + }); +} + +export function useCreatedByQuery(params?: { userId?: string; spaceId?: string }) { + const { userId, spaceId } = params ?? {}; + return useInfiniteQuery({ + queryKey: ["pages-created-by-user", { userId, spaceId }], + queryFn: ({ pageParam }) => getCreatedByPages({ userId, spaceId, cursor: pageParam, limit: 15 }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => + lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined, refetchOnMount: true, }); } diff --git a/apps/client/src/features/page/services/page-service.ts b/apps/client/src/features/page/services/page-service.ts index a84e812371..146da7dd14 100644 --- a/apps/client/src/features/page/services/page-service.ts +++ b/apps/client/src/features/page/services/page-service.ts @@ -77,7 +77,7 @@ export async function getAllSidebarPages( const pageParams: (string | undefined)[] = []; do { - const req = await api.post("/pages/sidebar-pages", { ...params, cursor }); + const req = await api.post("/pages/sidebar-pages", { ...params, cursor, limit: 100 }); const data: IPagination = req.data; pages.push(data); @@ -100,9 +100,16 @@ export async function getPageBreadcrumbs( } export async function getRecentChanges( - spaceId?: string, + params?: QueryParams & { spaceId?: string }, ): Promise> { - const req = await api.post("/pages/recent", { spaceId }); + const req = await api.post("/pages/recent", params); + return req.data; +} + +export async function getCreatedByPages( + params?: QueryParams & { userId?: string; spaceId?: string }, +): Promise> { + const req = await api.post("/pages/created-by-user", params); return req.data; } diff --git a/apps/client/src/features/page/tree/components/space-tree.tsx b/apps/client/src/features/page/tree/components/space-tree.tsx index 73945cdaf3..ee4d774582 100644 --- a/apps/client/src/features/page/tree/components/space-tree.tsx +++ b/apps/client/src/features/page/tree/components/space-tree.tsx @@ -28,6 +28,8 @@ import { IconLink, IconPlus, IconPointFilled, + IconStar, + IconStarFilled, IconTrash, } from "@tabler/icons-react"; import { @@ -69,6 +71,7 @@ import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sideb import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; import CopyPageModal from "../../components/copy-page-modal.tsx"; import { duplicatePage } from "../../services/page-service.ts"; +import { useFavoriteIds, useAddFavoriteMutation, useRemoveFavoriteMutation } from "@/features/favorite/queries/favorite-query"; interface SpaceTreeProps { spaceId: string; @@ -506,6 +509,10 @@ function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) { copyPageModalOpened, { open: openCopyPageModal, close: closeCopySpaceModal }, ] = useDisclosure(false); + const favoriteIds = useFavoriteIds("page", spaceId); + const addFavorite = useAddFavoriteMutation(); + const removeFavorite = useRemoveFavoriteMutation(); + const isFavorited = favoriteIds.has(node.data.id); const handleCopyLink = () => { const pageUrl = @@ -608,6 +615,21 @@ function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) { {t("Copy link")} + : } + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + if (isFavorited) { + removeFavorite.mutate({ type: "page", pageId: node.data.id }); + } else { + addFavorite.mutate({ type: "page", pageId: node.data.id }); + } + }} + > + {isFavorited ? t("Remove from favorites") : t("Add to favorites")} + + } onClick={(e) => { diff --git a/apps/client/src/features/page/types/page.types.ts b/apps/client/src/features/page/types/page.types.ts index 83bd713a75..6c34bac351 100644 --- a/apps/client/src/features/page/types/page.types.ts +++ b/apps/client/src/features/page/types/page.types.ts @@ -22,6 +22,7 @@ export interface IPage { creator: ICreator; lastUpdatedBy: ILastUpdatedBy; deletedBy: IDeletedBy; + contributors?: IContributor[]; space: Partial; breadcrumbs: string[]; permissions?: { @@ -30,6 +31,12 @@ export interface IPage { }; } +export interface IContributor { + id: string; + name: string; + avatarUrl: string; +} + interface ICreator { id: string; name: string; @@ -69,6 +76,7 @@ export interface SidebarPagesParams { spaceId?: string; pageId?: string; cursor?: string; + limit?: number; } export interface IPageInput { diff --git a/apps/client/src/features/search/components/search-control.module.css b/apps/client/src/features/search/components/search-control.module.css index 5e5a9c2697..80be81d9c7 100644 --- a/apps/client/src/features/search/components/search-control.module.css +++ b/apps/client/src/features/search/components/search-control.module.css @@ -29,6 +29,8 @@ border-radius: var(--mantine-radius-sm); border: 1px solid; font-weight: bold; + white-space: nowrap; + flex-shrink: 0; @mixin light { color: var(--mantine-color-gray-7); diff --git a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx index 6eeca80f68..d1957851ca 100644 --- a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx +++ b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx @@ -10,13 +10,22 @@ import { IconArrowDown, IconChartDots3, IconDots, + IconEye, + IconEyeOff, IconFileExport, IconHome, IconPlus, IconSearch, IconSettings, + IconStar, + IconStarFilled, IconTrash, } from "@tabler/icons-react"; +import { + useSpaceWatchStatusQuery, + useWatchSpaceMutation, + useUnwatchSpaceMutation, +} from "@/features/space/queries/space-watcher-query.ts"; import classes from "./space-sidebar.module.css"; import React from "react"; import { useAtom } from "jotai"; @@ -37,6 +46,11 @@ import PageImportModal from "@/features/page/components/page-import-modal.tsx"; import { useTranslation } from "react-i18next"; import { SwitchSpace } from "./switch-space"; import ExportModal from "@/components/common/export-modal"; +import { + useFavoriteIds, + useAddFavoriteMutation, + useRemoveFavoriteMutation, +} from "@/features/favorite/queries/favorite-query"; import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; import { searchSpotlight } from "@/features/search/constants"; @@ -75,11 +89,18 @@ export function SpaceSidebar() { marginBottom: 3, }} > - + + +
@@ -181,13 +202,20 @@ export function SpaceSidebar() { {t("Pages")} - {spaceAbility.can( - SpaceCaslAction.Manage, - SpaceCaslSubject.Page, - ) && ( - - + + + {spaceAbility.can( + SpaceCaslAction.Manage, + SpaceCaslSubject.Page, + ) && ( - - )} + )} +
@@ -225,9 +253,14 @@ export function SpaceSidebar() { interface SpaceMenuProps { spaceId: string; + canManagePages: boolean; onSpaceSettings: () => void; } -function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) { +function SpaceMenu({ + spaceId, + canManagePages, + onSpaceSettings, +}: SpaceMenuProps) { const { t } = useTranslation(); const { spaceSlug } = useParams(); const [importOpened, { open: openImportModal, close: closeImportModal }] = @@ -235,15 +268,38 @@ function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) { const [exportOpened, { open: openExportModal, close: closeExportModal }] = useDisclosure(false); + const { data: watchStatus } = useSpaceWatchStatusQuery(spaceId); + const watchMutation = useWatchSpaceMutation(); + const unwatchMutation = useUnwatchSpaceMutation(); + const isWatching = watchStatus?.watching ?? false; + + const favoriteIds = useFavoriteIds("space"); + const addFavoriteMutation = useAddFavoriteMutation(); + const removeFavoriteMutation = useRemoveFavoriteMutation(); + const isFavorited = favoriteIds.has(spaceId); + + const handleToggleFavorite = () => { + const params = { type: "space" as const, spaceId }; + if (isFavorited) { + removeFavoriteMutation.mutate(params); + } else { + addFavoriteMutation.mutate(params); + } + }; + + const handleToggleWatch = () => { + if (isWatching) { + unwatchMutation.mutate(spaceId); + } else { + watchMutation.mutate(spaceId); + } + }; + return ( <> - + } + onClick={handleToggleFavorite} + leftSection={ + isFavorited ? ( + + ) : ( + + ) + } > - {t("Import pages")} + {isFavorited ? t("Remove from favorites") : t("Add to favorites")} } + onClick={handleToggleWatch} + leftSection={ + isWatching ? : + } > - {t("Export space")} + {isWatching ? t("Stop watching space") : t("Watch space")} - + {canManagePages && ( + <> + - } - > - {t("Space settings")} - + } + > + {t("Import pages")} + - } - > - {t("Trash")} - + } + > + {t("Export space")} + + + + + } + > + {t("Space settings")} + + + } + > + {t("Trash")} + + + )} - + {canManagePages && ( + <> + - + + + )} ); } diff --git a/apps/client/src/features/space/components/space-carousel.module.css b/apps/client/src/features/space/components/space-carousel.module.css new file mode 100644 index 0000000000..87b9f1de7d --- /dev/null +++ b/apps/client/src/features/space/components/space-carousel.module.css @@ -0,0 +1,22 @@ +.card { + background-color: var(--mantine-color-body); + width: 220px; + + @mixin hover { + box-shadow: var(--mantine-shadow-xs); + transform: scale(1.02); + } +} + +.cardSection { + background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6)); +} + +.title { + font-family: + Greycliff CF, + var(--mantine-font-family); + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} diff --git a/apps/client/src/features/space/components/space-carousel.tsx b/apps/client/src/features/space/components/space-carousel.tsx new file mode 100644 index 0000000000..169d34b1ee --- /dev/null +++ b/apps/client/src/features/space/components/space-carousel.tsx @@ -0,0 +1,105 @@ +import { Text, Card, rem, Group, Button, Skeleton } from "@mantine/core"; +import { + prefetchSpace, + useGetSpacesQuery, +} from "@/features/space/queries/space-query.ts"; +import { getSpaceUrl } from "@/lib/config.ts"; +import { Link } from "react-router-dom"; +import classes from "./space-carousel.module.css"; +import { formatMemberCount } from "@/lib"; +import { useTranslation } from "react-i18next"; +import { IconArrowRight } from "@tabler/icons-react"; +import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; +import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts"; +import CardCarousel from "@/components/ui/card-carousel"; + +function SpaceCardSkeleton() { + return ( + + + + + + + ); +} + +export default function SpaceCarousel() { + const { t } = useTranslation(); + const { data, isPending } = useGetSpacesQuery({ limit: 20 }); + + if (isPending) { + return ( + <> + + + {t("Spaces you belong to")} + + + + {Array.from({ length: 4 }, (_, i) => ( + + ))} + + + ); + } + + const cards = data?.items.map((space) => ( + prefetchSpace(space.slug, space.id)} + className={classes.card} + withBorder + > + + + + + {space.name} + + + + {formatMemberCount(space.memberCount, t)} + + + )); + + return ( + <> + + + {t("Spaces you belong to")} + + + + {cards} + + {data?.items && data.items.length > 1 && ( + + + + )} + + ); +} diff --git a/apps/client/src/features/space/components/space-grid.module.css b/apps/client/src/features/space/components/space-grid.module.css index e714f81a5a..17ff9ba24c 100644 --- a/apps/client/src/features/space/components/space-grid.module.css +++ b/apps/client/src/features/space/components/space-grid.module.css @@ -7,9 +7,26 @@ } .cardSection { + position: relative; background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6)); } +.starButton { + position: absolute; + top: 4px; + right: 4px; + opacity: 0; + transition: opacity 150ms ease; +} + +.starButton[data-favorited="true"] { + opacity: 1; +} + +.card:hover .starButton { + opacity: 1; +} + .title { font-family: Greycliff CF, diff --git a/apps/client/src/features/space/components/space-grid.tsx b/apps/client/src/features/space/components/space-grid.tsx index 860ca28aa0..87e7e5a18b 100644 --- a/apps/client/src/features/space/components/space-grid.tsx +++ b/apps/client/src/features/space/components/space-grid.tsx @@ -11,6 +11,8 @@ import { useTranslation } from "react-i18next"; import { IconArrowRight } from "@tabler/icons-react"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts"; +import StarButton from "@/features/favorite/components/star-button"; +import { useFavoriteIds } from "@/features/favorite/queries/favorite-query"; export default function SpaceGrid() { const { t } = useTranslation(); @@ -27,7 +29,11 @@ export default function SpaceGrid() { className={classes.card} withBorder > - + +
+ +
+
{cards} - {data?.items && data.items.length > 9 && ( + {data?.items && data.items.length > 6 && ( + + )} + + ); +} diff --git a/apps/client/src/features/space/queries/space-query.ts b/apps/client/src/features/space/queries/space-query.ts index c2d16978ed..18edb09f31 100644 --- a/apps/client/src/features/space/queries/space-query.ts +++ b/apps/client/src/features/space/queries/space-query.ts @@ -71,9 +71,10 @@ export const prefetchSpace = (spaceSlug: string, spaceId?: string) => { if (spaceId) { // this endpoint only accepts uuid for now - queryClient.prefetchQuery({ + queryClient.prefetchInfiniteQuery({ queryKey: ["recent-changes", spaceId], - queryFn: () => getRecentChanges(spaceId), + queryFn: () => getRecentChanges({ spaceId }), + initialPageParam: undefined, }); } }; diff --git a/apps/client/src/features/space/queries/space-watcher-query.ts b/apps/client/src/features/space/queries/space-watcher-query.ts new file mode 100644 index 0000000000..84642bb558 --- /dev/null +++ b/apps/client/src/features/space/queries/space-watcher-query.ts @@ -0,0 +1,78 @@ +import { useMemo } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + watchSpace, + unwatchSpace, + getSpaceWatchStatus, + getWatchedSpaceIds, +} from "@/features/space/services/space-watcher-service"; +import { notifications } from "@mantine/notifications"; +import { useTranslation } from "react-i18next"; + +const SPACE_WATCHER_KEY = "space-watcher"; +const WATCHED_SPACE_IDS_KEY = "watched-space-ids"; + +export function useWatchedSpaceIds(): Set { + const { data } = useQuery({ + queryKey: [WATCHED_SPACE_IDS_KEY], + queryFn: () => getWatchedSpaceIds(), + refetchOnMount: true, + }); + + const items = data?.items; + return useMemo(() => new Set(items ?? []), [items]); +} + +export function useSpaceWatchStatusQuery(spaceId: string) { + return useQuery({ + queryKey: [SPACE_WATCHER_KEY, spaceId], + queryFn: () => getSpaceWatchStatus(spaceId), + enabled: !!spaceId, + staleTime: 60_000, + }); +} + +export function useWatchSpaceMutation() { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + return useMutation({ + mutationFn: (spaceId: string) => watchSpace(spaceId), + onSuccess: (_data, spaceId) => { + queryClient.setQueryData([SPACE_WATCHER_KEY, spaceId], { + watching: true, + }); + queryClient.setQueryData( + [WATCHED_SPACE_IDS_KEY], + (old: { items: string[]; meta: any } | undefined) => { + if (!old) return old; + if (old.items.includes(spaceId)) return old; + return { ...old, items: [...old.items, spaceId] }; + }, + ); + notifications.show({ message: t("You are now watching this space") }); + }, + }); +} + +export function useUnwatchSpaceMutation() { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + return useMutation({ + mutationFn: (spaceId: string) => unwatchSpace(spaceId), + onSuccess: (_data, spaceId) => { + queryClient.setQueryData([SPACE_WATCHER_KEY, spaceId], { + watching: false, + }); + queryClient.setQueryData( + [WATCHED_SPACE_IDS_KEY], + (old: { items: string[]; meta: any } | undefined) => { + if (!old) return old; + return { ...old, items: old.items.filter((id) => id !== spaceId) }; + }, + ); + notifications.show({ + message: t("You are no longer watching this space"), + }); + }, + }); +} diff --git a/apps/client/src/features/space/services/space-watcher-service.ts b/apps/client/src/features/space/services/space-watcher-service.ts new file mode 100644 index 0000000000..35280c0850 --- /dev/null +++ b/apps/client/src/features/space/services/space-watcher-service.ts @@ -0,0 +1,34 @@ +import api from "@/lib/api-client"; +import { IPagination } from "@/lib/types"; + +export async function watchSpace( + spaceId: string, +): Promise<{ watching: boolean }> { + const req = await api.post<{ watching: boolean }>("/spaces/watch", { + spaceId, + }); + return req.data; +} + +export async function unwatchSpace( + spaceId: string, +): Promise<{ watching: boolean }> { + const req = await api.post<{ watching: boolean }>("/spaces/unwatch", { + spaceId, + }); + return req.data; +} + +export async function getWatchedSpaceIds(): Promise> { + const req = await api.post>("/spaces/watched-ids"); + return req.data; +} + +export async function getSpaceWatchStatus( + spaceId: string, +): Promise<{ watching: boolean }> { + const req = await api.post<{ watching: boolean }>("/spaces/watch-status", { + spaceId, + }); + return req.data; +} diff --git a/apps/client/src/features/websocket/types/types.ts b/apps/client/src/features/websocket/types/types.ts index 1f20aca51a..e925d0c153 100644 --- a/apps/client/src/features/websocket/types/types.ts +++ b/apps/client/src/features/websocket/types/types.ts @@ -85,6 +85,11 @@ export type RefetchRootTreeNodeEvent = { spaceId: string; }; +export type VerificationUpdatedEvent = { + operation: "verificationUpdated"; + pageId: string; +}; + export type WebSocketEvent = | InvalidateEvent | CommentCreatedEvent @@ -96,4 +101,5 @@ export type WebSocketEvent = | AddTreeNodeEvent | MoveTreeNodeEvent | DeleteTreeNodeEvent - | RefetchRootTreeNodeEvent; + | RefetchRootTreeNodeEvent + | VerificationUpdatedEvent; diff --git a/apps/client/src/features/websocket/use-query-subscription.ts b/apps/client/src/features/websocket/use-query-subscription.ts index ee24b616c2..0dfd5d85fe 100644 --- a/apps/client/src/features/websocket/use-query-subscription.ts +++ b/apps/client/src/features/websocket/use-query-subscription.ts @@ -163,6 +163,11 @@ export const useQuerySubscription = () => { }); break; } + case "verificationUpdated": + queryClient.invalidateQueries({ + queryKey: ["page-verification-info", data.pageId], + }); + break; } }); }, [queryClient, socket]); diff --git a/apps/client/src/features/workspace/types/workspace.types.ts b/apps/client/src/features/workspace/types/workspace.types.ts index 5f061a5e55..b1ce98b32a 100644 --- a/apps/client/src/features/workspace/types/workspace.types.ts +++ b/apps/client/src/features/workspace/types/workspace.types.ts @@ -27,12 +27,14 @@ export interface IWorkspace { mcpEnabled?: boolean; trashRetentionDays?: number; restrictApiToAdmins?: boolean; + allowMemberTemplates?: boolean; } export interface IWorkspaceSettings { ai?: IWorkspaceAiSettings; sharing?: IWorkspaceSharingSettings; api?: IWorkspaceApiSettings; + templates?: IWorkspaceTemplateSettings; } export interface IWorkspaceApiSettings { @@ -43,12 +45,17 @@ export interface IWorkspaceAiSettings { search?: boolean; generative?: boolean; mcp?: boolean; + chat?: boolean; } export interface IWorkspaceSharingSettings { disabled?: boolean; } +export interface IWorkspaceTemplateSettings { + allowMemberTemplates?: boolean; +} + export interface ICreateInvite { role: string; emails: string[]; diff --git a/apps/client/src/hooks/use-time-ago.tsx b/apps/client/src/hooks/use-time-ago.tsx index 6f2148cafc..9a95cfa85a 100644 --- a/apps/client/src/hooks/use-time-ago.tsx +++ b/apps/client/src/hooks/use-time-ago.tsx @@ -26,7 +26,7 @@ function getSnapshot() { return tick; } -export function useTimeAgo(date: Date | string) { +export function useTimeAgo(date: Date | string | undefined) { const currentTick = useSyncExternalStore(subscribe, getSnapshot); - return useMemo(() => timeAgo(new Date(date)), [date, currentTick]); + return useMemo(() => (date ? timeAgo(new Date(date)) : ""), [date, currentTick]); } diff --git a/apps/client/src/lib/app-route.ts b/apps/client/src/lib/app-route.ts index fcc18b8eb8..a03877c310 100644 --- a/apps/client/src/lib/app-route.ts +++ b/apps/client/src/lib/app-route.ts @@ -1,6 +1,7 @@ const APP_ROUTE = { HOME: "/home", SPACES: "/spaces", + FAVORITES: "/favorites", SEARCH: "/search", AUTH: { LOGIN: "/login", diff --git a/apps/client/src/pages/dashboard/home.tsx b/apps/client/src/pages/dashboard/home.tsx index 900afa40de..83d553038a 100644 --- a/apps/client/src/pages/dashboard/home.tsx +++ b/apps/client/src/pages/dashboard/home.tsx @@ -1,6 +1,7 @@ import { Container, Space } from "@mantine/core"; import HomeTabs from "@/features/home/components/home-tabs"; -import SpaceGrid from "@/features/space/components/space-grid.tsx"; +import HomeAiPrompt from "@/features/home/components/home-ai-prompt"; +import SpaceCarousel from "@/features/space/components/space-carousel.tsx"; import { getAppName } from "@/lib/config.ts"; import { Helmet } from "react-helmet-async"; import { useTranslation } from "react-i18next"; @@ -15,8 +16,12 @@ export default function Home() { {t("Home")} - {getAppName()} - - + + + + + + diff --git a/apps/client/src/pages/favorites/favorites-page.tsx b/apps/client/src/pages/favorites/favorites-page.tsx new file mode 100644 index 0000000000..7e9aa626e7 --- /dev/null +++ b/apps/client/src/pages/favorites/favorites-page.tsx @@ -0,0 +1,139 @@ +import { + Text, + Group, + UnstyledButton, + Badge, + Table, + Container, + Title, + ActionIcon, + Button, +} from "@mantine/core"; +import { Link } from "react-router-dom"; +import { buildPageUrl } from "@/features/page/page.utils"; +import { formattedDate } from "@/lib/time"; +import { useFavoritesQuery } from "@/features/favorite/queries/favorite-query"; +import { IconFileDescription, IconStar } from "@tabler/icons-react"; +import { EmptyState } from "@/components/ui/empty-state"; +import { getSpaceUrl } from "@/lib/config"; +import { useTranslation } from "react-i18next"; +import { getInitialsColor } from "@/lib/get-initials-color"; +import PageListSkeleton from "@/components/ui/page-list-skeleton"; + +export default function FavoritesPage() { + const { t } = useTranslation(); + const { data, isLoading, isError, hasNextPage, fetchNextPage, isFetchingNextPage } = useFavoritesQuery("page"); + const favorites = data?.pages.flatMap((p) => p.items) ?? []; + + if (isLoading) { + return ( + + + {t("Favorites")} + + + + ); + } + + if (isError) { + return ( + + + {t("Favorites")} + + {t("Failed to fetch favorite pages")} + + ); + } + + return ( + + + {t("Favorites")} + + {favorites.length > 0 ? ( + <> + + + + {favorites.map((fav) => + fav.page ? ( + + + + + {fav.page.icon || ( + + + + )} + + {fav.page.title || t("Untitled")} + + + + + + {fav.space && ( + + {fav.space.name} + + )} + + + + {formattedDate(new Date(fav.createdAt))} + + + + ) : null, + )} + +
+
+ {hasNextPage && ( + + )} + + ) : ( + + )} +
+ ); +} diff --git a/apps/client/src/pages/page/page.tsx b/apps/client/src/pages/page/page.tsx index f0fc93fa54..b4e3baa805 100644 --- a/apps/client/src/pages/page/page.tsx +++ b/apps/client/src/pages/page/page.tsx @@ -107,6 +107,8 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) { slugId={page.slugId} spaceSlug={page?.space?.slug} editable={canEdit} + creator={page.creator} + contributors={page.contributors} canComment={canComment} /> diff --git a/apps/client/src/pages/space/space-home.tsx b/apps/client/src/pages/space/space-home.tsx index 0b5622fe10..b8d72a2df5 100644 --- a/apps/client/src/pages/space/space-home.tsx +++ b/apps/client/src/pages/space/space-home.tsx @@ -14,7 +14,7 @@ export default function SpaceHome() { {space?.name || 'Overview'} - {getAppName()} - + {space && } diff --git a/apps/client/src/pages/spaces/spaces.tsx b/apps/client/src/pages/spaces/spaces.tsx index 5fc6392991..81e350f71a 100644 --- a/apps/client/src/pages/spaces/spaces.tsx +++ b/apps/client/src/pages/spaces/spaces.tsx @@ -5,6 +5,7 @@ import { getAppName } from "@/lib/config"; import { useGetSpacesQuery } from "@/features/space/queries/space-query"; import CreateSpaceModal from "@/features/space/components/create-space-modal"; import { AllSpacesList } from "@/features/space/components/spaces-page"; +import FavoriteSpacesGrid from "@/features/space/components/spaces-page/favorite-spaces-grid"; import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search"; import useUserRole from "@/hooks/use-user-role"; @@ -33,9 +34,11 @@ export default function Spaces() { {isAdmin && } + + - {t("Spaces you belong to")} + {t("All spaces")} { this.logger.error( diff --git a/apps/server/src/common/events/audit-events.ts b/apps/server/src/common/events/audit-events.ts index 144cb22d82..955a6ba5ab 100644 --- a/apps/server/src/common/events/audit-events.ts +++ b/apps/server/src/common/events/audit-events.ts @@ -59,6 +59,14 @@ export const AuditEvent = { PAGE_RESTRICTION_REMOVED: 'page.restriction_removed', PAGE_PERMISSION_ADDED: 'page.permission_added', PAGE_PERMISSION_REMOVED: 'page.permission_removed', + // Page verification + PAGE_VERIFICATION_CREATED: 'page.verification_created', + PAGE_VERIFICATION_UPDATED: 'page.verification_updated', + PAGE_VERIFICATION_REMOVED: 'page.verification_removed', + PAGE_VERIFIED: 'page.verified', + PAGE_APPROVAL_REQUESTED: 'page.approval_requested', + PAGE_APPROVAL_REJECTED: 'page.approval_rejected', + PAGE_MARKED_OBSOLETE: 'page.marked_obsolete', // Share SHARE_CREATED: 'share.created', diff --git a/apps/server/src/common/features.ts b/apps/server/src/common/features.ts index b893e0b97a..38f226a862 100644 --- a/apps/server/src/common/features.ts +++ b/apps/server/src/common/features.ts @@ -17,6 +17,8 @@ export const Feature = { RETENTION: 'retention', SHARING_CONTROLS: 'sharing:controls', VIEWER_COMMENTS: 'comment:viewer', + TEMPLATES: 'templates', + PDF_EXPORT: 'export:pdf', } as const; export type FeatureKey = (typeof Feature)[keyof typeof Feature]; diff --git a/apps/server/src/common/helpers/prosemirror/utils.ts b/apps/server/src/common/helpers/prosemirror/utils.ts index c97f31a0ae..f8f78f8824 100644 --- a/apps/server/src/common/helpers/prosemirror/utils.ts +++ b/apps/server/src/common/helpers/prosemirror/utils.ts @@ -7,6 +7,10 @@ import { validate as isValidUUID } from 'uuid'; import { Transform } from '@tiptap/pm/transform'; import { TiptapTransformer } from '@hocuspocus/transformer'; import * as Y from 'yjs'; +import { + INTERNAL_LINK_REGEX, + extractPageSlugId, +} from '../../../integrations/export/utils'; export interface MentionNode { id: string; @@ -64,6 +68,27 @@ export function extractPageMentions(mentionList: MentionNode[]): MentionNode[] { return pageMentionList as MentionNode[]; } +export function extractInternalLinkSlugIds(prosemirrorJson: any): string[] { + const slugIds: string[] = []; + const doc = jsonToNode(prosemirrorJson); + + doc.descendants((node: Node) => { + for (const mark of node.marks) { + if (mark.type.name === 'link' && mark.attrs.internal && mark.attrs.href) { + const match = mark.attrs.href.match(INTERNAL_LINK_REGEX); + if (match) { + const slugId = extractPageSlugId(match[5]); + if (slugId && !slugIds.includes(slugId)) { + slugIds.push(slugId); + } + } + } + } + }); + + return slugIds; +} + export function extractUserMentionIdsFromJson(json: any): string[] { const userIds: string[] = []; diff --git a/apps/server/src/common/helpers/utils.ts b/apps/server/src/common/helpers/utils.ts index 36ff5b6396..c37e9a47f3 100644 --- a/apps/server/src/common/helpers/utils.ts +++ b/apps/server/src/common/helpers/utils.ts @@ -142,6 +142,18 @@ export function isUserDisabled(user: { return !!(user.deactivatedAt || user.deletedAt); } +const SENSITIVE_URL_PREFIXES = ['/api/sso/']; + +export function redactSensitiveUrl(url: string): string { + if (url && SENSITIVE_URL_PREFIXES.some((prefix) => url.includes(prefix))) { + const qsIndex = url.indexOf('?'); + if (qsIndex !== -1) { + return url.substring(0, qsIndex); + } + } + return url; +} + export function createByteCountingStream(source: Readable) { let bytesRead = 0; const stream = new Transform({ diff --git a/apps/server/src/common/logger/pino.config.ts b/apps/server/src/common/logger/pino.config.ts index 0b8cd11a0c..4c3b199339 100644 --- a/apps/server/src/common/logger/pino.config.ts +++ b/apps/server/src/common/logger/pino.config.ts @@ -1,5 +1,6 @@ import { Params } from 'nestjs-pino'; import { stdTimeFunctions } from 'pino'; +import { redactSensitiveUrl } from '../helpers/utils'; const CONTEXTS_TO_IGNORE = [ 'InstanceLoader', @@ -52,7 +53,7 @@ export function createPinoConfig(): Params { serializers: { req: (req) => ({ method: req.method, - url: req.url, + url: redactSensitiveUrl(req.url), ip: req.ip || req.remoteAddress, userAgent: req.headers?.['user-agent'], }), diff --git a/apps/server/src/core/attachment/attachment.constants.ts b/apps/server/src/core/attachment/attachment.constants.ts index 1028a4ae57..083c3c98ff 100644 --- a/apps/server/src/core/attachment/attachment.constants.ts +++ b/apps/server/src/core/attachment/attachment.constants.ts @@ -3,6 +3,7 @@ export enum AttachmentType { WorkspaceIcon = 'workspace-icon', SpaceIcon = 'space-icon', File = 'file', + Chat = 'chat', } export const validImageExtensions = ['.jpg', '.png', '.jpeg', '.webp']; diff --git a/apps/server/src/core/attachment/attachment.controller.ts b/apps/server/src/core/attachment/attachment.controller.ts index 39cf3c422d..1e4342a608 100644 --- a/apps/server/src/core/attachment/attachment.controller.ts +++ b/apps/server/src/core/attachment/attachment.controller.ts @@ -180,21 +180,29 @@ export class AttachmentController { } const attachment = await this.attachmentRepo.findById(fileId); - if ( - !attachment || - attachment.workspaceId !== workspace.id || - !attachment.pageId || - !attachment.spaceId - ) { + if (!attachment || attachment.workspaceId !== workspace.id) { throw new NotFoundException(); } - const page = await this.pageRepo.findById(attachment.pageId); - if (!page) { - throw new NotFoundException(); - } + if (attachment.aiChatId) { + // Chat-owned attachment: only the user who uploaded (and therefore + // owns the chat, per AttachmentRepo.claimAttachmentsForChat) can + // read it back. + if (attachment.creatorId !== user.id) { + throw new NotFoundException(); + } + } else { + if (!attachment.pageId || !attachment.spaceId) { + throw new NotFoundException(); + } - await this.pageAccessService.validateCanView(page, user); + const page = await this.pageRepo.findById(attachment.pageId); + if (!page) { + throw new NotFoundException(); + } + + await this.pageAccessService.validateCanView(page, user); + } try { return await this.sendFileResponse(req, res, attachment, 'private'); diff --git a/apps/server/src/core/attachment/attachment.utils.ts b/apps/server/src/core/attachment/attachment.utils.ts index 88edb2af4b..616fed5303 100644 --- a/apps/server/src/core/attachment/attachment.utils.ts +++ b/apps/server/src/core/attachment/attachment.utils.ts @@ -71,6 +71,8 @@ export function getAttachmentFolderPath( return `${workspaceId}/space-logos`; case AttachmentType.File: return `${workspaceId}/files`; + case AttachmentType.Chat: + return `${workspaceId}/chat-files`; default: return `${workspaceId}/files`; } diff --git a/apps/server/src/core/attachment/processors/attachment.processor.ts b/apps/server/src/core/attachment/processors/attachment.processor.ts index bcf8a08a9e..7249a9fa25 100644 --- a/apps/server/src/core/attachment/processors/attachment.processor.ts +++ b/apps/server/src/core/attachment/processors/attachment.processor.ts @@ -28,6 +28,11 @@ export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy { job.data.pageId, ); } + if (job.name === QueueJob.DELETE_AI_CHAT_ATTACHMENTS) { + await this.attachmentService.handleDeleteAiChatAttachments( + job.data.aiChatId, + ); + } if ( job.name === QueueJob.ATTACHMENT_INDEX_CONTENT || job.name === QueueJob.ATTACHMENT_INDEXING diff --git a/apps/server/src/core/attachment/services/attachment.service.ts b/apps/server/src/core/attachment/services/attachment.service.ts index a26ecc66ae..11e60995b1 100644 --- a/apps/server/src/core/attachment/services/attachment.service.ts +++ b/apps/server/src/core/attachment/services/attachment.service.ts @@ -300,6 +300,31 @@ export class AttachmentService { ); } + async handleDeleteAiChatAttachments(aiChatId: string) { + try { + const attachments = await this.attachmentRepo.findByAiChatId(aiChatId); + if (!attachments || attachments.length === 0) { + return; + } + + await Promise.all( + attachments.map(async (attachment) => { + try { + await this.storageService.delete(attachment.filePath); + await this.attachmentRepo.deleteAttachmentById(attachment.id); + } catch (err) { + this.logger.log( + `DeleteAiChatAttachments: failed to delete attachment ${attachment.id}:`, + err, + ); + } + }), + ); + } catch (err) { + throw err; + } + } + async handleDeleteSpaceAttachments(spaceId: string) { try { const attachments = await this.attachmentRepo.findBySpaceId(spaceId); diff --git a/apps/server/src/core/auth/auth.controller.ts b/apps/server/src/core/auth/auth.controller.ts index 441bfc1c9d..89bb9e1b08 100644 --- a/apps/server/src/core/auth/auth.controller.ts +++ b/apps/server/src/core/auth/auth.controller.ts @@ -11,6 +11,10 @@ import { Logger, } from '@nestjs/common'; import { SkipThrottle, ThrottlerGuard } from '@nestjs/throttler'; +import { + AI_CHAT_THROTTLER, + AUTH_THROTTLER, +} from '../../integrations/throttle/throttler-names'; import { LoginDto } from './dto/login.dto'; import { AuthService } from './services/auth.service'; import { SessionService } from '../session/session.service'; @@ -34,6 +38,7 @@ import { IAuditService, } from '../../integrations/audit/audit.service'; +@SkipThrottle({ [AI_CHAT_THROTTLER]: true }) @UseGuards(ThrottlerGuard) @Controller('auth') export class AuthController { @@ -113,7 +118,7 @@ export class AuthController { return workspace; } - @SkipThrottle() + @SkipThrottle({ [AUTH_THROTTLER]: true }) @UseGuards(JwtAuthGuard) @HttpCode(HttpStatus.OK) @Post('change-password') @@ -176,7 +181,7 @@ export class AuthController { return this.authService.verifyUserToken(verifyUserTokenDto, workspace.id); } - @SkipThrottle() + @SkipThrottle({ [AUTH_THROTTLER]: true }) @UseGuards(JwtAuthGuard) @HttpCode(HttpStatus.OK) @Post('collab-token') @@ -187,7 +192,7 @@ export class AuthController { return this.authService.getCollabToken(user, workspace.id); } - @SkipThrottle() + @SkipThrottle({ [AUTH_THROTTLER]: true }) @UseGuards(JwtAuthGuard) @HttpCode(HttpStatus.OK) @Post('logout') diff --git a/apps/server/src/core/auth/dto/jwt-payload.ts b/apps/server/src/core/auth/dto/jwt-payload.ts index f70848b946..b3ccda7099 100644 --- a/apps/server/src/core/auth/dto/jwt-payload.ts +++ b/apps/server/src/core/auth/dto/jwt-payload.ts @@ -5,6 +5,8 @@ export enum JwtType { ATTACHMENT = 'attachment', MFA_TOKEN = 'mfa_token', API_KEY = 'api_key', + PDF_RENDER = 'pdf_render', + PDF_EXPORT_DOWNLOAD = 'pdf_export_download', } export type JwtPayload = { sub: string; @@ -45,3 +47,15 @@ export type JwtApiKeyPayload = { apiKeyId: string; type: 'api_key'; }; + +export type JwtPdfRenderPayload = { + pageId: string; + workspaceId: string; + type: 'pdf_render'; +}; + +export type JwtPdfExportDownloadPayload = { + fileTaskId: string; + workspaceId: string; + type: 'pdf_export_download'; +}; diff --git a/apps/server/src/core/auth/services/token.service.ts b/apps/server/src/core/auth/services/token.service.ts index b9035ba36f..1cc10a07d9 100644 --- a/apps/server/src/core/auth/services/token.service.ts +++ b/apps/server/src/core/auth/services/token.service.ts @@ -13,6 +13,8 @@ import { JwtExchangePayload, JwtMfaTokenPayload, JwtPayload, + JwtPdfExportDownloadPayload, + JwtPdfRenderPayload, JwtType, } from '../dto/jwt-payload'; import { User } from '@docmost/db/types/entity.types'; @@ -115,6 +117,30 @@ export class TokenService { return this.jwtService.sign(payload, expiresIn ? { expiresIn } : {}); } + async generatePdfRenderToken( + pageId: string, + workspaceId: string, + ): Promise { + const payload: JwtPdfRenderPayload = { + pageId, + workspaceId, + type: JwtType.PDF_RENDER, + }; + return this.jwtService.sign(payload, { expiresIn: '60s' }); + } + + async generatePdfExportDownloadToken( + fileTaskId: string, + workspaceId: string, + ): Promise { + const payload: JwtPdfExportDownloadPayload = { + fileTaskId, + workspaceId, + type: JwtType.PDF_EXPORT_DOWNLOAD, + }; + return this.jwtService.sign(payload, { expiresIn: '1h' }); + } + async verifyJwt(token: string, tokenType: string) { const payload = await this.jwtService.verifyAsync(token, { secret: this.environmentService.getAppSecret(), diff --git a/apps/server/src/core/core.module.ts b/apps/server/src/core/core.module.ts index 5ff9246f5c..71d94b3cf8 100644 --- a/apps/server/src/core/core.module.ts +++ b/apps/server/src/core/core.module.ts @@ -20,6 +20,7 @@ import { AuditContextMiddleware } from '../common/middlewares/audit-context.midd import { ShareModule } from './share/share.module'; import { NotificationModule } from './notification/notification.module'; import { WatcherModule } from './watcher/watcher.module'; +import { FavoriteModule } from './favorite/favorite.module'; import { SessionModule } from './session/session.module'; import { ClsMiddleware } from 'nestjs-cls'; import { ApiKeyModule } from './api-key/api-key.module'; @@ -32,6 +33,7 @@ import { ApiKeyModule } from './api-key/api-key.module'; PageModule, AttachmentModule, CommentModule, + FavoriteModule, SearchModule, SpaceModule, GroupModule, diff --git a/apps/server/src/core/favorite/dto/favorite-ids.dto.ts b/apps/server/src/core/favorite/dto/favorite-ids.dto.ts new file mode 100644 index 0000000000..bc5b1ebbdb --- /dev/null +++ b/apps/server/src/core/favorite/dto/favorite-ids.dto.ts @@ -0,0 +1,12 @@ +import { IsIn, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; + +export class FavoriteIdsDto { + @IsString() + @IsNotEmpty() + @IsIn(['page', 'space', 'template']) + type: 'page' | 'space' | 'template'; + + @IsOptional() + @IsUUID() + spaceId?: string; +} diff --git a/apps/server/src/core/favorite/dto/favorite.dto.ts b/apps/server/src/core/favorite/dto/favorite.dto.ts new file mode 100644 index 0000000000..a3f87d6c4c --- /dev/null +++ b/apps/server/src/core/favorite/dto/favorite.dto.ts @@ -0,0 +1,28 @@ +import { + IsIn, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, +} from 'class-validator'; + +export class AddFavoriteDto { + @IsString() + @IsNotEmpty() + @IsIn(['page', 'space', 'template']) + type: 'page' | 'space' | 'template'; + + @IsOptional() + @IsUUID() + pageId?: string; + + @IsOptional() + @IsUUID() + spaceId?: string; + + @IsOptional() + @IsUUID() + templateId?: string; +} + +export class RemoveFavoriteDto extends AddFavoriteDto {} diff --git a/apps/server/src/core/favorite/dto/list-favorites.dto.ts b/apps/server/src/core/favorite/dto/list-favorites.dto.ts new file mode 100644 index 0000000000..f32c7aac89 --- /dev/null +++ b/apps/server/src/core/favorite/dto/list-favorites.dto.ts @@ -0,0 +1,12 @@ +import { IsIn, IsOptional, IsString, IsUUID } from 'class-validator'; + +export class ListFavoritesDto { + @IsOptional() + @IsString() + @IsIn(['page', 'space', 'template']) + type?: 'page' | 'space' | 'template'; + + @IsOptional() + @IsUUID() + spaceId?: string; +} diff --git a/apps/server/src/core/favorite/favorite.controller.ts b/apps/server/src/core/favorite/favorite.controller.ts new file mode 100644 index 0000000000..47e63aace2 --- /dev/null +++ b/apps/server/src/core/favorite/favorite.controller.ts @@ -0,0 +1,153 @@ +import { + BadRequestException, + Body, + Controller, + ForbiddenException, + HttpCode, + HttpStatus, + NotFoundException, + Post, + UseGuards, +} from '@nestjs/common'; +import { FavoriteService } from './services/favorite.service'; +import { AddFavoriteDto, RemoveFavoriteDto } from './dto/favorite.dto'; +import { FavoriteIdsDto } from './dto/favorite-ids.dto'; +import { ListFavoritesDto } from './dto/list-favorites.dto'; +import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { AuthUser } from '../../common/decorators/auth-user.decorator'; +import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; +import { Page, User, Workspace } from '@docmost/db/types/entity.types'; +import { PageRepo } from '@docmost/db/repos/page/page.repo'; +import { SpaceRepo } from '@docmost/db/repos/space/space.repo'; +import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; +import { PageAccessService } from '../page/page-access/page-access.service'; +import { TemplateRepo } from '@docmost/db/repos/template/template.repo'; +import { FavoriteType } from '@docmost/db/repos/favorite/favorite.repo'; + +@UseGuards(JwtAuthGuard) +@Controller('favorites') +export class FavoriteController { + constructor( + private readonly favoriteService: FavoriteService, + private readonly pageRepo: PageRepo, + private readonly spaceRepo: SpaceRepo, + private readonly spaceMemberRepo: SpaceMemberRepo, + private readonly pageAccessService: PageAccessService, + private readonly templateRepo: TemplateRepo, + ) {} + + @HttpCode(HttpStatus.OK) + @Post('add') + async addFavorite( + @Body() dto: AddFavoriteDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + const resolved = await this.resolveAndValidate(dto, user, workspace.id); + + await this.favoriteService.addFavorite(user.id, workspace.id, { + type: dto.type, + pageId: dto.pageId, + spaceId: dto.type === 'space' ? resolved.spaceId : undefined, + templateId: dto.templateId, + }); + } + + @HttpCode(HttpStatus.OK) + @Post('remove') + async removeFavorite( + @Body() dto: RemoveFavoriteDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + await this.resolveAndValidate(dto, user, workspace.id); + + await this.favoriteService.removeFavorite(user.id, { + type: dto.type, + pageId: dto.pageId, + spaceId: dto.spaceId, + templateId: dto.templateId, + }); + } + + @HttpCode(HttpStatus.OK) + @Post('ids') + async getFavoriteIds( + @Body() dto: FavoriteIdsDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + return this.favoriteService.getFavoriteIds( + user.id, + workspace.id, + dto.type as FavoriteType, + dto.spaceId, + ); + } + + @HttpCode(HttpStatus.OK) + @Post() + async getUserFavorites( + @Body() dto: ListFavoritesDto, + @Body() pagination: PaginationOptions, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + return this.favoriteService.getUserFavorites( + user.id, + workspace.id, + pagination, + dto.type as FavoriteType | undefined, + dto.spaceId, + ); + } + + private async resolveAndValidate( + dto: AddFavoriteDto | RemoveFavoriteDto, + user: User, + workspaceId: string, + ): Promise<{ spaceId: string; page?: Page }> { + if (dto.type === 'page') { + if (!dto.pageId) throw new BadRequestException('pageId is required'); + const page = await this.pageRepo.findById(dto.pageId); + if (!page) throw new NotFoundException('Page not found'); + await this.pageAccessService.validateCanView(page, user); + return { spaceId: page.spaceId, page }; + } + + if (dto.type === 'space') { + if (!dto.spaceId) throw new BadRequestException('spaceId is required'); + const space = await this.spaceRepo.findById(dto.spaceId, workspaceId); + if (!space) throw new NotFoundException('Space not found'); + await this.validateSpaceAccess(user.id, space.id); + return { spaceId: space.id }; + } + + if (dto.type === 'template') { + if (!dto.templateId) + throw new BadRequestException('templateId is required'); + const template = await this.templateRepo.findById( + dto.templateId, + workspaceId, + ); + if (!template) throw new NotFoundException('Template not found'); + if (template.spaceId) { + await this.validateSpaceAccess(user.id, template.spaceId); + } + return { spaceId: template.spaceId }; + } + + throw new BadRequestException('Invalid favorite type'); + } + + private async validateSpaceAccess( + userId: string, + spaceId: string, + ): Promise { + const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId); + if (!userSpaceIds.includes(spaceId)) { + throw new ForbiddenException(); + } + } +} diff --git a/apps/server/src/core/favorite/favorite.module.ts b/apps/server/src/core/favorite/favorite.module.ts new file mode 100644 index 0000000000..fc63e001d7 --- /dev/null +++ b/apps/server/src/core/favorite/favorite.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { FavoriteService } from './services/favorite.service'; +import { FavoriteController } from './favorite.controller'; + +@Module({ + controllers: [FavoriteController], + providers: [FavoriteService], + exports: [FavoriteService], +}) +export class FavoriteModule {} diff --git a/apps/server/src/core/favorite/services/favorite.service.ts b/apps/server/src/core/favorite/services/favorite.service.ts new file mode 100644 index 0000000000..79902e6452 --- /dev/null +++ b/apps/server/src/core/favorite/services/favorite.service.ts @@ -0,0 +1,148 @@ +import { Injectable } from '@nestjs/common'; +import { + FavoriteRepo, + FavoriteType, +} from '@docmost/db/repos/favorite/favorite.repo'; +import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; +import { InsertableFavorite } from '@docmost/db/types/entity.types'; +import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo'; +import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; + +@Injectable() +export class FavoriteService { + constructor( + private readonly favoriteRepo: FavoriteRepo, + private readonly pagePermissionRepo: PagePermissionRepo, + private readonly spaceMemberRepo: SpaceMemberRepo, + ) {} + + async getFavoriteIds( + userId: string, + workspaceId: string, + type: FavoriteType, + spaceId?: string, + ) { + const result = await this.favoriteRepo.getFavoriteIds( + userId, + workspaceId, + type, + spaceId, + ); + + if (result.items.length === 0) { + return result; + } + + if (type === FavoriteType.PAGE) { + const accessibleIds = + await this.pagePermissionRepo.filterAccessiblePageIds({ + pageIds: result.items, + userId, + }); + const accessibleSet = new Set(accessibleIds); + result.items = result.items.filter((id) => accessibleSet.has(id)); + } + + if (type === FavoriteType.SPACE) { + const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId); + const spaceSet = new Set(userSpaceIds); + result.items = result.items.filter((id) => spaceSet.has(id)); + } + + return result; + } + + async addFavorite( + userId: string, + workspaceId: string, + opts: { + type: FavoriteType; + pageId?: string; + spaceId?: string; + templateId?: string; + }, + ): Promise { + const favorite: InsertableFavorite = { + userId, + pageId: opts.pageId ?? null, + spaceId: opts.spaceId ?? null, + templateId: opts.templateId ?? null, + type: opts.type, + workspaceId, + }; + + await this.favoriteRepo.insert(favorite); + } + + async removeFavorite( + userId: string, + opts: { + type: FavoriteType; + pageId?: string; + spaceId?: string; + templateId?: string; + }, + ): Promise { + if (opts.type === FavoriteType.PAGE && opts.pageId) { + await this.favoriteRepo.deleteByUserAndPage(userId, opts.pageId); + } else if (opts.type === FavoriteType.SPACE && opts.spaceId) { + await this.favoriteRepo.deleteByUserAndSpace(userId, opts.spaceId); + } else if (opts.type === FavoriteType.TEMPLATE && opts.templateId) { + await this.favoriteRepo.deleteByUserAndTemplate(userId, opts.templateId); + } + } + + async getUserFavorites( + userId: string, + workspaceId: string, + pagination: PaginationOptions, + type?: FavoriteType, + spaceId?: string, + ) { + const result = await this.favoriteRepo.findUserFavorites( + userId, + workspaceId, + pagination, + type, + spaceId, + ); + + if (result.items.length === 0) { + return result; + } + + const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId); + const spaceSet = new Set(userSpaceIds); + + const pageFavorites = result.items.filter( + (f) => f.type === FavoriteType.PAGE && f.pageId, + ); + + let accessiblePageSet: Set | undefined; + if (pageFavorites.length > 0) { + const pageIds = pageFavorites.map((f) => f.pageId as string); + const accessibleIds = + await this.pagePermissionRepo.filterAccessiblePageIds({ + pageIds, + userId, + }); + accessiblePageSet = new Set(accessibleIds); + } + + result.items = result.items.filter((f) => { + if (f.type === FavoriteType.PAGE) { + return f.pageId && accessiblePageSet?.has(f.pageId); + } + if (f.type === FavoriteType.SPACE) { + return f.spaceId && spaceSet.has(f.spaceId); + } + if (f.type === FavoriteType.TEMPLATE) { + const templateSpaceId = (f as any).template?.spaceId; + return !templateSpaceId || spaceSet.has(templateSpaceId); + } + return true; + }); + + return result; + } +} diff --git a/apps/server/src/core/group/services/group-user.service.ts b/apps/server/src/core/group/services/group-user.service.ts index e0bdc23ae4..58ca8a892e 100644 --- a/apps/server/src/core/group/services/group-user.service.ts +++ b/apps/server/src/core/group/services/group-user.service.ts @@ -14,6 +14,7 @@ import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; import { UserRepo } from '@docmost/db/repos/user/user.repo'; import { executeTx } from '@docmost/db/utils'; import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo'; +import { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo'; import { AuditEvent, AuditResource } from '../../../common/events/audit-events'; import { AUDIT_SERVICE, @@ -29,6 +30,7 @@ export class GroupUserService { @Inject(forwardRef(() => GroupService)) private groupService: GroupService, private readonly watcherRepo: WatcherRepo, + private readonly favoriteRepo: FavoriteRepo, @InjectKysely() private readonly db: KyselyDB, @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, ) {} @@ -137,6 +139,12 @@ export class GroupUserService { spaceId, { trx }, ); + + await this.favoriteRepo.deleteByUsersWithoutSpaceAccess( + [userId], + spaceId, + { trx }, + ); } }); diff --git a/apps/server/src/core/group/services/group.service.ts b/apps/server/src/core/group/services/group.service.ts index 7d6005f0f0..8383763854 100644 --- a/apps/server/src/core/group/services/group.service.ts +++ b/apps/server/src/core/group/services/group.service.ts @@ -16,6 +16,7 @@ import { Group, InsertableGroup, User } from '@docmost/db/types/entity.types'; import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination'; import { GroupUserService } from './group-user.service'; import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo'; +import { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo'; import { executeTx } from '@docmost/db/utils'; import { InjectKysely } from 'nestjs-kysely'; import { AuditEvent, AuditResource } from '../../../common/events/audit-events'; @@ -34,6 +35,7 @@ export class GroupService { @Inject(forwardRef(() => GroupUserService)) private groupUserService: GroupUserService, private readonly watcherRepo: WatcherRepo, + private readonly favoriteRepo: FavoriteRepo, @InjectKysely() private readonly db: KyselyDB, @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, ) {} @@ -189,6 +191,12 @@ export class GroupService { spaceId, { trx }, ); + + await this.favoriteRepo.deleteByUsersWithoutSpaceAccess( + userIds, + spaceId, + { trx }, + ); } }); diff --git a/apps/server/src/core/notification/notification.constants.ts b/apps/server/src/core/notification/notification.constants.ts index 8f7f50491a..fc42bc64d3 100644 --- a/apps/server/src/core/notification/notification.constants.ts +++ b/apps/server/src/core/notification/notification.constants.ts @@ -5,6 +5,11 @@ export const NotificationType = { PAGE_USER_MENTION: 'page.user_mention', PAGE_PERMISSION_GRANTED: 'page.permission_granted', PAGE_UPDATED: 'page.updated', + PAGE_VERIFICATION_EXPIRING: 'page.verification_expiring', + PAGE_VERIFICATION_EXPIRED: 'page.verification_expired', + PAGE_VERIFIED: 'page.verified', + PAGE_APPROVAL_REQUESTED: 'page.approval_requested', + PAGE_APPROVAL_REJECTED: 'page.approval_rejected', } as const; export type NotificationType = diff --git a/apps/server/src/core/notification/notification.module.ts b/apps/server/src/core/notification/notification.module.ts index 8377829494..9aa452a5c6 100644 --- a/apps/server/src/core/notification/notification.module.ts +++ b/apps/server/src/core/notification/notification.module.ts @@ -4,6 +4,7 @@ import { NotificationController } from './notification.controller'; import { NotificationProcessor } from './notification.processor'; import { CommentNotificationService } from './services/comment.notification'; import { PageNotificationService } from './services/page.notification'; +import { VerificationNotificationService } from './services/verification.notification'; import { PageUpdateEmailRateLimiter } from './services/page-update-email-rate-limiter'; @Module({ @@ -14,6 +15,7 @@ import { PageUpdateEmailRateLimiter } from './services/page-update-email-rate-li NotificationProcessor, CommentNotificationService, PageNotificationService, + VerificationNotificationService, PageUpdateEmailRateLimiter, ], exports: [NotificationService], diff --git a/apps/server/src/core/notification/notification.processor.ts b/apps/server/src/core/notification/notification.processor.ts index e3d3a88326..c477c398ed 100644 --- a/apps/server/src/core/notification/notification.processor.ts +++ b/apps/server/src/core/notification/notification.processor.ts @@ -1,18 +1,26 @@ import { Logger, OnModuleDestroy } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq'; import { Job } from 'bullmq'; import { InjectKysely } from 'nestjs-kysely'; import { KyselyDB } from '@docmost/db/types/kysely.types'; import { QueueJob, QueueName } from '../../integrations/queue/constants'; import { + IApprovalRejectedNotificationJob, + IApprovalRequestedNotificationJob, ICommentNotificationJob, ICommentResolvedNotificationJob, IPageMentionNotificationJob, IPageUpdateNotificationJob, + IPageVerifiedNotificationJob, IPermissionGrantedNotificationJob, + IVerificationExpiringNotificationJob, + IVerificationExpiredNotificationJob, + IVerificationReconcileJob, } from '../../integrations/queue/constants/queue.interface'; import { CommentNotificationService } from './services/comment.notification'; import { PageNotificationService } from './services/page.notification'; +import { VerificationNotificationService } from './services/verification.notification'; import { DomainService } from '../../integrations/environment/domain.service'; @Processor(QueueName.NOTIFICATION_QUEUE) @@ -25,7 +33,9 @@ export class NotificationProcessor constructor( private readonly commentNotificationService: CommentNotificationService, private readonly pageNotificationService: PageNotificationService, + private readonly verificationNotificationService: VerificationNotificationService, private readonly domainService: DomainService, + private readonly moduleRef: ModuleRef, @InjectKysely() private readonly db: KyselyDB, ) { super(); @@ -37,12 +47,23 @@ export class NotificationProcessor | ICommentResolvedNotificationJob | IPageMentionNotificationJob | IPageUpdateNotificationJob - | IPermissionGrantedNotificationJob, + | IPermissionGrantedNotificationJob + | IVerificationExpiringNotificationJob + | IVerificationExpiredNotificationJob + | IVerificationReconcileJob + | IPageVerifiedNotificationJob + | IApprovalRequestedNotificationJob + | IApprovalRejectedNotificationJob, void >, ): Promise { try { - const workspaceId = (job.data as { workspaceId: string }).workspaceId; + if (job.name === QueueJob.VERIFICATION_RECONCILE) { + await this.runVerificationReconcile(); + return; + } + + const workspaceId = await this.resolveWorkspaceId(job); const appUrl = await this.getWorkspaceUrl(workspaceId); switch (job.name) { @@ -92,6 +113,45 @@ export class NotificationProcessor break; } + case QueueJob.PAGE_VERIFICATION_EXPIRING: { + await this.verificationNotificationService.processVerificationExpiring( + job.data as IVerificationExpiringNotificationJob, + appUrl, + ); + break; + } + + case QueueJob.PAGE_VERIFICATION_EXPIRED: { + await this.verificationNotificationService.processVerificationExpired( + job.data as IVerificationExpiredNotificationJob, + appUrl, + ); + break; + } + + case QueueJob.PAGE_VERIFIED_NOTIFICATION: { + await this.verificationNotificationService.processPageVerified( + job.data as IPageVerifiedNotificationJob, + ); + break; + } + + case QueueJob.PAGE_APPROVAL_REQUESTED_NOTIFICATION: { + await this.verificationNotificationService.processApprovalRequested( + job.data as IApprovalRequestedNotificationJob, + appUrl, + ); + break; + } + + case QueueJob.PAGE_APPROVAL_REJECTED_NOTIFICATION: { + await this.verificationNotificationService.processApprovalRejected( + job.data as IApprovalRejectedNotificationJob, + appUrl, + ); + break; + } + default: this.logger.warn(`Unknown notification job: ${job.name}`); } @@ -102,6 +162,49 @@ export class NotificationProcessor } } + private async resolveWorkspaceId(job: Job): Promise { + if ( + job.name === QueueJob.PAGE_VERIFICATION_EXPIRING || + job.name === QueueJob.PAGE_VERIFICATION_EXPIRED + ) { + const { verificationId } = job.data as { verificationId: string }; + const row = await this.db + .selectFrom('pageVerifications') + .select('workspaceId') + .where('id', '=', verificationId) + .executeTakeFirst(); + return row?.workspaceId ?? ''; + } + return (job.data as { workspaceId: string }).workspaceId; + } + + private async runVerificationReconcile(): Promise { + let eeModule: { PageVerificationSchedulerService?: unknown }; + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + eeModule = require('../../ee/page-verification/page-verification-scheduler.service'); + } catch { + this.logger.debug( + 'VERIFICATION_RECONCILE fired but EE scheduler not bundled in this build', + ); + return; + } + + const schedulerClass = eeModule.PageVerificationSchedulerService as + | (new (...args: unknown[]) => { reconcile(): Promise }) + | undefined; + if (!schedulerClass) return; + + const scheduler = this.moduleRef.get(schedulerClass, { strict: false }); + if (!scheduler) { + this.logger.warn( + 'VERIFICATION_RECONCILE fired but scheduler service not resolvable', + ); + return; + } + await scheduler.reconcile(); + } + private async getWorkspaceUrl(workspaceId: string): Promise { const workspace = await this.db .selectFrom('workspaces') diff --git a/apps/server/src/core/notification/services/page.notification.ts b/apps/server/src/core/notification/services/page.notification.ts index 9e5c75dd85..77ab967aad 100644 --- a/apps/server/src/core/notification/services/page.notification.ts +++ b/apps/server/src/core/notification/services/page.notification.ts @@ -179,7 +179,11 @@ export class PageNotificationService { async processPageUpdate(data: IPageUpdateNotificationJob, appUrl: string) { const { pageId, spaceId, workspaceId, actorIds } = data; - const watcherIds = await this.watcherRepo.getPageWatcherIds(pageId); + const watcherIds = await this.watcherRepo.getPageUpdateRecipientIds( + pageId, + spaceId, + ); + if (watcherIds.length === 0) return; const actorSet = new Set(actorIds); @@ -219,7 +223,7 @@ export class PageNotificationService { const context = await this.getPageContext(actorId, pageId, spaceId, appUrl); if (!context) return; - const { actor, pageTitle, basePageUrl } = context; + const { actor, pageTitle, basePageUrl, spaceName } = context; for (const userId of recipientIds) { const notification = await this.notificationService.create({ @@ -243,6 +247,7 @@ export class PageNotificationService { actorName: actor.name, pageTitle, pageUrl: basePageUrl, + spaceName, }), NotificationType.PAGE_UPDATED, ); @@ -421,7 +426,7 @@ export class PageNotificationService { .executeTakeFirst(), this.db .selectFrom('spaces') - .select(['id', 'slug']) + .select(['id', 'slug', 'name']) .where('id', '=', spaceId) .executeTakeFirst(), ]); @@ -432,6 +437,11 @@ export class PageNotificationService { const basePageUrl = `${appUrl}/s/${space.slug}/p/${page.slugId}`; - return { actor, pageTitle: getPageTitle(page.title), basePageUrl }; + return { + actor, + pageTitle: getPageTitle(page.title), + basePageUrl, + spaceName: space.name, + }; } } diff --git a/apps/server/src/core/notification/services/verification.notification.ts b/apps/server/src/core/notification/services/verification.notification.ts new file mode 100644 index 0000000000..351649e55f --- /dev/null +++ b/apps/server/src/core/notification/services/verification.notification.ts @@ -0,0 +1,355 @@ +import { Injectable } from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB } from '@docmost/db/types/kysely.types'; +import { + IApprovalRejectedNotificationJob, + IApprovalRequestedNotificationJob, + IPageVerifiedNotificationJob, + IVerificationExpiringNotificationJob, + IVerificationExpiredNotificationJob, +} from '../../../integrations/queue/constants/queue.interface'; +import { NotificationService } from '../notification.service'; +import { NotificationType } from '../notification.constants'; +import { VerificationExpiringEmail } from '@docmost/transactional/emails/verification-expiring-email'; +import { VerificationExpiredEmail } from '@docmost/transactional/emails/verification-expired-email'; +import { ApprovalRequestedEmail } from '@docmost/transactional/emails/approval-requested-email'; +import { ApprovalRejectedEmail } from '@docmost/transactional/emails/approval-rejected-email'; +import { getPageTitle } from '../../../common/helpers'; +import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; +import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo'; + +@Injectable() +export class VerificationNotificationService { + constructor( + @InjectKysely() private readonly db: KyselyDB, + private readonly notificationService: NotificationService, + private readonly spaceMemberRepo: SpaceMemberRepo, + private readonly pagePermissionRepo: PagePermissionRepo, + ) {} + + private async getAlreadyNotifiedUserIds( + pageVerificationId: string, + type: string, + ): Promise> { + const rows = await this.db + .selectFrom('notifications') + .select('userId') + .where('pageVerificationId', '=', pageVerificationId) + .where('type', '=', type) + .execute(); + return new Set(rows.map((r) => r.userId)); + } + + private async filterAccessibleRecipients( + userIds: string[], + pageId: string, + spaceId: string, + ): Promise { + if (userIds.length === 0) return []; + const inSpace = await this.spaceMemberRepo.getUserIdsWithSpaceAccess( + userIds, + spaceId, + ); + if (inSpace.size === 0) return []; + return this.pagePermissionRepo.getUserIdsWithPageAccess(pageId, [ + ...inSpace, + ]); + } + + async processVerificationExpiring( + data: IVerificationExpiringNotificationJob, + appUrl: string, + ) { + const verification = await this.db + .selectFrom('pageVerifications') + .selectAll() + .where('id', '=', data.verificationId) + .executeTakeFirst(); + + if (!verification) return; + if (verification.type !== 'expiring') return; + if (!verification.expiresAt) return; + const expiresAtMs = new Date(verification.expiresAt).getTime(); + if (expiresAtMs <= Date.now()) return; + + const verifierRows = await this.db + .selectFrom('pageVerifiers') + .select('userId') + .where('pageVerificationId', '=', verification.id) + .execute(); + const verifierIds = verifierRows.map((r) => r.userId); + if (verifierIds.length === 0) return; + + const accessibleVerifierIds = await this.filterAccessibleRecipients( + verifierIds, + verification.pageId, + verification.spaceId, + ); + if (accessibleVerifierIds.length === 0) return; + + const alreadyNotified = await this.getAlreadyNotifiedUserIds( + verification.id, + NotificationType.PAGE_VERIFICATION_EXPIRING, + ); + const recipients = accessibleVerifierIds.filter( + (id) => !alreadyNotified.has(id), + ); + if (recipients.length === 0) return; + + const context = await this.getPageContext( + verification.pageId, + verification.spaceId, + appUrl, + ); + if (!context) return; + + const { pageTitle, spaceName, basePageUrl } = context; + const expiresAtIso = new Date(verification.expiresAt).toISOString(); + + for (const userId of recipients) { + const notification = await this.notificationService.create({ + userId, + workspaceId: verification.workspaceId, + type: NotificationType.PAGE_VERIFICATION_EXPIRING, + pageId: verification.pageId, + spaceId: verification.spaceId, + pageVerificationId: verification.id, + data: { expiresAt: expiresAtIso }, + }); + + const subject = `"${pageTitle}" needs to be re-verified soon`; + + await this.notificationService.queueEmail( + userId, + notification.id, + subject, + VerificationExpiringEmail({ + pageTitle, + spaceName, + pageUrl: basePageUrl, + expiresAt: new Date(verification.expiresAt).toLocaleDateString(), + }), + ); + } + } + + async processVerificationExpired( + data: IVerificationExpiredNotificationJob, + appUrl: string, + ) { + const verification = await this.db + .selectFrom('pageVerifications') + .selectAll() + .where('id', '=', data.verificationId) + .executeTakeFirst(); + + if (!verification) return; + if (verification.type !== 'expiring') return; + if (!verification.expiresAt) return; + if (new Date(verification.expiresAt).getTime() > Date.now()) return; + + const verifierRows = await this.db + .selectFrom('pageVerifiers') + .select('userId') + .where('pageVerificationId', '=', verification.id) + .execute(); + const verifierIds = verifierRows.map((r) => r.userId); + if (verifierIds.length === 0) return; + + const accessibleVerifierIds = await this.filterAccessibleRecipients( + verifierIds, + verification.pageId, + verification.spaceId, + ); + if (accessibleVerifierIds.length === 0) return; + + const alreadyNotified = await this.getAlreadyNotifiedUserIds( + verification.id, + NotificationType.PAGE_VERIFICATION_EXPIRED, + ); + const recipients = accessibleVerifierIds.filter( + (id) => !alreadyNotified.has(id), + ); + if (recipients.length === 0) return; + + const context = await this.getPageContext( + verification.pageId, + verification.spaceId, + appUrl, + ); + if (!context) return; + + const { pageTitle, spaceName, basePageUrl } = context; + + for (const userId of recipients) { + const notification = await this.notificationService.create({ + userId, + workspaceId: verification.workspaceId, + type: NotificationType.PAGE_VERIFICATION_EXPIRED, + pageId: verification.pageId, + spaceId: verification.spaceId, + pageVerificationId: verification.id, + }); + + const subject = `"${pageTitle}" verification has expired`; + + await this.notificationService.queueEmail( + userId, + notification.id, + subject, + VerificationExpiredEmail({ + pageTitle, + spaceName, + pageUrl: basePageUrl, + }), + ); + } + } + + async processPageVerified(data: IPageVerifiedNotificationJob) { + const { verifierIds, pageId, spaceId, workspaceId, actorId } = data; + if (verifierIds.length === 0) return; + + const accessibleVerifierIds = await this.filterAccessibleRecipients( + verifierIds, + pageId, + spaceId, + ); + if (accessibleVerifierIds.length === 0) return; + + for (const userId of accessibleVerifierIds) { + await this.notificationService.create({ + userId, + workspaceId, + type: NotificationType.PAGE_VERIFIED, + actorId, + pageId, + spaceId, + }); + } + } + + async processApprovalRequested( + data: IApprovalRequestedNotificationJob, + appUrl: string, + ) { + const { verifierIds, pageId, spaceId, workspaceId, actorId } = data; + if (verifierIds.length === 0) return; + + const accessibleVerifierIds = await this.filterAccessibleRecipients( + verifierIds, + pageId, + spaceId, + ); + if (accessibleVerifierIds.length === 0) return; + + const context = await this.getPageContext(pageId, spaceId, appUrl); + if (!context) return; + + const { pageTitle, spaceName, basePageUrl } = context; + const actorName = await this.getUserName(actorId); + + for (const userId of accessibleVerifierIds) { + const notification = await this.notificationService.create({ + userId, + workspaceId, + type: NotificationType.PAGE_APPROVAL_REQUESTED, + actorId, + pageId, + spaceId, + }); + + const subject = `"${pageTitle}" needs your approval`; + + await this.notificationService.queueEmail( + userId, + notification.id, + subject, + ApprovalRequestedEmail({ + actorName, + pageTitle, + spaceName, + pageUrl: basePageUrl, + }), + ); + } + } + + async processApprovalRejected( + data: IApprovalRejectedNotificationJob, + appUrl: string, + ) { + const { pageId, spaceId, workspaceId, actorId, requestedById, comment } = + data; + + const recipients = await this.filterAccessibleRecipients( + [requestedById], + pageId, + spaceId, + ); + if (recipients.length === 0) return; + + const context = await this.getPageContext(pageId, spaceId, appUrl); + if (!context) return; + + const { pageTitle, spaceName, basePageUrl } = context; + const actorName = await this.getUserName(actorId); + + const notification = await this.notificationService.create({ + userId: requestedById, + workspaceId, + type: NotificationType.PAGE_APPROVAL_REJECTED, + actorId, + pageId, + spaceId, + }); + + const subject = `"${pageTitle}" was returned for revision`; + + await this.notificationService.queueEmail( + requestedById, + notification.id, + subject, + ApprovalRejectedEmail({ + actorName, + pageTitle, + spaceName, + pageUrl: basePageUrl, + comment, + }), + ); + } + + private async getUserName(userId: string): Promise { + const user = await this.db + .selectFrom('users') + .select('name') + .where('id', '=', userId) + .executeTakeFirst(); + return user?.name ?? 'Someone'; + } + + private async getPageContext( + pageId: string, + spaceId: string, + appUrl: string, + ) { + const [page, space] = await Promise.all([ + this.db + .selectFrom('pages') + .select(['id', 'title', 'slugId']) + .where('id', '=', pageId) + .executeTakeFirst(), + this.db + .selectFrom('spaces') + .select(['id', 'slug', 'name']) + .where('id', '=', spaceId) + .executeTakeFirst(), + ]); + + if (!page || !space) return null; + + const basePageUrl = `${appUrl}/s/${space.slug}/p/${page.slugId}`; + return { pageTitle: getPageTitle(page.title), spaceName: space.name ?? space.slug, basePageUrl }; + } +} diff --git a/apps/server/src/core/page/dto/created-by-user.dto.ts b/apps/server/src/core/page/dto/created-by-user.dto.ts new file mode 100644 index 0000000000..1c5c1292cc --- /dev/null +++ b/apps/server/src/core/page/dto/created-by-user.dto.ts @@ -0,0 +1,11 @@ +import { IsOptional, IsUUID } from 'class-validator'; + +export class CreatedByUserDto { + @IsOptional() + @IsUUID() + userId?: string; + + @IsOptional() + @IsUUID() + spaceId?: string; +} diff --git a/apps/server/src/core/page/dto/sidebar-page.dto.ts b/apps/server/src/core/page/dto/sidebar-page.dto.ts index 012f64b024..7f4568f97b 100644 --- a/apps/server/src/core/page/dto/sidebar-page.dto.ts +++ b/apps/server/src/core/page/dto/sidebar-page.dto.ts @@ -1,5 +1,4 @@ import { IsOptional, IsString, IsUUID } from 'class-validator'; -import { SpaceIdDto } from './page.dto'; export class SidebarPageDto { @IsOptional() diff --git a/apps/server/src/core/page/page.controller.ts b/apps/server/src/core/page/page.controller.ts index eff24936d3..fa279d851c 100644 --- a/apps/server/src/core/page/page.controller.ts +++ b/apps/server/src/core/page/page.controller.ts @@ -35,6 +35,7 @@ import { import SpaceAbilityFactory from '../casl/abilities/space-ability.factory'; import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { RecentPageDto } from './dto/recent-page.dto'; +import { CreatedByUserDto } from './dto/created-by-user.dto'; import { DuplicatePageDto } from './dto/duplicate-page.dto'; import { DeletedPageDto } from './dto/deleted-page.dto'; import { @@ -336,6 +337,29 @@ export class PageController { return this.pageService.getRecentPages(user.id, pagination); } + @HttpCode(HttpStatus.OK) + @Post('created-by-user') + async getCreatedByPages( + @Body() dto: CreatedByUserDto, + @Body() pagination: PaginationOptions, + @AuthUser() user: User, + ) { + const targetUserId = dto.userId ?? user.id; + + if (dto.spaceId) { + const ability = await this.spaceAbility.createForUser( + user, + dto.spaceId, + ); + + if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) { + throw new ForbiddenException(); + } + } + + return this.pageService.getCreatedByPages(targetUserId, user.id, pagination, dto.spaceId); + } + @HttpCode(HttpStatus.OK) @Post('trash') async getDeletedPages( diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts index 8df9e4bd48..0c8149f991 100644 --- a/apps/server/src/core/page/services/page.service.ts +++ b/apps/server/src/core/page/services/page.service.ts @@ -47,6 +47,10 @@ import { QueueJob, QueueName } from '../../../integrations/queue/constants'; import { EventName } from '../../../common/events/event.contants'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { CollaborationGateway } from '../../../collaboration/collaboration.gateway'; +import { + INTERNAL_LINK_REGEX, + extractPageSlugId, +} from '../../../integrations/export/utils'; import { markdownToHtml } from '@docmost/editor-ext'; import { WatcherService } from '../../watcher/watcher.service'; import { sql } from 'kysely'; @@ -296,7 +300,7 @@ export class PageService { } const result = await executeWithCursorPagination(query, { - perPage: 200, + perPage: pagination.limit, cursor: pagination.cursor, beforeCursor: pagination.beforeCursor, fields: [ @@ -448,6 +452,20 @@ export class PageService { .where('pageId', 'in', pageIdsToMove) .execute(); + // Update page verifications + await trx + .updateTable('pageVerifications') + .set({ spaceId: spaceId }) + .where('pageId', 'in', pageIdsToMove) + .execute(); + + // Update notifications — access follows the page after a move + await trx + .updateTable('notifications') + .set({ spaceId: spaceId }) + .where('pageId', 'in', pageIdsToMove) + .execute(); + // Update attachments await this.attachmentRepo.updateAttachmentsByPageId( { spaceId }, @@ -510,6 +528,11 @@ export class PageService { }); }); + const slugIdMap = new Map(); + for (const [, entry] of pageMap) { + slugIdMap.set(entry.oldSlugId, entry); + } + const attachmentMap = new Map(); const insertablePages: InsertablePage[] = await Promise.all( @@ -576,6 +599,28 @@ export class PageService { node.attrs.slugId = mappedPage.newSlugId; } } + + // Update internal page links in link marks + for (const mark of node.marks) { + if ( + mark.type.name === 'link' && + mark.attrs.internal && + mark.attrs.href + ) { + const match = mark.attrs.href.match(INTERNAL_LINK_REGEX); + if (match) { + const slugId = extractPageSlugId(match[5]); + if (slugId && slugIdMap.has(slugId)) { + const mappedPage = slugIdMap.get(slugId); + //@ts-ignore + mark.attrs.href = mark.attrs.href.replace( + slugId, + mappedPage.newSlugId, + ); + } + } + } + } }); const prosemirrorJson = prosemirrorDoc.toJSON(); @@ -825,6 +870,33 @@ export class PageService { return result; } + async getCreatedByPages( + creatorId: string, + requestingUserId: string, + pagination: PaginationOptions, + spaceId?: string, + ): Promise> { + const result = await this.pageRepo.getCreatedByPages( + creatorId, + requestingUserId, + pagination, + spaceId, + ); + + if (result.items.length > 0) { + const pageIds = result.items.map((p) => p.id); + const accessibleIds = + await this.pagePermissionRepo.filterAccessiblePageIds({ + pageIds, + userId: requestingUserId, + }); + const accessibleSet = new Set(accessibleIds); + result.items = result.items.filter((p) => accessibleSet.has(p.id)); + } + + return result; + } + async getDeletedSpacePages( spaceId: string, userId: string, diff --git a/apps/server/src/core/space/services/space-member.service.ts b/apps/server/src/core/space/services/space-member.service.ts index 0fbab02e7d..bc280f2523 100644 --- a/apps/server/src/core/space/services/space-member.service.ts +++ b/apps/server/src/core/space/services/space-member.service.ts @@ -17,6 +17,7 @@ import { UpdateSpaceMemberRoleDto } from '../dto/update-space-member-role.dto'; import { SpaceRole } from '../../../common/helpers/types/permission'; import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination'; import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo'; +import { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo'; import { executeTx } from '@docmost/db/utils'; import { AuditEvent, AuditResource } from '../../../common/events/audit-events'; import { @@ -31,6 +32,7 @@ export class SpaceMemberService { private groupUserRepo: GroupUserRepo, private spaceRepo: SpaceRepo, private watcherRepo: WatcherRepo, + private favoriteRepo: FavoriteRepo, @InjectKysely() private readonly db: KyselyDB, @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, ) {} @@ -272,6 +274,12 @@ export class SpaceMemberService { dto.spaceId, { trx }, ); + + await this.favoriteRepo.deleteByUsersWithoutSpaceAccess( + affectedUserIds, + dto.spaceId, + { trx }, + ); }); this.auditService.log({ diff --git a/apps/server/src/core/space/space.controller.ts b/apps/server/src/core/space/space.controller.ts index 61ebb38da9..1db39f43b5 100644 --- a/apps/server/src/core/space/space.controller.ts +++ b/apps/server/src/core/space/space.controller.ts @@ -53,7 +53,41 @@ export class SpaceController { pagination: PaginationOptions, @AuthUser() user: User, ) { - return this.spaceMemberService.getUserSpaces(user.id, pagination); + const result = await this.spaceMemberService.getUserSpaces( + user.id, + pagination, + ); + + if (result.items.length > 0) { + const spaceIds = result.items.map((s) => s.id); + const roles = await this.spaceMemberRepo.getUserRolesForSpaces( + user.id, + spaceIds, + ); + + const roleMap = new Map(); + for (const row of roles) { + const existing = roleMap.get(row.spaceId) || []; + existing.push(row.role); + roleMap.set(row.spaceId, existing); + } + + result.items = result.items.map((space) => { + const spaceRoles = roleMap.get(space.id); + const role = spaceRoles + ? findHighestUserSpaceRole( + spaceRoles.map((r) => ({ userId: user.id, role: r })), + ) + : undefined; + + return { + ...space, + membership: { userId: user.id, role }, + }; + }); + } + + return result; } @HttpCode(HttpStatus.OK) diff --git a/apps/server/src/core/watcher/dto/space-watcher.dto.ts b/apps/server/src/core/watcher/dto/space-watcher.dto.ts new file mode 100644 index 0000000000..1df06010d0 --- /dev/null +++ b/apps/server/src/core/watcher/dto/space-watcher.dto.ts @@ -0,0 +1,7 @@ +import { IsString, IsNotEmpty } from 'class-validator'; + +export class SpaceWatcherDto { + @IsString() + @IsNotEmpty() + spaceId: string; +} diff --git a/apps/server/src/core/watcher/space-watcher.controller.ts b/apps/server/src/core/watcher/space-watcher.controller.ts new file mode 100644 index 0000000000..f03a604158 --- /dev/null +++ b/apps/server/src/core/watcher/space-watcher.controller.ts @@ -0,0 +1,104 @@ +import { + Body, + Controller, + ForbiddenException, + HttpCode, + HttpStatus, + NotFoundException, + Post, + UseGuards, +} from '@nestjs/common'; +import { WatcherService } from './watcher.service'; +import { AuthUser } from '../../common/decorators/auth-user.decorator'; +import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { User, Workspace } from '@docmost/db/types/entity.types'; +import { SpaceWatcherDto } from './dto/space-watcher.dto'; +import { SpaceRepo } from '@docmost/db/repos/space/space.repo'; +import SpaceAbilityFactory from '../casl/abilities/space-ability.factory'; +import { + SpaceCaslAction, + SpaceCaslSubject, +} from '../casl/interfaces/space-ability.type'; + +@UseGuards(JwtAuthGuard) +@Controller('spaces') +export class SpaceWatcherController { + constructor( + private readonly watcherService: WatcherService, + private readonly spaceRepo: SpaceRepo, + private readonly spaceAbility: SpaceAbilityFactory, + ) {} + + private async loadSpaceAndAuthorize( + spaceId: string, + user: User, + workspace: Workspace, + ) { + const space = await this.spaceRepo.findById(spaceId, workspace.id); + if (!space) { + throw new NotFoundException('Space not found'); + } + + const ability = await this.spaceAbility.createForUser(user, space.id); + if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Settings)) { + throw new ForbiddenException(); + } + + return space; + } + + @HttpCode(HttpStatus.OK) + @Post('watched-ids') + async getWatchedSpaceIds( + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + return this.watcherService.getWatchedSpaceIds(user.id, workspace.id); + } + + @HttpCode(HttpStatus.OK) + @Post('watch') + async watchSpace( + @Body() dto: SpaceWatcherDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + const space = await this.loadSpaceAndAuthorize(dto.spaceId, user, workspace); + + await this.watcherService.watchSpace(user.id, space.id, workspace.id); + + return { watching: true }; + } + + @HttpCode(HttpStatus.OK) + @Post('unwatch') + async unwatchSpace( + @Body() dto: SpaceWatcherDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + const space = await this.loadSpaceAndAuthorize(dto.spaceId, user, workspace); + + await this.watcherService.unwatchSpace(user.id, space.id); + + return { watching: false }; + } + + @HttpCode(HttpStatus.OK) + @Post('watch-status') + async getWatchStatus( + @Body() dto: SpaceWatcherDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + const space = await this.loadSpaceAndAuthorize(dto.spaceId, user, workspace); + + const watching = await this.watcherService.isWatchingSpace( + user.id, + space.id, + ); + + return { watching }; + } +} diff --git a/apps/server/src/core/watcher/watcher.controller.ts b/apps/server/src/core/watcher/watcher.controller.ts index cd10fa3795..24c317f682 100644 --- a/apps/server/src/core/watcher/watcher.controller.ts +++ b/apps/server/src/core/watcher/watcher.controller.ts @@ -59,7 +59,12 @@ export class WatcherController { await this.pageAccessService.validateCanView(page, user); - await this.watcherService.unwatchPage(user.id, page.id); + await this.watcherService.unwatchPage( + user.id, + page.id, + page.spaceId, + page.workspaceId, + ); return { watching: false }; } diff --git a/apps/server/src/core/watcher/watcher.module.ts b/apps/server/src/core/watcher/watcher.module.ts index 76267b5ab2..357b835207 100644 --- a/apps/server/src/core/watcher/watcher.module.ts +++ b/apps/server/src/core/watcher/watcher.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; import { WatcherService } from './watcher.service'; import { WatcherController } from './watcher.controller'; +import { SpaceWatcherController } from './space-watcher.controller'; import { PageAccessModule } from '../page/page-access/page-access.module'; @Module({ imports: [PageAccessModule], - controllers: [WatcherController], + controllers: [WatcherController, SpaceWatcherController], providers: [WatcherService], exports: [WatcherService], }) diff --git a/apps/server/src/core/watcher/watcher.service.ts b/apps/server/src/core/watcher/watcher.service.ts index 384a0787bd..1f071a70ac 100644 --- a/apps/server/src/core/watcher/watcher.service.ts +++ b/apps/server/src/core/watcher/watcher.service.ts @@ -6,10 +6,14 @@ import { import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { KyselyTransaction } from '@docmost/db/types/kysely.types'; import { InsertableWatcher } from '@docmost/db/types/entity.types'; +import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; @Injectable() export class WatcherService { - constructor(private readonly watcherRepo: WatcherRepo) {} + constructor( + private readonly watcherRepo: WatcherRepo, + private readonly spaceMemberRepo: SpaceMemberRepo, + ) {} async watchPage( userId: string, @@ -50,14 +54,62 @@ export class WatcherService { return this.watcherRepo.insertMany(watchers, trx); } - async unwatchPage(userId: string, pageId: string) { - return this.watcherRepo.mute(userId, pageId); + async unwatchPage( + userId: string, + pageId: string, + spaceId: string, + workspaceId: string, + ) { + return this.watcherRepo.mute(userId, pageId, spaceId, workspaceId); } async isWatchingPage(userId: string, pageId: string): Promise { return this.watcherRepo.isWatching(userId, pageId); } + async watchSpace( + userId: string, + spaceId: string, + workspaceId: string, + trx?: KyselyTransaction, + ) { + const watcher: InsertableWatcher = { + userId, + pageId: null, + spaceId, + workspaceId, + type: WatcherType.SPACE, + addedById: userId, + }; + return this.watcherRepo.upsertSpace(watcher, trx); + } + + async unwatchSpace(userId: string, spaceId: string) { + return this.watcherRepo.deleteSpaceWatch(userId, spaceId); + } + + async getWatchedSpaceIds(userId: string, workspaceId: string) { + const result = await this.watcherRepo.getWatchedSpaceIds(userId, workspaceId); + + const spaceIds = result.items.map((r) => r.spaceId); + + if (spaceIds.length === 0) { + return { items: spaceIds, meta: result.meta }; + } + + const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId); + const spaceSet = new Set(userSpaceIds); + + return { + items: spaceIds.filter((id) => spaceSet.has(id)), + meta: result.meta, + }; + } + + async isWatchingSpace(userId: string, spaceId: string): Promise { + return this.watcherRepo.isWatchingSpace(userId, spaceId); + } + async getPageWatchers(pageId: string, pagination: PaginationOptions) { return this.watcherRepo.findPageWatchers(pageId, pagination); } diff --git a/apps/server/src/core/workspace/dto/update-workspace.dto.ts b/apps/server/src/core/workspace/dto/update-workspace.dto.ts index 9b822f50c1..4b14820843 100644 --- a/apps/server/src/core/workspace/dto/update-workspace.dto.ts +++ b/apps/server/src/core/workspace/dto/update-workspace.dto.ts @@ -46,8 +46,16 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) { @IsBoolean() mcpEnabled: boolean; + @IsOptional() + @IsBoolean() + aiChat: boolean; + @IsOptional() @IsInt() @Min(1) trashRetentionDays: number; + + @IsOptional() + @IsBoolean() + allowMemberTemplates: boolean; } diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts index 2c5ac9a7be..22902fce55 100644 --- a/apps/server/src/core/workspace/services/workspace.service.ts +++ b/apps/server/src/core/workspace/services/workspace.service.ts @@ -49,6 +49,7 @@ import { AuthUser } from 'src/common/decorators/auth-user.decorator'; import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination'; import { ShareRepo } from '@docmost/db/repos/share/share.repo'; import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo'; +import { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo'; import { AuditEvent, AuditResource } from '../../../common/events/audit-events'; import { AUDIT_SERVICE, @@ -72,6 +73,7 @@ export class WorkspaceService { private licenseCheckService: LicenseCheckService, private shareRepo: ShareRepo, private watcherRepo: WatcherRepo, + private favoriteRepo: FavoriteRepo, @InjectKysely() private readonly db: KyselyDB, @InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue, @InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue, @@ -151,7 +153,7 @@ export class WorkspaceService { status = WorkspaceStatus.Active; plan = 'standard'; billingEmail = user.email; - settings = { ai: { generative: true } }; + settings = { ai: { generative: true, chat: true } }; } // create workspace @@ -446,11 +448,41 @@ export class WorkspaceService { ); } + if (typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined') { + const prev = settingsBefore?.templates?.allowMemberTemplates ?? false; + if (prev !== updateWorkspaceDto.allowMemberTemplates) { + before.allowMemberTemplates = prev; + after.allowMemberTemplates = updateWorkspaceDto.allowMemberTemplates; + } + await this.workspaceRepo.updateTemplateSettings( + workspaceId, + 'allowMemberTemplates', + updateWorkspaceDto.allowMemberTemplates, + trx, + ); + } + + if (typeof updateWorkspaceDto.aiChat !== 'undefined') { + const prev = settingsBefore?.ai?.chat ?? false; + if (prev !== updateWorkspaceDto.aiChat) { + before.aiChat = prev; + after.aiChat = updateWorkspaceDto.aiChat; + } + await this.workspaceRepo.updateAiSettings( + workspaceId, + 'chat', + updateWorkspaceDto.aiChat, + trx, + ); + } + delete updateWorkspaceDto.restrictApiToAdmins; delete updateWorkspaceDto.aiSearch; delete updateWorkspaceDto.generativeAi; delete updateWorkspaceDto.disablePublicSharing; delete updateWorkspaceDto.mcpEnabled; + delete updateWorkspaceDto.allowMemberTemplates; + delete updateWorkspaceDto.aiChat; await this.workspaceRepo.updateWorkspace( updateWorkspaceDto, @@ -785,6 +817,10 @@ export class WorkspaceService { trx, }); + await this.favoriteRepo.deleteByUserAndWorkspace(userId, workspaceId, { + trx, + }); + await this.userSessionRepo.revokeByUserId(userId, workspaceId, trx); }); diff --git a/apps/server/src/database/database.module.ts b/apps/server/src/database/database.module.ts index 548c079ca2..67b60d2499 100644 --- a/apps/server/src/database/database.module.ts +++ b/apps/server/src/database/database.module.ts @@ -22,6 +22,8 @@ import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo'; import { ShareRepo } from '@docmost/db/repos/share/share.repo'; import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo'; import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo'; +import { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo'; +import { TemplateRepo } from '@docmost/db/repos/template/template.repo'; import { PageListener } from '@docmost/db/listeners/page.listener'; import { PostgresJSDialect } from 'kysely-postgres-js'; import * as postgres from 'postgres'; @@ -76,6 +78,7 @@ import { ApiKeyRepo } from './repos/api-key/api-key.repo'; PagePermissionRepo, PageHistoryRepo, CommentRepo, + FavoriteRepo, AttachmentRepo, UserTokenRepo, UserSessionRepo, @@ -84,6 +87,7 @@ import { ApiKeyRepo } from './repos/api-key/api-key.repo'; ApiKeyRepo, NotificationRepo, WatcherRepo, + TemplateRepo, PageListener, ], exports: [ @@ -97,6 +101,7 @@ import { ApiKeyRepo } from './repos/api-key/api-key.repo'; PagePermissionRepo, PageHistoryRepo, CommentRepo, + FavoriteRepo, AttachmentRepo, UserTokenRepo, UserSessionRepo, @@ -105,6 +110,7 @@ import { ApiKeyRepo } from './repos/api-key/api-key.repo'; ApiKeyRepo, NotificationRepo, WatcherRepo, + TemplateRepo, ], }) export class DatabaseModule implements OnApplicationBootstrap { diff --git a/apps/server/src/database/migrations/20260409T132415-ai-chat.ts b/apps/server/src/database/migrations/20260409T132415-ai-chat.ts new file mode 100644 index 0000000000..28b595f1dd --- /dev/null +++ b/apps/server/src/database/migrations/20260409T132415-ai-chat.ts @@ -0,0 +1,118 @@ +import { type Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('ai_chats') + .ifNotExists() + .addColumn('id', 'uuid', (col) => + col.primaryKey().defaultTo(sql`gen_uuid_v7()`), + ) + .addColumn('workspace_id', 'uuid', (col) => + col.references('workspaces.id').onDelete('cascade').notNull(), + ) + .addColumn('creator_id', 'uuid', (col) => + col.references('users.id').notNull(), + ) + .addColumn('title', 'varchar', (col) => col) + .addColumn('created_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addColumn('updated_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addColumn('deleted_at', 'timestamptz', (col) => col) + .execute(); + + await db.schema + .createIndex('idx_ai_chats_workspace_creator') + .ifNotExists() + .on('ai_chats') + .columns(['workspace_id', 'creator_id', 'id']) + .execute(); + + await db.schema + .createTable('ai_chat_messages') + .ifNotExists() + .addColumn('id', 'uuid', (col) => + col.primaryKey().defaultTo(sql`gen_uuid_v7()`), + ) + .addColumn('chat_id', 'uuid', (col) => + col.references('ai_chats.id').onDelete('cascade').notNull(), + ) + .addColumn('workspace_id', 'uuid', (col) => + col.references('workspaces.id').onDelete('cascade').notNull(), + ) + .addColumn('user_id', 'uuid', (col) => + col.references('users.id').onDelete('set null'), + ) + .addColumn('role', 'varchar', (col) => col.notNull()) + .addColumn('content', 'text', (col) => col) + .addColumn('tool_calls', 'jsonb', (col) => col) + .addColumn('metadata', 'jsonb', (col) => col) + .addColumn('tsv', sql`tsvector`, (col) => col) + .addColumn('created_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addColumn('updated_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addColumn('deleted_at', 'timestamptz', (col) => col) + .execute(); + + await db.schema + .createIndex('idx_ai_chat_messages_chat_id') + .ifNotExists() + .on('ai_chat_messages') + .columns(['chat_id', 'id']) + .execute(); + + await db.schema + .createIndex('idx_ai_chat_messages_tsv') + .ifNotExists() + .on('ai_chat_messages') + .using('GIN') + .column('tsv') + .execute(); + + //ts-vector + await sql` + CREATE OR REPLACE FUNCTION ai_chat_messages_tsvector_trigger() RETURNS trigger AS $$ + BEGIN + NEW.tsv := to_tsvector('english', f_unaccent(substring(coalesce(NEW.content, ''), 1, 100000))); + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + `.execute(db); + + await sql` + CREATE OR REPLACE TRIGGER ai_chat_messages_tsvector_update + BEFORE INSERT OR UPDATE ON ai_chat_messages + FOR EACH ROW EXECUTE FUNCTION ai_chat_messages_tsvector_trigger(); + `.execute(db); + + await db.schema + .alterTable('attachments') + .addColumn('ai_chat_id', 'uuid', (col) => col) + .execute(); + + await db.schema + .createIndex('idx_attachments_ai_chat_id') + .ifNotExists() + .on('attachments') + .column('ai_chat_id') + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropIndex('idx_attachments_ai_chat_id').execute(); + await db.schema.alterTable('attachments').dropColumn('ai_chat_id').execute(); + + await sql`DROP TRIGGER IF EXISTS ai_chat_messages_tsvector_update ON ai_chat_messages`.execute( + db, + ); + await sql`DROP FUNCTION IF EXISTS ai_chat_messages_tsvector_trigger`.execute( + db, + ); + await db.schema.dropTable('ai_chat_messages').execute(); + await db.schema.dropTable('ai_chats').execute(); +} diff --git a/apps/server/src/database/migrations/20260412T135891-templates.ts b/apps/server/src/database/migrations/20260412T135891-templates.ts new file mode 100644 index 0000000000..7684be6d7c --- /dev/null +++ b/apps/server/src/database/migrations/20260412T135891-templates.ts @@ -0,0 +1,76 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('templates') + .addColumn('id', 'uuid', (col) => + col.primaryKey().defaultTo(sql`gen_uuid_v7()`), + ) + .addColumn('title', 'varchar') + .addColumn('description', 'text') + .addColumn('content', 'jsonb') + .addColumn('ydoc', 'bytea') + .addColumn('icon', 'varchar') + .addColumn('space_id', 'uuid', (col) => + col.references('spaces.id').onDelete('cascade'), + ) + .addColumn('workspace_id', 'uuid', (col) => + col.notNull().references('workspaces.id').onDelete('cascade'), + ) + .addColumn('creator_id', 'uuid', (col) => + col.references('users.id').onDelete('set null'), + ) + .addColumn('last_updated_by_id', 'uuid', (col) => + col.references('users.id').onDelete('set null'), + ) + .addColumn('collaborator_ids', sql`uuid[]`) + .addColumn('text_content', 'text', (col) => col) + .addColumn('tsv', sql`tsvector`, (col) => col) + .addColumn('created_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addColumn('updated_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addColumn('deleted_at', 'timestamptz') + .execute(); + + await db.schema + .createIndex('idx_templates_workspace_id') + .on('templates') + .columns(['workspace_id']) + .execute(); + + await db.schema + .createIndex('idx_templates_space_id') + .on('templates') + .columns(['space_id']) + .execute(); + + await db.schema + .createIndex('templates_tsv_idx') + .on('templates') + .using('GIN') + .column('tsv') + .execute(); + + await sql` + CREATE OR REPLACE FUNCTION templates_tsvector_trigger() RETURNS trigger AS $$ + begin + new.tsv := + setweight(to_tsvector('english', f_unaccent(coalesce(new.title, ''))), 'A') || + setweight(to_tsvector('english', f_unaccent(substring(coalesce(new.text_content, ''), 1, 1000000))), 'B'); + return new; + end; + $$ LANGUAGE plpgsql; + `.execute(db); + + await sql`CREATE OR REPLACE TRIGGER templates_tsvector_update BEFORE INSERT OR UPDATE + ON templates FOR EACH ROW EXECUTE FUNCTION templates_tsvector_trigger();`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP TRIGGER IF EXISTS templates_tsvector_update ON templates`.execute(db); + await sql`DROP FUNCTION IF EXISTS templates_tsvector_trigger`.execute(db); + await db.schema.dropTable('templates').execute(); +} diff --git a/apps/server/src/database/migrations/20260412T162318-favorites.ts b/apps/server/src/database/migrations/20260412T162318-favorites.ts new file mode 100644 index 0000000000..0c5cfdaf28 --- /dev/null +++ b/apps/server/src/database/migrations/20260412T162318-favorites.ts @@ -0,0 +1,63 @@ +import { type Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('favorites') + .addColumn('id', 'uuid', (col) => + col.primaryKey().defaultTo(sql`gen_uuid_v7()`), + ) + .addColumn('user_id', 'uuid', (col) => + col.references('users.id').onDelete('cascade').notNull(), + ) + .addColumn('page_id', 'uuid', (col) => + col.references('pages.id').onDelete('cascade'), + ) + .addColumn('space_id', 'uuid', (col) => + col.references('spaces.id').onDelete('cascade'), + ) + .addColumn('template_id', 'uuid', (col) => + col.references('templates.id').onDelete('cascade'), + ) + .addColumn('type', 'varchar', (col) => col.notNull()) + .addColumn('workspace_id', 'uuid', (col) => + col.references('workspaces.id').onDelete('cascade').notNull(), + ) + .addColumn('created_at', 'timestamptz', (col) => + col.defaultTo(sql`now()`).notNull(), + ) + .execute(); + + await db.schema + .createIndex('idx_favorites_user_page') + .on('favorites') + .columns(['user_id', 'page_id']) + .unique() + .where('page_id', 'is not', null) + .execute(); + + await db.schema + .createIndex('idx_favorites_user_space') + .on('favorites') + .columns(['user_id', 'space_id']) + .unique() + .where('space_id', 'is not', null) + .execute(); + + await db.schema + .createIndex('idx_favorites_user_template') + .on('favorites') + .columns(['user_id', 'template_id']) + .unique() + .where('template_id', 'is not', null) + .execute(); + + await db.schema + .createIndex('idx_favorites_user_workspace_type') + .on('favorites') + .columns(['user_id', 'workspace_id', 'type']) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('favorites').execute(); +} diff --git a/apps/server/src/database/migrations/20260413T121647-page-verifications.ts b/apps/server/src/database/migrations/20260413T121647-page-verifications.ts new file mode 100644 index 0000000000..9d50150668 --- /dev/null +++ b/apps/server/src/database/migrations/20260413T121647-page-verifications.ts @@ -0,0 +1,117 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('page_verifications') + .addColumn('id', 'uuid', (col) => + col.primaryKey().defaultTo(sql`gen_uuid_v7()`), + ) + .addColumn('page_id', 'uuid', (col) => + col.notNull().unique().references('pages.id').onDelete('cascade'), + ) + .addColumn('workspace_id', 'uuid', (col) => + col.notNull().references('workspaces.id').onDelete('cascade'), + ) + .addColumn('space_id', 'uuid', (col) => + col.notNull().references('spaces.id').onDelete('cascade'), + ) + .addColumn('type', 'varchar', (col) => col.notNull().defaultTo('expiring')) + .addColumn('status', 'varchar') + .addColumn('mode', 'varchar') + .addColumn('period_amount', 'integer') + .addColumn('period_unit', 'varchar') + .addColumn('verified_at', 'timestamptz') + .addColumn('verified_by_id', 'uuid', (col) => + col.references('users.id').onDelete('set null'), + ) + .addColumn('expires_at', 'timestamptz') + .addColumn('requested_at', 'timestamptz') + .addColumn('requested_by_id', 'uuid', (col) => + col.references('users.id').onDelete('set null'), + ) + .addColumn('rejected_at', 'timestamptz') + .addColumn('rejected_by_id', 'uuid', (col) => + col.references('users.id').onDelete('set null'), + ) + .addColumn('rejection_comment', 'text') + .addColumn('data', 'jsonb') + .addColumn('creator_id', 'uuid', (col) => + col.references('users.id').onDelete('set null'), + ) + .addColumn('created_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addColumn('updated_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .execute(); + + await db.schema + .createTable('page_verifiers') + .addColumn('id', 'uuid', (col) => + col.primaryKey().defaultTo(sql`gen_uuid_v7()`), + ) + .addColumn('page_verification_id', 'uuid', (col) => + col.notNull().references('page_verifications.id').onDelete('cascade'), + ) + .addColumn('user_id', 'uuid', (col) => + col.notNull().references('users.id').onDelete('cascade'), + ) + .addColumn('is_primary', 'boolean', (col) => col.notNull().defaultTo(false)) + .addColumn('added_by_id', 'uuid', (col) => + col.references('users.id').onDelete('set null'), + ) + .addColumn('created_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addUniqueConstraint('page_verifiers_verification_user_unique', [ + 'page_verification_id', + 'user_id', + ]) + .execute(); + + await db.schema + .createIndex('idx_page_verifications_expires_at') + .ifNotExists() + .on('page_verifications') + .column('expires_at') + .where('expires_at', 'is not', null) + .execute(); + + await db.schema + .createIndex('idx_page_verifications_workspace_id_id') + .ifNotExists() + .on('page_verifications') + .columns(['workspace_id', 'id desc']) + .execute(); + + await db.schema + .createIndex('idx_page_verifications_space_id') + .ifNotExists() + .on('page_verifications') + .column('space_id') + .execute(); + + await db.schema + .createIndex('idx_page_verifiers_user_id') + .ifNotExists() + .on('page_verifiers') + .column('user_id') + .execute(); + + await db.schema + .alterTable('notifications') + .addColumn('page_verification_id', 'uuid', (col) => + col.references('page_verifications.id').onDelete('cascade'), + ) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema + .alterTable('notifications') + .dropColumn('page_verification_id') + .execute(); + await db.schema.dropTable('page_verifiers').ifExists().execute(); + await db.schema.dropTable('page_verifications').ifExists().execute(); +} diff --git a/apps/server/src/database/migrations/20260414T124451-update-file_tasks.ts b/apps/server/src/database/migrations/20260414T124451-update-file_tasks.ts new file mode 100644 index 0000000000..4e95cab720 --- /dev/null +++ b/apps/server/src/database/migrations/20260414T124451-update-file_tasks.ts @@ -0,0 +1,32 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .alterTable('file_tasks') + .addColumn('page_id', 'uuid', (col) => + col.references('pages.id').onDelete('set null').ifNotExists(), + ) + .execute(); + + await db.schema + .alterTable('file_tasks') + .addColumn('metadata', 'jsonb', (col) => col.ifNotExists()) + .execute(); + + await db.schema + .createIndex('idx_file_tasks_page_export') + .ifNotExists() + .on('file_tasks') + .columns(['page_id', 'workspace_id']) + .where(sql.ref('type'), '=', 'export') + .where(sql.ref('deleted_at'), 'is', null) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropIndex('idx_file_tasks_page_export').execute(); + + await db.schema.alterTable('file_tasks').dropColumn('page_id').execute(); + + await db.schema.alterTable('file_tasks').dropColumn('metadata').execute(); +} diff --git a/apps/server/src/database/repos/attachment/attachment.repo.ts b/apps/server/src/database/repos/attachment/attachment.repo.ts index d1c145ed00..bf711f4327 100644 --- a/apps/server/src/database/repos/attachment/attachment.repo.ts +++ b/apps/server/src/database/repos/attachment/attachment.repo.ts @@ -7,6 +7,7 @@ import { InsertableAttachment, UpdatableAttachment, } from '@docmost/db/types/entity.types'; +import { AttachmentType } from '../../../core/attachment/attachment.constants'; import { jsonObjectFrom } from 'kysely/helpers/postgres'; @Injectable() @@ -24,6 +25,7 @@ export class AttachmentRepo { 'creatorId', 'pageId', 'spaceId', + 'aiChatId', 'workspaceId', 'createdAt', 'updatedAt', @@ -45,6 +47,21 @@ export class AttachmentRepo { .executeTakeFirst(); } + async findByIdWithContent( + attachmentId: string, + opts?: { + trx?: KyselyTransaction; + }, + ): Promise { + const db = dbOrTx(this.db, opts?.trx); + + return db + .selectFrom('attachments') + .select([...this.baseFields, 'textContent']) + .where('id', '=', attachmentId) + .executeTakeFirst(); + } + async insertAttachment( insertableAttachment: InsertableAttachment, trx?: KyselyTransaction, @@ -73,6 +90,21 @@ export class AttachmentRepo { .execute(); } + async findByAiChatId( + aiChatId: string, + opts?: { + trx?: KyselyTransaction; + }, + ): Promise { + const db = dbOrTx(this.db, opts?.trx); + + return db + .selectFrom('attachments') + .select(this.baseFields) + .where('aiChatId', '=', aiChatId) + .execute(); + } + updateAttachmentsByPageId( updatableAttachment: UpdatableAttachment, pageIds: string[], @@ -98,6 +130,25 @@ export class AttachmentRepo { .executeTakeFirst(); } + async claimAttachmentsForChat( + attachmentIds: string[], + aiChatId: string, + creatorId: string, + workspaceId: string, + ): Promise { + if (attachmentIds.length === 0) return; + + await this.db + .updateTable('attachments') + .set({ aiChatId }) + .where('id', 'in', attachmentIds) + .where('creatorId', '=', creatorId) + .where('workspaceId', '=', workspaceId) + .where('type', '=', AttachmentType.Chat) + .where('aiChatId', 'is', null) + .execute(); + } + async deleteAttachmentById(attachmentId: string): Promise { await this.db .deleteFrom('attachments') diff --git a/apps/server/src/database/repos/favorite/favorite.repo.ts b/apps/server/src/database/repos/favorite/favorite.repo.ts new file mode 100644 index 0000000000..54b21135f1 --- /dev/null +++ b/apps/server/src/database/repos/favorite/favorite.repo.ts @@ -0,0 +1,292 @@ +import { Injectable } from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; +import { InsertableFavorite, Favorite } from '@docmost/db/types/entity.types'; +import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; +import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination'; +import { jsonObjectFrom } from 'kysely/helpers/postgres'; +import { ExpressionBuilder, SelectQueryBuilder, sql } from 'kysely'; +import { DB } from '@docmost/db/types/db'; +import { dbOrTx } from '@docmost/db/utils'; + +export const FavoriteType = { + PAGE: 'page', + SPACE: 'space', + TEMPLATE: 'template', +} as const; + +export type FavoriteType = (typeof FavoriteType)[keyof typeof FavoriteType]; + +@Injectable() +export class FavoriteRepo { + constructor(@InjectKysely() private readonly db: KyselyDB) {} + + async insert(favorite: InsertableFavorite): Promise { + try { + return await this.db + .insertInto('favorites') + .values(favorite) + .returningAll() + .executeTakeFirst(); + } catch (err: any) { + if (err?.code === '23505') return undefined; + throw err; + } + } + + async deleteByUserAndPage(userId: string, pageId: string): Promise { + await this.db + .deleteFrom('favorites') + .where('userId', '=', userId) + .where('pageId', '=', pageId) + .execute(); + } + + async deleteByUserAndSpace(userId: string, spaceId: string): Promise { + await this.db + .deleteFrom('favorites') + .where('userId', '=', userId) + .where('spaceId', '=', spaceId) + .where('type', '=', FavoriteType.SPACE) + .execute(); + } + + async deleteByUserAndTemplate( + userId: string, + templateId: string, + ): Promise { + await this.db + .deleteFrom('favorites') + .where('userId', '=', userId) + .where('templateId', '=', templateId) + .execute(); + } + + async getFavoriteIds( + userId: string, + workspaceId: string, + type: FavoriteType, + spaceId?: string, + ): Promise<{ items: string[]; meta: any }> { + const idColumn = + type === FavoriteType.PAGE + ? 'pageId' + : type === FavoriteType.SPACE + ? 'spaceId' + : 'templateId'; + + let query = this.db + .selectFrom('favorites') + .select(['favorites.id', `favorites.${idColumn} as entityId`]) + .where('favorites.userId', '=', userId) + .where('favorites.workspaceId', '=', workspaceId) + .where('favorites.type', '=', type); + + if (spaceId) { + query = this.applySpaceFilter(query, type, spaceId); + } + + const result = await executeWithCursorPagination(query, { + perPage: 250, + fields: [{ expression: 'favorites.id', direction: 'desc' }], + parseCursor: (cursor) => ({ id: cursor.id }), + }); + + return { + items: result.items + .map((r) => (r as any).entityId as string) + .filter(Boolean), + meta: result.meta, + }; + } + + async findUserFavorites( + userId: string, + workspaceId: string, + pagination: PaginationOptions, + type?: FavoriteType, + spaceId?: string, + ) { + let query = this.db + .selectFrom('favorites') + .selectAll('favorites') + .where('favorites.userId', '=', userId) + .where('favorites.workspaceId', '=', workspaceId); + + if (type) { + query = query.where('favorites.type', '=', type); + } + + if (spaceId) { + query = this.applySpaceFilter(query, type, spaceId); + } + + if (type === FavoriteType.PAGE || !type) { + query = query.select((eb) => this.withPage(eb)); + } + + if (type === FavoriteType.PAGE) { + query = query.select((eb) => this.withPageSpace(eb)); + } else if (type === FavoriteType.SPACE) { + query = query.select((eb) => this.withSpace(eb)); + } else { + query = query.select((eb) => this.withSpaceResolved(eb)); + } + + if (type === FavoriteType.TEMPLATE || !type) { + query = query.select((eb) => this.withTemplate(eb)); + } + + return executeWithCursorPagination(query, { + perPage: pagination.limit, + cursor: pagination.cursor, + beforeCursor: pagination.beforeCursor, + fields: [{ expression: 'favorites.id', direction: 'desc' }], + parseCursor: (cursor) => ({ + id: cursor.id, + }), + }); + } + + async deleteByUsersWithoutSpaceAccess( + userIds: string[], + spaceId: string, + opts?: { trx?: KyselyTransaction }, + ): Promise { + if (userIds.length === 0) return; + + const { trx } = opts; + const db = dbOrTx(this.db, trx); + + const usersWithAccess = db + .selectFrom('spaceMembers') + .select('userId') + .where('spaceId', '=', spaceId) + .where('userId', 'is not', null) + .union( + db + .selectFrom('spaceMembers') + .innerJoin('groupUsers', 'groupUsers.groupId', 'spaceMembers.groupId') + .select('groupUsers.userId') + .where('spaceMembers.spaceId', '=', spaceId), + ); + + await db + .deleteFrom('favorites') + .where('userId', 'in', userIds) + .where('spaceId', '=', spaceId) + .where('userId', 'not in', usersWithAccess) + .execute(); + } + + async deleteByUserAndWorkspace( + userId: string, + workspaceId: string, + opts?: { trx?: KyselyTransaction }, + ): Promise { + const { trx } = opts; + const db = dbOrTx(this.db, trx); + + await db + .deleteFrom('favorites') + .where('userId', '=', userId) + .where('workspaceId', '=', workspaceId) + .execute(); + } + + private applySpaceFilter>( + query: Q, + type: FavoriteType | undefined, + spaceId: string, + ): Q { + if (type === FavoriteType.PAGE) { + return query.where((eb: any) => + eb.exists( + eb + .selectFrom('pages') + .select(sql`1`.as('one')) + .whereRef('pages.id', '=', 'favorites.pageId') + .where('pages.spaceId', '=', spaceId), + ), + ) as Q; + } + if (type === FavoriteType.SPACE) { + return query.where('favorites.spaceId' as any, '=', spaceId) as Q; + } + if (type === FavoriteType.TEMPLATE) { + return query.where((eb: any) => + eb.exists( + eb + .selectFrom('templates') + .select(sql`1`.as('one')) + .whereRef('templates.id', '=', 'favorites.templateId') + .where('templates.spaceId', '=', spaceId), + ), + ) as Q; + } + return query; + } + + private withPage(eb: ExpressionBuilder) { + return jsonObjectFrom( + eb + .selectFrom('pages') + .select([ + 'pages.id', + 'pages.slugId', + 'pages.title', + 'pages.icon', + 'pages.spaceId', + ]) + .whereRef('pages.id', '=', 'favorites.pageId'), + ).as('page'); + } + + private withSpace(eb: ExpressionBuilder) { + return jsonObjectFrom( + eb + .selectFrom('spaces') + .select(['spaces.id', 'spaces.name', 'spaces.slug', 'spaces.logo']) + .whereRef('spaces.id', '=', 'favorites.spaceId'), + ).as('space'); + } + + private withPageSpace(eb: ExpressionBuilder) { + return jsonObjectFrom( + eb + .selectFrom('spaces') + .innerJoin('pages', 'pages.spaceId', 'spaces.id') + .select(['spaces.id', 'spaces.name', 'spaces.slug', 'spaces.logo']) + .whereRef('pages.id', '=', 'favorites.pageId'), + ).as('space'); + } + + private withSpaceResolved(eb: ExpressionBuilder) { + return jsonObjectFrom( + eb + .selectFrom('spaces') + .select(['spaces.id', 'spaces.name', 'spaces.slug', 'spaces.logo']) + .where(({ or, ref }) => + or([ + sql`${ref('spaces.id')} = ${ref('favorites.spaceId')}`, + sql`${ref('spaces.id')} = (SELECT pages.space_id FROM pages WHERE pages.id = ${ref('favorites.pageId')})`, + ]), + ), + ).as('space'); + } + + private withTemplate(eb: ExpressionBuilder) { + return jsonObjectFrom( + eb + .selectFrom('templates') + .select([ + 'templates.id', + 'templates.title', + 'templates.description', + 'templates.icon', + 'templates.spaceId', + ]) + .whereRef('templates.id', '=', 'favorites.templateId'), + ).as('template'); + } +} diff --git a/apps/server/src/database/repos/notification/notification.repo.ts b/apps/server/src/database/repos/notification/notification.repo.ts index 2914dbfc50..9abca4234d 100644 --- a/apps/server/src/database/repos/notification/notification.repo.ts +++ b/apps/server/src/database/repos/notification/notification.repo.ts @@ -11,7 +11,10 @@ import { ExpressionBuilder } from 'kysely'; import { DB } from '@docmost/db/types/db'; import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; -import { NotificationTab, NotificationType } from '../../../core/notification/notification.constants'; +import { + NotificationTab, + NotificationType, +} from '../../../core/notification/notification.constants'; @Injectable() export class NotificationRepo { @@ -43,7 +46,11 @@ export class NotificationRepo { .where((eb) => eb.or([ eb('spaceId', 'is', null), - eb('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)), + eb( + 'spaceId', + 'in', + this.spaceMemberRepo.getUserSpaceIdsQuery(userId), + ), ]), ); @@ -62,6 +69,14 @@ export class NotificationRepo { }); } + async insert(notification: InsertableNotification): Promise { + return this.db + .insertInto('notifications') + .values(notification) + .returningAll() + .executeTakeFirst(); + } + async getUnreadCount(userId: string): Promise { const result = await this.db .selectFrom('notifications') @@ -71,7 +86,11 @@ export class NotificationRepo { .where((eb) => eb.or([ eb('spaceId', 'is', null), - eb('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)), + eb( + 'spaceId', + 'in', + this.spaceMemberRepo.getUserSpaceIdsQuery(userId), + ), ]), ) .executeTakeFirst(); @@ -79,14 +98,6 @@ export class NotificationRepo { return Number(result?.count ?? 0); } - async insert(notification: InsertableNotification): Promise { - return this.db - .insertInto('notifications') - .values(notification) - .returningAll() - .executeTakeFirst(); - } - async markAsRead(notificationId: string, userId: string): Promise { await this.db .updateTable('notifications') @@ -94,12 +105,6 @@ export class NotificationRepo { .where('id', '=', notificationId) .where('userId', '=', userId) .where('readAt', 'is', null) - .where((eb) => - eb.or([ - eb('spaceId', 'is', null), - eb('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)), - ]), - ) .execute(); } @@ -116,36 +121,24 @@ export class NotificationRepo { .where('id', 'in', notificationIds) .where('userId', '=', userId) .where('readAt', 'is', null) - .where((eb) => - eb.or([ - eb('spaceId', 'is', null), - eb('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)), - ]), - ) .execute(); } - async markAsEmailed(notificationId: string): Promise { + async markAllAsRead(userId: string): Promise { await this.db .updateTable('notifications') - .set({ emailedAt: new Date() }) - .where('id', '=', notificationId) - .where('emailedAt', 'is', null) + .set({ readAt: new Date() }) + .where('userId', '=', userId) + .where('readAt', 'is', null) .execute(); } - async markAllAsRead(userId: string): Promise { + async markAsEmailed(notificationId: string): Promise { await this.db .updateTable('notifications') - .set({ readAt: new Date() }) - .where('userId', '=', userId) - .where('readAt', 'is', null) - .where((eb) => - eb.or([ - eb('spaceId', 'is', null), - eb('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)), - ]), - ) + .set({ emailedAt: new Date() }) + .where('id', '=', notificationId) + .where('emailedAt', 'is', null) .execute(); } diff --git a/apps/server/src/database/repos/page/page.repo.ts b/apps/server/src/database/repos/page/page.repo.ts index 1449031287..a35234f666 100644 --- a/apps/server/src/database/repos/page/page.repo.ts +++ b/apps/server/src/database/repos/page/page.repo.ts @@ -324,6 +324,35 @@ export class PageRepo { }); } + async getCreatedByPages(creatorId: string, requestingUserId: string, pagination: PaginationOptions, spaceId?: string) { + let query = this.db + .selectFrom('pages') + .select(this.baseFields) + .select((eb) => this.withSpace(eb)) + .where('creatorId', '=', creatorId) + .where('deletedAt', 'is', null); + + if (spaceId) { + query = query.where('spaceId', '=', spaceId); + } else { + query = query.where('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(requestingUserId)); + } + + return executeWithCursorPagination(query, { + perPage: pagination.limit, + cursor: pagination.cursor, + beforeCursor: pagination.beforeCursor, + fields: [ + { expression: 'updatedAt', direction: 'desc' }, + { expression: 'id', direction: 'desc' }, + ], + parseCursor: (cursor) => ({ + updatedAt: new Date(cursor.updatedAt), + id: cursor.id, + }), + }); + } + async getDeletedPagesInSpace(spaceId: string, pagination: PaginationOptions) { const query = this.db .selectFrom('pages') diff --git a/apps/server/src/database/repos/space/space-member.repo.ts b/apps/server/src/database/repos/space/space-member.repo.ts index 908c8497db..50961802f8 100644 --- a/apps/server/src/database/repos/space/space-member.repo.ts +++ b/apps/server/src/database/repos/space/space-member.repo.ts @@ -290,6 +290,32 @@ export class SpaceMemberRepo { return membership.map((space) => space.id); } + async getUserRolesForSpaces( + userId: string, + spaceIds: string[], + ): Promise<{ spaceId: string; role: string }[]> { + if (spaceIds.length === 0) return []; + + return this.db + .selectFrom('spaceMembers') + .select(['spaceId', 'role']) + .where('userId', '=', userId) + .where('spaceId', 'in', spaceIds) + .unionAll( + this.db + .selectFrom('spaceMembers') + .innerJoin( + 'groupUsers', + 'groupUsers.groupId', + 'spaceMembers.groupId', + ) + .select(['spaceMembers.spaceId', 'spaceMembers.role']) + .where('groupUsers.userId', '=', userId) + .where('spaceMembers.spaceId', 'in', spaceIds), + ) + .execute(); + } + async getUserSpaces(userId: string, pagination: PaginationOptions) { let query = this.db .selectFrom('spaces') diff --git a/apps/server/src/database/repos/template/template.repo.ts b/apps/server/src/database/repos/template/template.repo.ts new file mode 100644 index 0000000000..583cc9ed74 --- /dev/null +++ b/apps/server/src/database/repos/template/template.repo.ts @@ -0,0 +1,160 @@ +import { Injectable } from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; +import { dbOrTx } from '@docmost/db/utils'; +import { + InsertableTemplate, + Page, + Template, + UpdatableTemplate, +} from '@docmost/db/types/entity.types'; +import { PaginationOptions } from '../../pagination/pagination-options'; +import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination'; +import { ExpressionBuilder, sql } from 'kysely'; +import { DB } from '@docmost/db/types/db'; +import { jsonObjectFrom } from 'kysely/helpers/postgres'; + +@Injectable() +export class TemplateRepo { + private baseFields: Array = [ + 'id', + 'title', + 'description', + 'icon', + 'spaceId', + 'workspaceId', + 'creatorId', + 'lastUpdatedById', + 'createdAt', + 'updatedAt', + ]; + + constructor(@InjectKysely() private readonly db: KyselyDB) {} + + async findById( + templateId: string, + workspaceId: string, + opts?: { includeContent?: boolean; trx?: KyselyTransaction }, + ): Promise