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 (
<>
-
+ {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 && (
+
+ }
+ size="sm"
+ >
+ {t("View all spaces")}
+
+
+ )}
+ >
+ );
+}
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 {
+ const db = dbOrTx(this.db, opts?.trx);
+
+ const query = db
+ .selectFrom('templates')
+ .select(this.baseFields)
+ .$if(opts?.includeContent ?? false, (qb) => qb.select('content'))
+ .select((eb) => [this.withCreator(eb)])
+ .where('id', '=', templateId)
+ .where('workspaceId', '=', workspaceId);
+
+ return query.executeTakeFirst() as Promise;
+ }
+
+ async findTemplates(
+ workspaceId: string,
+ accessibleSpaceIds: string[],
+ pagination: PaginationOptions,
+ opts?: { spaceId?: string },
+ ) {
+ let query = this.db
+ .selectFrom('templates')
+ .select(this.baseFields)
+ .select((eb) => [this.withCreator(eb)])
+ .where('workspaceId', '=', workspaceId);
+
+ if (opts?.spaceId) {
+ if (!accessibleSpaceIds.includes(opts.spaceId)) {
+ query = query.where('spaceId', 'is', null);
+ } else {
+ query = query.where((eb) =>
+ eb.or([eb('spaceId', '=', opts.spaceId), eb('spaceId', 'is', null)]),
+ );
+ }
+ } else {
+ query = query.where((eb) =>
+ eb.or([
+ eb('spaceId', 'is', null),
+ ...(accessibleSpaceIds.length > 0
+ ? [eb('spaceId', 'in', accessibleSpaceIds)]
+ : []),
+ ]),
+ );
+ }
+
+ if (pagination.query) {
+ const searchTerm = `%${pagination.query}%`;
+ query = query.where((eb) =>
+ eb.or([
+ eb(sql`f_unaccent(title)`, 'ilike', sql`f_unaccent(${searchTerm})`),
+ eb(
+ sql`f_unaccent(description)`,
+ 'ilike',
+ sql`f_unaccent(${searchTerm})`,
+ ),
+ ]),
+ );
+ }
+
+ return executeWithCursorPagination(query, {
+ perPage: pagination.limit,
+ cursor: pagination.cursor,
+ beforeCursor: pagination.beforeCursor,
+ fields: [
+ { expression: 'title', direction: 'asc' },
+ { expression: 'id', direction: 'asc' },
+ ],
+ parseCursor: (cursor) => ({
+ title: cursor.title,
+ id: cursor.id,
+ }),
+ });
+ }
+
+ async insertTemplate(
+ insertableTemplate: InsertableTemplate,
+ trx?: KyselyTransaction,
+ ): Promise<{ id: string }> {
+ const db = dbOrTx(this.db, trx);
+ return db
+ .insertInto('templates')
+ .values(insertableTemplate)
+ .returning('id')
+ .executeTakeFirst();
+ }
+
+ async updateTemplate(
+ updatableTemplate: UpdatableTemplate,
+ templateId: string,
+ workspaceId: string,
+ trx?: KyselyTransaction,
+ ): Promise {
+ const db = dbOrTx(this.db, trx);
+ await db
+ .updateTable('templates')
+ .set({ ...updatableTemplate, updatedAt: new Date() })
+ .where('id', '=', templateId)
+ .where('workspaceId', '=', workspaceId)
+ .execute();
+ }
+
+ async deleteTemplate(
+ templateId: string,
+ workspaceId: string,
+ trx?: KyselyTransaction,
+ ): Promise {
+ const db = dbOrTx(this.db, trx);
+ await db
+ .deleteFrom('templates')
+ .where('id', '=', templateId)
+ .where('workspaceId', '=', workspaceId)
+ .execute();
+ }
+
+ withCreator(eb: ExpressionBuilder) {
+ return jsonObjectFrom(
+ eb
+ .selectFrom('users')
+ .select(['users.id', 'users.name', 'users.avatarUrl'])
+ .whereRef('users.id', '=', 'templates.creatorId'),
+ ).as('creator');
+ }
+}
diff --git a/apps/server/src/database/repos/watcher/watcher.repo.ts b/apps/server/src/database/repos/watcher/watcher.repo.ts
index 9739b4deb6..16fa1fd5fb 100644
--- a/apps/server/src/database/repos/watcher/watcher.repo.ts
+++ b/apps/server/src/database/repos/watcher/watcher.repo.ts
@@ -20,18 +20,6 @@ export type WatcherType = (typeof WatcherType)[keyof typeof WatcherType];
export class WatcherRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
- async findByUserAndPage(
- userId: string,
- pageId: string,
- ): Promise {
- return this.db
- .selectFrom('watchers')
- .selectAll()
- .where('userId', '=', userId)
- .where('pageId', '=', pageId)
- .executeTakeFirst();
- }
-
async findPageWatchers(pageId: string, pagination: PaginationOptions) {
const query = this.db
.selectFrom('watchers')
@@ -66,6 +54,53 @@ export class WatcherRepo {
return watchers.map((w) => w.userId);
}
+ /**
+ * Recipients for a `page.updated` notification, combining:
+ * - Active page watchers on this page, AND
+ * - Active space watchers on this space, EXCLUDING any user who has a
+ * muted page watcher row for this page (per-page mute always wins).
+ *
+ * Deduplicated at the SQL level — a user watching both the page and the
+ * containing space appears once.
+ */
+ async getPageUpdateRecipientIds(
+ pageId: string,
+ spaceId: string,
+ trx?: KyselyTransaction,
+ ): Promise {
+ const db = dbOrTx(this.db, trx);
+
+ const pageWatchers = db
+ .selectFrom('watchers')
+ .select('userId')
+ .where('pageId', '=', pageId)
+ .where('type', '=', WatcherType.PAGE)
+ .where('mutedAt', 'is', null);
+
+ const spaceWatchers = db
+ .selectFrom('watchers as sw')
+ .select('sw.userId')
+ .where('sw.spaceId', '=', spaceId)
+ .where('sw.pageId', 'is', null)
+ .where('sw.type', '=', WatcherType.SPACE)
+ .where((eb) =>
+ eb.not(
+ eb.exists(
+ eb
+ .selectFrom('watchers as pw')
+ .select('pw.id')
+ .whereRef('pw.userId', '=', 'sw.userId')
+ .where('pw.pageId', '=', pageId)
+ .where('pw.type', '=', WatcherType.PAGE)
+ .where('pw.mutedAt', 'is not', null),
+ ),
+ ),
+ );
+
+ const rows = await pageWatchers.union(spaceWatchers).execute();
+ return [...new Set(rows.map((r) => r.userId))];
+ }
+
async insert(
watcher: InsertableWatcher,
trx?: KyselyTransaction,
@@ -110,20 +145,97 @@ export class WatcherRepo {
.executeTakeFirst();
}
+ async upsertSpace(
+ watcher: InsertableWatcher,
+ trx?: KyselyTransaction,
+ ): Promise {
+ const db = dbOrTx(this.db, trx);
+ return db
+ .insertInto('watchers')
+ .values(watcher)
+ .onConflict((oc) =>
+ oc
+ .columns(['userId', 'spaceId'])
+ .where('pageId', 'is', null)
+ .doNothing(),
+ )
+ .returningAll()
+ .executeTakeFirst();
+ }
+
async mute(
userId: string,
pageId: string,
+ spaceId: string,
+ workspaceId: string,
trx?: KyselyTransaction,
): Promise {
const db = dbOrTx(this.db, trx);
+ const mutedAt = new Date();
await db
- .updateTable('watchers')
- .set({ mutedAt: new Date() })
+ .insertInto('watchers')
+ .values({
+ userId,
+ pageId,
+ spaceId,
+ workspaceId,
+ type: WatcherType.PAGE,
+ addedById: userId,
+ mutedAt,
+ })
+ .onConflict((oc) =>
+ oc
+ .columns(['userId', 'pageId'])
+ .where('pageId', 'is not', null)
+ .doUpdateSet({ mutedAt }),
+ )
+ .execute();
+ }
+
+ async deleteSpaceWatch(
+ userId: string,
+ spaceId: string,
+ trx?: KyselyTransaction,
+ ): Promise {
+ const db = dbOrTx(this.db, trx);
+ await db
+ .deleteFrom('watchers')
.where('userId', '=', userId)
- .where('pageId', '=', pageId)
+ .where('spaceId', '=', spaceId)
+ .where('pageId', 'is', null)
+ .where('type', '=', WatcherType.SPACE)
.execute();
}
+ async getWatchedSpaceIds(userId: string, workspaceId: string) {
+ const query = this.db
+ .selectFrom('watchers')
+ .select(['watchers.id', 'watchers.spaceId'])
+ .where('userId', '=', userId)
+ .where('workspaceId', '=', workspaceId)
+ .where('pageId', 'is', null)
+ .where('type', '=', WatcherType.SPACE);
+
+ return executeWithCursorPagination(query, {
+ perPage: 250,
+ fields: [{ expression: 'watchers.id', direction: 'asc' }],
+ parseCursor: (cursor) => ({ id: cursor.id }),
+ });
+ }
+
+ async isWatchingSpace(userId: string, spaceId: string): Promise {
+ const watcher = await this.db
+ .selectFrom('watchers')
+ .select('id')
+ .where('userId', '=', userId)
+ .where('spaceId', '=', spaceId)
+ .where('pageId', 'is', null)
+ .where('type', '=', WatcherType.SPACE)
+ .executeTakeFirst();
+
+ return !!watcher;
+ }
+
async isWatching(userId: string, pageId: string): Promise {
const watcher = await this.db
.selectFrom('watchers')
@@ -164,14 +276,14 @@ export class WatcherRepo {
.where('spaceId', '=', spaceId)
.where('userId', 'is not', null)
.union(
- this.db
+ db
.selectFrom('spaceMembers')
.innerJoin('groupUsers', 'groupUsers.groupId', 'spaceMembers.groupId')
.select('groupUsers.userId')
.where('spaceMembers.spaceId', '=', spaceId),
);
- await this.db
+ await db
.deleteFrom('watchers')
.where('userId', 'in', userIds)
.where('spaceId', '=', spaceId)
diff --git a/apps/server/src/database/repos/workspace/workspace.repo.ts b/apps/server/src/database/repos/workspace/workspace.repo.ts
index deb52a93c4..4e3990110c 100644
--- a/apps/server/src/database/repos/workspace/workspace.repo.ts
+++ b/apps/server/src/database/repos/workspace/workspace.repo.ts
@@ -230,4 +230,24 @@ export class WorkspaceRepo {
.executeTakeFirst();
}
+ async updateTemplateSettings(
+ workspaceId: string,
+ prefKey: string,
+ prefValue: string | boolean,
+ trx?: KyselyTransaction,
+ ) {
+ const db = dbOrTx(this.db, trx);
+ return db
+ .updateTable('workspaces')
+ .set({
+ settings: sql`COALESCE(settings, '{}'::jsonb)
+ || jsonb_build_object('templates', COALESCE(settings->'templates', '{}'::jsonb)
+ || jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
+ updatedAt: new Date(),
+ })
+ .where('id', '=', workspaceId)
+ .returning(this.baseFields)
+ .executeTakeFirst();
+ }
+
}
diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts
index 31accabdee..c110a777b7 100644
--- a/apps/server/src/database/types/db.d.ts
+++ b/apps/server/src/database/types/db.d.ts
@@ -38,6 +38,7 @@ export interface ApiKeys {
}
export interface Attachments {
+ aiChatId: string | null;
createdAt: Generated;
creatorId: string;
deletedAt: Timestamp | null;
@@ -172,6 +173,17 @@ export interface Comments {
workspaceId: string;
}
+export interface Favorites {
+ id: Generated;
+ userId: string;
+ pageId: string | null;
+ spaceId: string | null;
+ templateId: string | null;
+ type: string;
+ workspaceId: string;
+ createdAt: Generated;
+}
+
export interface FileTasks {
createdAt: Generated;
creatorId: string | null;
@@ -182,6 +194,8 @@ export interface FileTasks {
filePath: string;
fileSize: Int8 | null;
id: Generated;
+ metadata: Json | null;
+ pageId: string | null;
source: string | null;
spaceId: string | null;
status: string | null;
@@ -387,6 +401,7 @@ export interface Notifications {
pageId: string | null;
spaceId: string | null;
commentId: string | null;
+ pageVerificationId: string | null;
data: Json | null;
readAt: Timestamp | null;
emailedAt: Timestamp | null;
@@ -428,6 +443,83 @@ export interface PagePermissions {
updatedAt: Generated;
}
+export interface PageVerifications {
+ id: Generated;
+ pageId: string;
+ workspaceId: string;
+ spaceId: string;
+ type: Generated;
+ status: string | null;
+ mode: string | null;
+ periodAmount: number | null;
+ periodUnit: string | null;
+ verifiedAt: Timestamp | null;
+ verifiedById: string | null;
+ expiresAt: Timestamp | null;
+ requestedAt: Timestamp | null;
+ requestedById: string | null;
+ rejectedAt: Timestamp | null;
+ rejectedById: string | null;
+ rejectionComment: string | null;
+ data: Json | null;
+ creatorId: string | null;
+ createdAt: Generated;
+ updatedAt: Generated;
+}
+
+export interface PageVerifiers {
+ id: Generated;
+ pageVerificationId: string;
+ userId: string;
+ isPrimary: Generated;
+ addedById: string | null;
+ createdAt: Generated;
+}
+
+export interface Templates {
+ id: Generated;
+ title: string | null;
+ description: string | null;
+ content: Json | null;
+ ydoc: Buffer | null;
+ icon: string | null;
+ spaceId: string | null;
+ workspaceId: string;
+ creatorId: string | null;
+ lastUpdatedById: string | null;
+ collaboratorIds: string[] | null;
+ textContent: string | null;
+ tsv: string | null;
+ createdAt: Generated;
+ updatedAt: Generated;
+ deletedAt: Timestamp | null;
+}
+
+export interface AiChats {
+ id: Generated;
+ workspaceId: string;
+ creatorId: string;
+ title: string | null;
+ createdAt: Generated;
+ updatedAt: Generated;
+ deletedAt: Timestamp | null;
+}
+
+export interface AiChatMessages {
+ id: Generated;
+ chatId: string;
+ workspaceId: string;
+ userId: string | null;
+ role: string;
+ content: string | null;
+ toolCalls: Json | null;
+ metadata: Json | null;
+ tsv: string | null;
+ createdAt: Generated;
+ updatedAt: Generated;
+ deletedAt: Timestamp | null;
+}
+
export interface UserSessions {
id: Generated;
userId: string;
@@ -444,6 +536,8 @@ export interface UserSessions {
}
export interface DB {
+ aiChats: AiChats;
+ aiChatMessages: AiChatMessages;
apiKeys: ApiKeys;
attachments: Attachments;
audit: Audit;
@@ -452,6 +546,7 @@ export interface DB {
backlinks: Backlinks;
billing: Billing;
comments: Comments;
+ favorites: Favorites;
fileTasks: FileTasks;
groups: Groups;
groupUsers: GroupUsers;
@@ -459,10 +554,13 @@ export interface DB {
pageAccess: PageAccess;
pagePermissions: PagePermissions;
pageHistory: PageHistory;
+ pageVerifications: PageVerifications;
+ pageVerifiers: PageVerifiers;
pages: Pages;
shares: Shares;
spaceMembers: SpaceMembers;
spaces: Spaces;
+ templates: Templates;
userMfa: UserMfa;
users: Users;
userSessions: UserSessions;
diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts
index ed56e89bec..5e6e5c7b8e 100644
--- a/apps/server/src/database/types/entity.types.ts
+++ b/apps/server/src/database/types/entity.types.ts
@@ -1,11 +1,15 @@
import { Insertable, Selectable, Updateable } from 'kysely';
import {
+ AiChats,
+ AiChatMessages,
Attachments,
Comments,
Groups,
Notifications,
PageAccess as _PageAccess,
PagePermissions as _PagePermissions,
+ PageVerifications as _PageVerifications,
+ PageVerifiers as _PageVerifiers,
Pages,
Spaces,
Users,
@@ -20,15 +24,32 @@ import {
AuthProviders,
AuthAccounts,
Shares,
+ Favorites,
FileTasks,
UserMfa as _UserMFA,
UserSessions,
ApiKeys,
Watchers,
Audit as _Audit,
+ Templates,
} from './db';
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
+// AI Chat
+export type AiChat = Selectable;
+export type InsertableAiChat = Insertable;
+export type UpdatableAiChat = Updateable>;
+
+// AI Chat Message
+// `tsv` is an internal tsvector column maintained by a trigger for
+// full-text search. It is omitted from the public type so it never leaks
+// into HTTP responses or the chat history fed to the language model.
+export type AiChatMessage = Omit, 'tsv'>;
+export type InsertableAiChatMessage = Omit<
+ Insertable,
+ 'tsv'
+>;
+
// Workspace
export type Workspace = Selectable;
export type InsertableWorkspace = Insertable;
@@ -131,6 +152,11 @@ export type Share = Selectable;
export type InsertableShare = Insertable;
export type UpdatableShare = Updateable>;
+// Favorite
+export type Favorite = Selectable;
+export type InsertableFavorite = Insertable;
+export type UpdatableFavorite = Updateable>;
+
// File Task
export type FileTask = Selectable;
export type InsertableFileTask = Insertable;
@@ -171,6 +197,15 @@ export type PagePermission = Selectable<_PagePermissions>;
export type InsertablePagePermission = Insertable<_PagePermissions>;
export type UpdatablePagePermission = Updateable>;
+// Page Verification
+export type PageVerification = Selectable<_PageVerifications>;
+export type InsertablePageVerification = Insertable<_PageVerifications>;
+export type UpdatablePageVerification = Updateable>;
+
+// Page Verifier
+export type PageVerifier = Selectable<_PageVerifiers>;
+export type InsertablePageVerifier = Insertable<_PageVerifiers>;
+
// User Session
export type UserSession = Selectable;
export type InsertableUserSession = Insertable;
@@ -180,3 +215,8 @@ export type UpdatableUserSession = Updateable>;
export type Audit = Selectable<_Audit>;
export type InsertableAudit = Insertable<_Audit>;
export type UpdatableAudit = Updateable>;
+
+// Template
+export type Template = Selectable;
+export type InsertableTemplate = Insertable;
+export type UpdatableTemplate = Updateable>;
diff --git a/apps/server/src/integrations/environment/environment.service.ts b/apps/server/src/integrations/environment/environment.service.ts
index b1b27d40dc..af35e93468 100644
--- a/apps/server/src/integrations/environment/environment.service.ts
+++ b/apps/server/src/integrations/environment/environment.service.ts
@@ -75,6 +75,10 @@ export class EnvironmentService {
return new Date(Date.now() + msUntilExpiry);
}
+ getGotenbergUrl(): string | undefined {
+ return this.configService.get('GOTENBERG_URL');
+ }
+
getStorageDriver(): string {
return this.configService.get('STORAGE_DRIVER', 'local');
}
@@ -252,6 +256,13 @@ export class EnvironmentService {
return this.configService.get('AI_COMPLETION_MODEL');
}
+ getAiChatModel(): string {
+ return (
+ this.configService.get('AI_CHAT_MODEL') ||
+ this.configService.get('AI_COMPLETION_MODEL')
+ );
+ }
+
getAiEmbeddingDimension(): number {
return parseInt(
this.configService.get('AI_EMBEDDING_DIMENSION'),
diff --git a/apps/server/src/integrations/export/export.service.ts b/apps/server/src/integrations/export/export.service.ts
index d93f9ba030..3f8da1bf97 100644
--- a/apps/server/src/integrations/export/export.service.ts
+++ b/apps/server/src/integrations/export/export.service.ts
@@ -353,7 +353,7 @@ export class ExportService {
if (attachmentIds.length > 0) {
const attachments = await this.db
.selectFrom('attachments')
- .selectAll()
+ .select(['id', 'fileName', 'filePath'])
.where('id', 'in', attachmentIds)
.where('spaceId', '=', spaceId)
.execute();
diff --git a/apps/server/src/integrations/import/processors/file-task.processor.ts b/apps/server/src/integrations/import/processors/file-task.processor.ts
index 20001dd7da..03527707b6 100644
--- a/apps/server/src/integrations/import/processors/file-task.processor.ts
+++ b/apps/server/src/integrations/import/processors/file-task.processor.ts
@@ -5,6 +5,9 @@ import { QueueJob, QueueName } from 'src/integrations/queue/constants';
import { FileImportTaskService } from '../services/file-import-task.service';
import { FileTaskStatus } from '../utils/file.utils';
import { StorageService } from '../../storage/storage.service';
+import { ModuleRef } from '@nestjs/core';
+import { InjectKysely } from 'nestjs-kysely';
+import { KyselyDB } from '@docmost/db/types/kysely.types';
@Processor(QueueName.FILE_TASK_QUEUE)
export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
@@ -13,6 +16,8 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
constructor(
private readonly fileTaskService: FileImportTaskService,
private readonly storageService: StorageService,
+ private readonly moduleRef: ModuleRef,
+ @InjectKysely() private readonly db: KyselyDB,
) {
super();
}
@@ -23,8 +28,11 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
case QueueJob.IMPORT_TASK:
await this.fileTaskService.processZIpImport(job.data.fileTaskId);
break;
- case QueueJob.EXPORT_TASK:
- // TODO: export task
+ case QueueJob.PDF_EXPORT_TASK:
+ await this.processExportTask(job.data.fileTaskId);
+ break;
+ case QueueJob.PDF_EXPORT_CLEANUP:
+ await this.processExportCleanup();
break;
}
} catch (err) {
@@ -33,6 +41,24 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
}
}
+ private getPdfExportService() {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const PdfExportModule = require('./../../../ee/pdf-export/pdf-export.service');
+ return this.moduleRef.get(PdfExportModule.PdfExportService, {
+ strict: false,
+ });
+ }
+
+ private async processExportTask(fileTaskId: string): Promise {
+ const pdfExportService = this.getPdfExportService();
+ await pdfExportService.generateAndStorePdf(fileTaskId);
+ }
+
+ private async processExportCleanup(): Promise {
+ const pdfExportService = this.getPdfExportService();
+ await pdfExportService.cleanupExpiredExports();
+ }
+
@OnWorkerEvent('active')
onActive(job: Job) {
this.logger.debug(`Processing ${job.name} job`);
@@ -41,32 +67,39 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
@OnWorkerEvent('failed')
async onFailed(job: Job) {
this.logger.error(
- `Error processing ${job.name} job. Import Task ID: ${job.data.fileTaskId}. Reason: ${job.failedReason}`,
+ `Error processing ${job.name} job. File Task ID: ${job.data?.fileTaskId}. Reason: ${job.failedReason}`,
);
- await this.handleFailedJob(job);
+ if (job.name === QueueJob.IMPORT_TASK) {
+ await this.handleFailedImportJob(job);
+ } else if (job.name === QueueJob.PDF_EXPORT_TASK) {
+ await this.handleFailedExportJob(job);
+ }
}
@OnWorkerEvent('completed')
async onCompleted(job: Job) {
this.logger.log(
- `Completed ${job.name} job for File task ID ${job.data.fileTaskId}`,
+ `Completed ${job.name} job for File task ID ${job.data?.fileTaskId}`,
);
- try {
- const fileTask = await this.fileTaskService.getFileTask(
- job.data.fileTaskId,
- );
- if (fileTask) {
- await this.storageService.delete(fileTask.filePath);
- this.logger.debug(`Deleted imported zip file: ${fileTask.filePath}`);
+ if (job.name === QueueJob.IMPORT_TASK) {
+ try {
+ const fileTask = await this.fileTaskService.getFileTask(
+ job.data.fileTaskId,
+ );
+ if (fileTask) {
+ await this.storageService.delete(fileTask.filePath);
+ this.logger.debug(`Deleted imported zip file: ${fileTask.filePath}`);
+ }
+ } catch (err) {
+ this.logger.error(`Failed to delete imported zip file:`, err);
}
- } catch (err) {
- this.logger.error(`Failed to delete imported zip file:`, err);
}
+ // Export tasks: do NOT delete the file on completion (kept for 24h cache)
}
- private async handleFailedJob(job: Job) {
+ private async handleFailedImportJob(job: Job) {
try {
const fileTaskId = job.data.fileTaskId;
const reason = job.failedReason || 'Unknown error';
@@ -86,6 +119,25 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
}
}
+ private async handleFailedExportJob(job: Job) {
+ try {
+ const fileTaskId = job.data.fileTaskId;
+ const reason = job.failedReason || 'Unknown error';
+
+ await this.db
+ .updateTable('fileTasks')
+ .set({
+ status: FileTaskStatus.Failed,
+ errorMessage: reason,
+ updatedAt: new Date(),
+ })
+ .where('id', '=', fileTaskId)
+ .execute();
+ } catch (err) {
+ this.logger.error(err);
+ }
+ }
+
async onModuleDestroy(): Promise {
if (this.worker) {
await this.worker.close();
diff --git a/apps/server/src/integrations/queue/constants/queue.constants.ts b/apps/server/src/integrations/queue/constants/queue.constants.ts
index 92d15426a5..c783ec05e6 100644
--- a/apps/server/src/integrations/queue/constants/queue.constants.ts
+++ b/apps/server/src/integrations/queue/constants/queue.constants.ts
@@ -17,6 +17,7 @@ export enum QueueJob {
ATTACHMENT_INDEX_CONTENT = 'attachment-index-content',
ATTACHMENT_INDEXING = 'attachment-indexing',
DELETE_PAGE_ATTACHMENTS = 'delete-page-attachments',
+ DELETE_AI_CHAT_ATTACHMENTS = 'delete-ai-chat-attachments',
DELETE_USER_AVATARS = 'delete-user-avatars',
@@ -70,7 +71,16 @@ export enum QueueJob {
PAGE_MENTION_NOTIFICATION = 'page-mention-notification',
PAGE_PERMISSION_GRANTED = 'page-permission-granted',
PAGE_UPDATE_DIGEST = 'page-update-digest',
+ PAGE_VERIFICATION_EXPIRING = 'page-verification-expiring',
+ PAGE_VERIFICATION_EXPIRED = 'page-verification-expired',
+ VERIFICATION_RECONCILE = 'verification-reconcile',
+ PAGE_VERIFIED_NOTIFICATION = 'page-verified-notification',
+ PAGE_APPROVAL_REQUESTED_NOTIFICATION = 'page-approval-requested-notification',
+ PAGE_APPROVAL_REJECTED_NOTIFICATION = 'page-approval-rejected-notification',
AUDIT_LOG = 'audit-log',
AUDIT_CLEANUP = 'audit-cleanup',
+
+ PDF_EXPORT_TASK = 'pdf-export-task',
+ PDF_EXPORT_CLEANUP = 'pdf-export-cleanup',
}
diff --git a/apps/server/src/integrations/queue/constants/queue.interface.ts b/apps/server/src/integrations/queue/constants/queue.interface.ts
index f0683a4eec..6dea53c3cf 100644
--- a/apps/server/src/integrations/queue/constants/queue.interface.ts
+++ b/apps/server/src/integrations/queue/constants/queue.interface.ts
@@ -4,6 +4,7 @@ export interface IPageBacklinkJob {
pageId: string;
workspaceId: string;
mentions: MentionNode[];
+ internalLinkSlugIds?: string[];
}
export interface IAddPageWatchersJob {
@@ -75,3 +76,40 @@ export interface IPermissionGrantedNotificationJob {
actorId: string;
role: string;
}
+
+export interface IVerificationExpiringNotificationJob {
+ verificationId: string;
+}
+
+export interface IVerificationExpiredNotificationJob {
+ verificationId: string;
+}
+
+export interface IVerificationReconcileJob {
+ // no payload
+}
+
+export interface IPageVerifiedNotificationJob {
+ pageId: string;
+ spaceId: string;
+ workspaceId: string;
+ actorId: string;
+ verifierIds: string[];
+}
+
+export interface IApprovalRequestedNotificationJob {
+ pageId: string;
+ spaceId: string;
+ workspaceId: string;
+ actorId: string;
+ verifierIds: string[];
+}
+
+export interface IApprovalRejectedNotificationJob {
+ pageId: string;
+ spaceId: string;
+ workspaceId: string;
+ actorId: string;
+ requestedById: string;
+ comment?: string;
+}
diff --git a/apps/server/src/integrations/queue/tasks/backlinks.task.ts b/apps/server/src/integrations/queue/tasks/backlinks.task.ts
index 268adaaea0..9d707272a1 100644
--- a/apps/server/src/integrations/queue/tasks/backlinks.task.ts
+++ b/apps/server/src/integrations/queue/tasks/backlinks.task.ts
@@ -11,7 +11,7 @@ export async function processBacklinks(
backlinkRepo: BacklinkRepo,
data: IPageBacklinkJob,
): Promise {
- const { pageId, mentions, workspaceId } = data;
+ const { pageId, mentions, workspaceId, internalLinkSlugIds = [] } = data;
await executeTx(db, async (trx) => {
const existingBacklinks = await trx
@@ -20,7 +20,28 @@ export async function processBacklinks(
.where('sourcePageId', '=', pageId)
.execute();
- if (existingBacklinks.length === 0 && mentions.length === 0) {
+ const mentionTargetPageIds = mentions
+ .filter((mention) => mention.entityId !== pageId)
+ .map((mention) => mention.entityId);
+
+ let resolvedLinkPageIds: string[] = [];
+ if (internalLinkSlugIds.length > 0) {
+ const resolvedPages = await trx
+ .selectFrom('pages')
+ .select('id')
+ .where('slugId', 'in', internalLinkSlugIds)
+ .where('workspaceId', '=', workspaceId)
+ .execute();
+ resolvedLinkPageIds = resolvedPages
+ .map((p) => p.id)
+ .filter((id) => id !== pageId);
+ }
+
+ const allTargetPageIds = [
+ ...new Set([...mentionTargetPageIds, ...resolvedLinkPageIds]),
+ ];
+
+ if (existingBacklinks.length === 0 && allTargetPageIds.length === 0) {
return;
}
@@ -28,16 +49,12 @@ export async function processBacklinks(
(backlink) => backlink.targetPageId,
);
- const targetPageIds = mentions
- .filter((mention) => mention.entityId !== pageId)
- .map((mention) => mention.entityId);
-
let validTargetPages = [];
- if (targetPageIds.length > 0) {
+ if (allTargetPageIds.length > 0) {
validTargetPages = await trx
.selectFrom('pages')
.select('id')
- .where('id', 'in', targetPageIds)
+ .where('id', 'in', allTargetPageIds)
.where('workspaceId', '=', workspaceId)
.execute();
}
diff --git a/apps/server/src/integrations/throttle/throttle.module.ts b/apps/server/src/integrations/throttle/throttle.module.ts
index 8f080e1d9d..42dd0ec4a7 100644
--- a/apps/server/src/integrations/throttle/throttle.module.ts
+++ b/apps/server/src/integrations/throttle/throttle.module.ts
@@ -4,6 +4,7 @@ import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis'
import { EnvironmentService } from '../environment/environment.service';
import { EnvironmentModule } from '../environment/environment.module';
import { parseRedisUrl } from '../../common/helpers';
+import { AUTH_THROTTLER, AI_CHAT_THROTTLER } from './throttler-names';
import Redis from 'ioredis';
@Module({
@@ -14,7 +15,10 @@ import Redis from 'ioredis';
const redisConfig = parseRedisUrl(environmentService.getRedisUrl());
return {
- throttlers: [{ name: 'auth', ttl: 60_000, limit: 10 }],
+ throttlers: [
+ { name: AUTH_THROTTLER, ttl: 60_000, limit: 10 },
+ { name: AI_CHAT_THROTTLER, ttl: 60_000, limit: 25 },
+ ],
errorMessage: 'Too many requests',
storage: new ThrottlerStorageRedisService(
new Redis({
diff --git a/apps/server/src/integrations/throttle/throttler-names.ts b/apps/server/src/integrations/throttle/throttler-names.ts
new file mode 100644
index 0000000000..388ba29d70
--- /dev/null
+++ b/apps/server/src/integrations/throttle/throttler-names.ts
@@ -0,0 +1,2 @@
+export const AUTH_THROTTLER = 'auth';
+export const AI_CHAT_THROTTLER = 'ai-chat';
diff --git a/apps/server/src/integrations/throttle/user-throttler.guard.ts b/apps/server/src/integrations/throttle/user-throttler.guard.ts
new file mode 100644
index 0000000000..35744c0940
--- /dev/null
+++ b/apps/server/src/integrations/throttle/user-throttler.guard.ts
@@ -0,0 +1,13 @@
+import { Injectable } from '@nestjs/common';
+import { ThrottlerGuard } from '@nestjs/throttler';
+
+type AuthedRequest = { user?: { id?: string } };
+
+@Injectable()
+export class UserThrottlerGuard extends ThrottlerGuard {
+ protected async getTracker(req: AuthedRequest): Promise {
+ const userId = req.user?.id;
+ if (userId) return `user:${userId}`;
+ return super.getTracker(req as Parameters[0]);
+ }
+}
diff --git a/apps/server/src/integrations/transactional/emails/approval-rejected-email.tsx b/apps/server/src/integrations/transactional/emails/approval-rejected-email.tsx
new file mode 100644
index 0000000000..c647ef8d46
--- /dev/null
+++ b/apps/server/src/integrations/transactional/emails/approval-rejected-email.tsx
@@ -0,0 +1,41 @@
+import { Section, Text } from '@react-email/components';
+import * as React from 'react';
+import { content, paragraph } from '../css/styles';
+import { EmailButton, MailBody } from '../partials/partials';
+
+interface Props {
+ actorName: string;
+ pageTitle: string;
+ spaceName: string;
+ pageUrl: string;
+ comment?: string;
+}
+
+export const ApprovalRejectedEmail = ({
+ actorName,
+ pageTitle,
+ spaceName,
+ pageUrl,
+ comment,
+}: Props) => {
+ return (
+
+
+ Hi there,
+
+ {actorName} returned{' '}
+ {pageTitle} in the{' '}
+ {spaceName} space for revision.
+
+ {comment && (
+
+ “{comment}”
+
+ )}
+
+ View page
+
+ );
+};
+
+export default ApprovalRejectedEmail;
diff --git a/apps/server/src/integrations/transactional/emails/approval-requested-email.tsx b/apps/server/src/integrations/transactional/emails/approval-requested-email.tsx
new file mode 100644
index 0000000000..f3c2765992
--- /dev/null
+++ b/apps/server/src/integrations/transactional/emails/approval-requested-email.tsx
@@ -0,0 +1,34 @@
+import { Section, Text } from '@react-email/components';
+import * as React from 'react';
+import { content, paragraph } from '../css/styles';
+import { EmailButton, MailBody } from '../partials/partials';
+
+interface Props {
+ actorName: string;
+ pageTitle: string;
+ spaceName: string;
+ pageUrl: string;
+}
+
+export const ApprovalRequestedEmail = ({
+ actorName,
+ pageTitle,
+ spaceName,
+ pageUrl,
+}: Props) => {
+ return (
+
+
+ Hi there,
+
+ {actorName} submitted{' '}
+ {pageTitle} in the{' '}
+ {spaceName} space for your approval.
+
+
+ Review page
+
+ );
+};
+
+export default ApprovalRequestedEmail;
diff --git a/apps/server/src/integrations/transactional/emails/page-update-email.tsx b/apps/server/src/integrations/transactional/emails/page-update-email.tsx
index 188d8a3415..876b4f31b1 100644
--- a/apps/server/src/integrations/transactional/emails/page-update-email.tsx
+++ b/apps/server/src/integrations/transactional/emails/page-update-email.tsx
@@ -8,6 +8,7 @@ interface Props {
actorName: string;
pageTitle: string;
pageUrl: string;
+ spaceName: string;
}
export const PageUpdateEmail = ({
@@ -15,6 +16,7 @@ export const PageUpdateEmail = ({
actorName,
pageTitle,
pageUrl,
+ spaceName,
}: Props) => {
return (
@@ -24,8 +26,8 @@ export const PageUpdateEmail = ({
{actorName} updated{' '}
{pageTitle}
-
- .
+ {' '}
+ in the {spaceName} space.
View page
diff --git a/apps/server/src/integrations/transactional/emails/verification-expired-email.tsx b/apps/server/src/integrations/transactional/emails/verification-expired-email.tsx
new file mode 100644
index 0000000000..be808333d4
--- /dev/null
+++ b/apps/server/src/integrations/transactional/emails/verification-expired-email.tsx
@@ -0,0 +1,28 @@
+import { Section, Text } from '@react-email/components';
+import * as React from 'react';
+import { content, paragraph } from '../css/styles';
+import { EmailButton, MailBody } from '../partials/partials';
+
+interface Props {
+ pageTitle: string;
+ spaceName: string;
+ pageUrl: string;
+}
+
+export const VerificationExpiredEmail = ({ pageTitle, spaceName, pageUrl }: Props) => {
+ return (
+
+
+ Hi there,
+
+ The verification for {pageTitle} in the{' '}
+ {spaceName} space has expired. Please re-verify the
+ page to confirm it is still accurate.
+
+
+ Re-verify page
+
+ );
+};
+
+export default VerificationExpiredEmail;
diff --git a/apps/server/src/integrations/transactional/emails/verification-expiring-email.tsx b/apps/server/src/integrations/transactional/emails/verification-expiring-email.tsx
new file mode 100644
index 0000000000..16bb49772a
--- /dev/null
+++ b/apps/server/src/integrations/transactional/emails/verification-expiring-email.tsx
@@ -0,0 +1,34 @@
+import { Section, Text } from '@react-email/components';
+import * as React from 'react';
+import { content, paragraph } from '../css/styles';
+import { EmailButton, MailBody } from '../partials/partials';
+
+interface Props {
+ pageTitle: string;
+ spaceName: string;
+ pageUrl: string;
+ expiresAt: string;
+}
+
+export const VerificationExpiringEmail = ({
+ pageTitle,
+ spaceName,
+ pageUrl,
+ expiresAt,
+}: Props) => {
+ return (
+
+
+ Hi there,
+
+ The page {pageTitle} in the{' '}
+ {spaceName} space needs to be re-verified. The
+ verification expires on {expiresAt}.
+
+
+ Review page
+
+ );
+};
+
+export default VerificationExpiringEmail;
diff --git a/package.json b/package.json
index 6c31f8f0b7..318146e2a1 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "forkmost",
"homepage": "https://github.com/Vito0912/forkmost",
- "version": "0.70.3",
+ "version": "0.80.0",
"private": true,
"scripts": {
"build": "nx run-many -t build",
@@ -103,16 +103,17 @@
"prosemirror-changeset": "2.4.0",
"y-prosemirror": "1.3.7",
"glob": "13.0.6",
- "ws": "8.19.0",
+ "ws": "8.20.0",
"dompurify": "3.3.3",
"tmp": "0.2.5",
- "hono": "4.12.8",
+ "hono": "4.12.12",
"mermaid": "11.13.0",
"nanoid@^3": "3.3.8",
"socket.io-parser": "4.2.6",
"serialize-javascript": "7.0.3",
- "lodash-es": "4.17.23",
- "@hono/node-server": "1.19.10",
+ "lodash-es": "4.18.1",
+ "lodash": "4.18.1",
+ "@hono/node-server": "1.19.13",
"undici": "7.24.0",
"ajv@^6": "6.14.0",
"ajv@^8": "8.18.0",
@@ -128,7 +129,12 @@
"yaml@>=1.0.0 <1.10.3": "1.10.3",
"yaml@>=2.0.0 <2.8.3": "2.8.3",
"path-to-regexp@^8": "8.4.0",
- "brace-expansion@^5": "5.0.5"
+ "brace-expansion@^5": "5.0.5",
+ "@xmldom/xmldom": "0.8.12",
+ "handlebars": "4.7.9",
+ "axios": "1.15.0",
+ "langsmith": "0.5.18",
+ "follow-redirects": "1.16.0"
},
"neverBuiltDependencies": []
}
diff --git a/packages/editor-ext/src/lib/link.ts b/packages/editor-ext/src/lib/link.ts
index 1d4cb2074d..38ff1da69d 100644
--- a/packages/editor-ext/src/lib/link.ts
+++ b/packages/editor-ext/src/lib/link.ts
@@ -1,7 +1,6 @@
-import { mergeAttributes } from "@tiptap/core";
-import TiptapLink from "@tiptap/extension-link";
-import { Plugin, PluginKey } from "@tiptap/pm/state";
-import { EditorView } from "@tiptap/pm/view";
+import TiptapLink from '@tiptap/extension-link';
+import { Plugin, PluginKey } from '@tiptap/pm/state';
+import { EditorView } from '@tiptap/pm/view';
export const LinkExtension = TiptapLink.extend({
inclusive: false,
@@ -19,48 +18,6 @@ export const LinkExtension = TiptapLink.extend({
};
},
- parseHTML() {
- return [
- {
- tag: 'a[href]:not([data-type="button"]):not([href *= "javascript:" i])',
- getAttrs: (element) => {
- if (
- element
- .getAttribute("href")
- ?.toLowerCase()
- .startsWith("javascript:")
- ) {
- return false;
- }
-
- return null;
- },
- },
- ];
- },
-
- renderHTML({ HTMLAttributes }) {
- if (HTMLAttributes.href?.toLowerCase().startsWith("javascript:")) {
- return [
- "a",
- mergeAttributes(
- this.options.HTMLAttributes,
- { ...HTMLAttributes, href: "" },
- { class: "link" },
- ),
- 0,
- ];
- }
-
- return [
- "a",
- mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
- class: "link",
- }),
- 0,
- ];
- },
-
addProseMirrorPlugins() {
const { editor } = this;
@@ -71,7 +28,7 @@ export const LinkExtension = TiptapLink.extend({
handleKeyDown: (view: EditorView, event: KeyboardEvent) => {
const { selection } = editor.state;
- if (event.key === "Escape" && selection.empty !== true) {
+ if (event.key === 'Escape' && selection.empty !== true) {
editor.commands.focus(selection.to, { scrollIntoView: false });
}
@@ -89,13 +46,19 @@ export const LinkExtension = TiptapLink.extend({
// - left boundary: cursor just before a link, e.g. at the start of a
// line (#1748), where Firefox places new text inside the link node
new Plugin({
- key: new PluginKey("linkBoundaryInput"),
+ key: new PluginKey('linkBoundaryInput'),
props: {
handleKeyDown: (view: EditorView, event: KeyboardEvent) => {
// Only handle single printable characters
if (event.key.length !== 1) return false;
// Don't handle modified keys (shortcuts) or composing (IME)
- if (event.ctrlKey || event.metaKey || event.altKey || event.isComposing) return false;
+ if (
+ event.ctrlKey ||
+ event.metaKey ||
+ event.altKey ||
+ event.isComposing
+ )
+ return false;
const { state } = view;
const linkType = state.schema.marks.link;
diff --git a/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts b/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts
index 7b1efffd5f..e0ee1948c6 100644
--- a/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts
+++ b/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts
@@ -45,6 +45,8 @@ marked.use({
],
});
+marked.setOptions({ breaks: true });
+
export function markdownToHtml(
markdownInput: string,
): string | Promise {
@@ -54,8 +56,5 @@ export function markdownToHtml(
.replace(YAML_FONT_MATTER_REGEX, "")
.trimStart();
- return marked
- .options({ breaks: true })
- .parse(markdown)
- .toString();
+ return marked.parse(markdown).toString();
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fc71baa5f0..59e5215ee3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,16 +8,17 @@ overrides:
prosemirror-changeset: 2.4.0
y-prosemirror: 1.3.7
glob: 13.0.6
- ws: 8.19.0
+ ws: 8.20.0
dompurify: 3.3.3
tmp: 0.2.5
- hono: 4.12.8
+ hono: 4.12.12
mermaid: 11.13.0
nanoid@^3: 3.3.8
socket.io-parser: 4.2.6
serialize-javascript: 7.0.3
- lodash-es: 4.17.23
- '@hono/node-server': 1.19.10
+ lodash-es: 4.18.1
+ lodash: 4.18.1
+ '@hono/node-server': 1.19.13
undici: 7.24.0
ajv@^6: 6.14.0
ajv@^8: 8.18.0
@@ -34,6 +35,11 @@ overrides:
yaml@>=2.0.0 <2.8.3: 2.8.3
path-to-regexp@^8: 8.4.0
brace-expansion@^5: 5.0.5
+ '@xmldom/xmldom': 0.8.12
+ handlebars: 4.7.9
+ axios: 1.15.0
+ langsmith: 0.5.18
+ follow-redirects: 1.16.0
patchedDependencies:
react-arborist@3.4.0:
@@ -289,8 +295,8 @@ importers:
specifier: ^1.1.0
version: 1.1.0
axios:
- specifier: 1.13.6
- version: 1.13.6
+ specifier: 1.15.0
+ version: 1.15.0
blueimp-load-image:
specifier: ^5.16.0
version: 5.16.0
@@ -419,8 +425,8 @@ importers:
specifier: ^18.3.1
version: 18.3.1
'@vitejs/plugin-react':
- specifier: ^6.0.0
- version: 6.0.1(vite@8.0.3(@emnapi/core@1.8.1)(@emnapi/runtime@1.5.0)(@types/node@22.19.1)(esbuild@0.27.7)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.8))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))
+ specifier: ^6.0.1
+ version: 6.0.1(vite@8.0.5(@emnapi/core@1.8.1)(@emnapi/runtime@1.5.0)(@types/node@22.19.1)(esbuild@0.27.7)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.8))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))
eslint:
specifier: ^9.28.0
version: 9.39.2(jiti@2.4.2)
@@ -458,8 +464,8 @@ importers:
specifier: ^8.57.1
version: 8.58.0(eslint@9.39.2(jiti@2.4.2))(typescript@5.9.3)
vite:
- specifier: ^8.0.1
- version: 8.0.3(@emnapi/core@1.8.1)(@emnapi/runtime@1.5.0)(@types/node@22.19.1)(esbuild@0.27.7)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.8))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)
+ specifier: 8.0.5
+ version: 8.0.5(@emnapi/core@1.8.1)(@emnapi/runtime@1.5.0)(@types/node@22.19.1)(esbuild@0.27.7)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.8))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)
apps/server:
dependencies:
@@ -497,14 +503,14 @@ importers:
specifier: ^5.1.6
version: 5.1.6(keyv@5.6.0)
'@langchain/core':
- specifier: 1.1.34
- version: 1.1.34(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@4.3.6))
+ specifier: 1.1.39
+ version: 1.1.39(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0)
'@langchain/textsplitters':
specifier: 1.0.1
- version: 1.0.1(@langchain/core@1.1.34(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@4.3.6)))
+ version: 1.0.1(@langchain/core@1.1.39(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))
'@modelcontextprotocol/sdk':
- specifier: ^1.27.1
- version: 1.27.1(@cfworker/json-schema@4.1.1)(zod@4.3.6)
+ specifier: ^1.29.0
+ version: 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)
'@nest-lab/throttler-storage-redis':
specifier: ^1.2.0
version: 1.2.0(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(@nestjs/throttler@6.5.0(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(reflect-metadata@0.2.2))(ioredis@5.10.1)(reflect-metadata@0.2.2)
@@ -518,13 +524,13 @@ importers:
specifier: ^3.1.0
version: 3.1.0(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(cache-manager@7.2.8)(keyv@5.6.0)(rxjs@7.8.2)
'@nestjs/common':
- specifier: ^11.1.17
+ specifier: ^11.1.18
version: 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/config':
specifier: ^4.0.3
version: 4.0.3(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)
'@nestjs/core':
- specifier: ^11.1.17
+ specifier: ^11.1.18
version: 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/event-emitter':
specifier: ^3.0.1
@@ -533,16 +539,16 @@ importers:
specifier: 11.0.2
version: 11.0.2(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))
'@nestjs/mapped-types':
- specifier: ^2.1.0
- version: 2.1.0(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)
+ specifier: ^2.1.1
+ version: 2.1.1(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)
'@nestjs/passport':
specifier: ^11.0.5
version: 11.0.5(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
'@nestjs/platform-fastify':
- specifier: ^11.1.17
+ specifier: ^11.1.18
version: 11.1.18(@fastify/static@9.0.0)(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)
'@nestjs/platform-socket.io':
- specifier: ^11.1.17
+ specifier: ^11.1.18
version: 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.18)(rxjs@7.8.2)
'@nestjs/schedule':
specifier: ^6.1.1
@@ -554,7 +560,7 @@ importers:
specifier: ^6.5.0
version: 6.5.0(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(reflect-metadata@0.2.2)
'@nestjs/websockets':
- specifier: ^11.1.17
+ specifier: ^11.1.18
version: 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)(@nestjs/platform-socket.io@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@node-saml/passport-saml':
specifier: ^5.1.0
@@ -598,6 +604,9 @@ importers:
cookie:
specifier: ^1.1.1
version: 1.1.1
+ fast-bm25:
+ specifier: 0.0.5
+ version: 0.0.5(typescript@5.9.3)
fastify-ip:
specifier: ^2.0.0
version: 2.0.0
@@ -610,6 +619,9 @@ importers:
ioredis:
specifier: ^5.10.1
version: 5.10.1
+ js-tiktoken:
+ specifier: ^1.0.21
+ version: 1.0.21
jsonwebtoken:
specifier: ^9.0.3
version: 9.0.3
@@ -650,8 +662,8 @@ importers:
specifier: ^4.6.1
version: 4.6.1(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.1.0)(rxjs@7.8.2)
nodemailer:
- specifier: ^8.0.4
- version: 8.0.4
+ specifier: ^8.0.5
+ version: 8.0.5
openid-client:
specifier: ^6.8.2
version: 6.8.2
@@ -716,11 +728,11 @@ importers:
specifier: ^1.3.1
version: 1.3.1
typesense:
- specifier: ^3.0.3
+ specifier: ^3.0.5
version: 3.0.5(@babel/runtime@7.29.2)
ws:
- specifier: 8.19.0
- version: 8.19.0
+ specifier: 8.20.0
+ version: 8.20.0
yauzl:
specifier: ^3.2.1
version: 3.3.0
@@ -732,13 +744,13 @@ importers:
specifier: ^9.28.0
version: 9.39.2
'@nestjs/cli':
- specifier: ^11.0.16
- version: 11.0.16(@swc/core@1.5.25(@swc/helpers@0.5.5))(@types/node@25.5.2)
+ specifier: ^11.0.18
+ version: 11.0.21(@swc/core@1.5.25(@swc/helpers@0.5.5))(@types/node@25.5.2)
'@nestjs/schematics':
- specifier: ^11.0.9
+ specifier: ^11.0.10
version: 11.0.10(chokidar@4.0.3)(typescript@5.9.3)
'@nestjs/testing':
- specifier: ^11.1.17
+ specifier: ^11.1.18
version: 11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18)
'@types/bcrypt':
specifier: ^6.0.0
@@ -807,8 +819,8 @@ importers:
specifier: ^29.4.6
version: 29.4.6(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(jest-util@30.3.0)(jest@30.3.0(@types/node@25.5.2)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.5.25(@swc/helpers@0.5.5))(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3)
ts-loader:
- specifier: ^9.5.4
- version: 9.5.4(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.5.25(@swc/helpers@0.5.5)))
+ specifier: ^9.5.7
+ version: 9.5.7(typescript@5.9.3)(webpack@5.106.0(@swc/core@1.5.25(@swc/helpers@0.5.5)))
ts-node:
specifier: ^10.9.2
version: 10.9.2(@swc/core@1.5.25(@swc/helpers@0.5.5))(@types/node@25.5.2)(typescript@5.9.3)
@@ -871,8 +883,8 @@ packages:
resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==}
engines: {node: '>=18'}
- '@angular-devkit/core@19.2.19':
- resolution: {integrity: sha512-JbLL+4IMLMBgjLZlnPG4lYDfz4zGrJ/s6Aoon321NJKuw1Kb1k5KpFu9dUY0BqLIe8xPQ2UJBpI+xXdK5MXMHQ==}
+ '@angular-devkit/core@19.2.23':
+ resolution: {integrity: sha512-RazHPQkUEsNU/OZ75w9UeHxGFMthRiuAW2B/uA7eXExBj/1meHrrBfoCA56ujW2GUxVjRtSrMjylKh4R4meiYA==}
engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
peerDependencies:
chokidar: ^4.0.0
@@ -880,8 +892,8 @@ packages:
chokidar:
optional: true
- '@angular-devkit/core@19.2.23':
- resolution: {integrity: sha512-RazHPQkUEsNU/OZ75w9UeHxGFMthRiuAW2B/uA7eXExBj/1meHrrBfoCA56ujW2GUxVjRtSrMjylKh4R4meiYA==}
+ '@angular-devkit/core@19.2.24':
+ resolution: {integrity: sha512-Kd49warf6U/EyWe5BszF/eebN3zQ3bk7tgfEljAw8q/rX95UUtriJubWvp6pgzHfzBA4jwq8f+QiNZB8eBEXPA==}
engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
peerDependencies:
chokidar: ^4.0.0
@@ -889,19 +901,19 @@ packages:
chokidar:
optional: true
- '@angular-devkit/schematics-cli@19.2.19':
- resolution: {integrity: sha512-7q9UY6HK6sccL9F3cqGRUwKhM7b/XfD2YcVaZ2WD7VMaRlRm85v6mRjSrfKIAwxcQU0UK27kMc79NIIqaHjzxA==}
+ '@angular-devkit/schematics-cli@19.2.24':
+ resolution: {integrity: sha512-bsStZQG67J1HBqTmWxtIcobvgrn32L4UOdL7hGyOru5VxDWPNA8pRnDYavT3hnJeBkJYPoQIw8u7Dm0ecoQprw==}
engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
hasBin: true
- '@angular-devkit/schematics@19.2.19':
- resolution: {integrity: sha512-J4Jarr0SohdrHcb40gTL4wGPCQ952IMWF1G/MSAQfBAPvA9ZKApYhpxcY7PmehVePve+ujpus1dGsJ7dPxz8Kg==}
- engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
-
'@angular-devkit/schematics@19.2.23':
resolution: {integrity: sha512-Jzs7YM4X6azmHU7Mw5tQSPMuvaqYS8SLnZOJbtiXCy1JyuW9bm/WBBecNHMiuZ8LHXKhvQ6AVX1tKrzF6uiDmw==}
engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
+ '@angular-devkit/schematics@19.2.24':
+ resolution: {integrity: sha512-lnw+ZM1Io+cJAkReC0NPDjqObL8NtKzKIkdgEEKC8CUmkhurYhedbicN8Y8NYHgG1uLd2GozW3+/QqPRZaN+Lw==}
+ engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
+
'@antfu/install-pkg@1.1.0':
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
@@ -2311,11 +2323,11 @@ packages:
y-prosemirror: 1.3.7
yjs: ^13.6.8
- '@hono/node-server@1.19.10':
- resolution: {integrity: sha512-hZ7nOssGqRgyV3FVVQdfi+U4q02uB23bpnYpdvNXkYTRRyWx84b7yf1ans+dnJ/7h41sGL3CeQTfO+ZGxuO+Iw==}
+ '@hono/node-server@1.19.13':
+ resolution: {integrity: sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==}
engines: {node: '>=18.14.1'}
peerDependencies:
- hono: 4.12.8
+ hono: 4.12.12
'@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
@@ -2347,15 +2359,6 @@ packages:
resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==}
engines: {node: '>=18'}
- '@inquirer/checkbox@4.1.2':
- resolution: {integrity: sha512-PL9ixC5YsPXzXhAZFUPmkXGxfgjkdfZdPEPPmt4kFwQ4LBMDG9n/nHXYRGGZSKZJs+d1sGKWgS2GiPzVRKUdtQ==}
- engines: {node: '>=18'}
- peerDependencies:
- '@types/node': '>=18'
- peerDependenciesMeta:
- '@types/node':
- optional: true
-
'@inquirer/checkbox@4.3.2':
resolution: {integrity: sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==}
engines: {node: '>=18'}
@@ -2374,24 +2377,6 @@ packages:
'@types/node':
optional: true
- '@inquirer/confirm@5.1.6':
- resolution: {integrity: sha512-6ZXYK3M1XmaVBZX6FCfChgtponnL0R6I7k8Nu+kaoNkT828FVZTcca1MqmWQipaW2oNREQl5AaPCUOOCVNdRMw==}
- engines: {node: '>=18'}
- peerDependencies:
- '@types/node': '>=18'
- peerDependenciesMeta:
- '@types/node':
- optional: true
-
- '@inquirer/core@10.1.7':
- resolution: {integrity: sha512-AA9CQhlrt6ZgiSy6qoAigiA1izOa751ugX6ioSjqgJ+/Gd+tEN/TORk5sUYNjXuHWfW0r1n/a6ak4u/NqHHrtA==}
- engines: {node: '>=18'}
- peerDependencies:
- '@types/node': '>=18'
- peerDependenciesMeta:
- '@types/node':
- optional: true
-
'@inquirer/core@10.3.2':
resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==}
engines: {node: '>=18'}
@@ -2410,15 +2395,6 @@ packages:
'@types/node':
optional: true
- '@inquirer/editor@4.2.7':
- resolution: {integrity: sha512-gktCSQtnSZHaBytkJKMKEuswSk2cDBuXX5rxGFv306mwHfBPjg5UAldw9zWGoEyvA9KpRDkeM4jfrx0rXn0GyA==}
- engines: {node: '>=18'}
- peerDependencies:
- '@types/node': '>=18'
- peerDependenciesMeta:
- '@types/node':
- optional: true
-
'@inquirer/expand@4.0.23':
resolution: {integrity: sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==}
engines: {node: '>=18'}
@@ -2428,15 +2404,6 @@ packages:
'@types/node':
optional: true
- '@inquirer/expand@4.0.9':
- resolution: {integrity: sha512-Xxt6nhomWTAmuSX61kVgglLjMEFGa+7+F6UUtdEUeg7fg4r9vaFttUUKrtkViYYrQBA5Ia1tkOJj2koP9BuLig==}
- engines: {node: '>=18'}
- peerDependencies:
- '@types/node': '>=18'
- peerDependenciesMeta:
- '@types/node':
- optional: true
-
'@inquirer/external-editor@1.0.3':
resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==}
engines: {node: '>=18'}
@@ -2446,23 +2413,10 @@ packages:
'@types/node':
optional: true
- '@inquirer/figures@1.0.10':
- resolution: {integrity: sha512-Ey6176gZmeqZuY/W/nZiUyvmb1/qInjcpiZjXWi6nON+nxJpD1bxtSoBxNliGISae32n6OwbY+TSXPZ1CfS4bw==}
- engines: {node: '>=18'}
-
'@inquirer/figures@1.0.15':
resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==}
engines: {node: '>=18'}
- '@inquirer/input@4.1.6':
- resolution: {integrity: sha512-1f5AIsZuVjPT4ecA8AwaxDFNHny/tSershP/cTvTDxLdiIGTeILNcKozB0LaYt6mojJLUbOYhpIxicaYf7UKIQ==}
- engines: {node: '>=18'}
- peerDependencies:
- '@types/node': '>=18'
- peerDependenciesMeta:
- '@types/node':
- optional: true
-
'@inquirer/input@4.3.1':
resolution: {integrity: sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==}
engines: {node: '>=18'}
@@ -2481,15 +2435,6 @@ packages:
'@types/node':
optional: true
- '@inquirer/number@3.0.9':
- resolution: {integrity: sha512-iN2xZvH3tyIYXLXBvlVh0npk1q/aVuKXZo5hj+K3W3D4ngAEq/DkLpofRzx6oebTUhBvOgryZ+rMV0yImKnG3w==}
- engines: {node: '>=18'}
- peerDependencies:
- '@types/node': '>=18'
- peerDependenciesMeta:
- '@types/node':
- optional: true
-
'@inquirer/password@4.0.23':
resolution: {integrity: sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==}
engines: {node: '>=18'}
@@ -2499,15 +2444,6 @@ packages:
'@types/node':
optional: true
- '@inquirer/password@4.0.9':
- resolution: {integrity: sha512-xBEoOw1XKb0rIN208YU7wM7oJEHhIYkfG7LpTJAEW913GZeaoQerzf5U/LSHI45EVvjAdgNXmXgH51cUXKZcJQ==}
- engines: {node: '>=18'}
- peerDependencies:
- '@types/node': '>=18'
- peerDependenciesMeta:
- '@types/node':
- optional: true
-
'@inquirer/prompts@7.10.1':
resolution: {integrity: sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==}
engines: {node: '>=18'}
@@ -2526,15 +2462,6 @@ packages:
'@types/node':
optional: true
- '@inquirer/rawlist@4.0.9':
- resolution: {integrity: sha512-+5t6ebehKqgoxV8fXwE49HkSF2Rc9ijNiVGEQZwvbMI61/Q5RcD+jWD6Gs1tKdz5lkI8GRBL31iO0HjGK1bv+A==}
- engines: {node: '>=18'}
- peerDependencies:
- '@types/node': '>=18'
- peerDependenciesMeta:
- '@types/node':
- optional: true
-
'@inquirer/rawlist@4.1.11':
resolution: {integrity: sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==}
engines: {node: '>=18'}
@@ -2544,15 +2471,6 @@ packages:
'@types/node':
optional: true
- '@inquirer/search@3.0.9':
- resolution: {integrity: sha512-DWmKztkYo9CvldGBaRMr0ETUHgR86zE6sPDVOHsqz4ISe9o1LuiWfgJk+2r75acFclA93J/lqzhT0dTjCzHuoA==}
- engines: {node: '>=18'}
- peerDependencies:
- '@types/node': '>=18'
- peerDependenciesMeta:
- '@types/node':
- optional: true
-
'@inquirer/search@3.2.2':
resolution: {integrity: sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==}
engines: {node: '>=18'}
@@ -2562,15 +2480,6 @@ packages:
'@types/node':
optional: true
- '@inquirer/select@4.0.9':
- resolution: {integrity: sha512-BpJyJe7Dkhv2kz7yG7bPSbJLQuu/rqyNlF1CfiiFeFwouegfH+zh13KDyt6+d9DwucKo7hqM3wKLLyJxZMO+Xg==}
- engines: {node: '>=18'}
- peerDependencies:
- '@types/node': '>=18'
- peerDependenciesMeta:
- '@types/node':
- optional: true
-
'@inquirer/select@4.4.2':
resolution: {integrity: sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==}
engines: {node: '>=18'}
@@ -2589,15 +2498,6 @@ packages:
'@types/node':
optional: true
- '@inquirer/type@3.0.4':
- resolution: {integrity: sha512-2MNFrDY8jkFYc9Il9DgLsHhMzuHnOYM1+CUYVWbzu9oT0hC7V7EcYvdCKeoll/Fcci04A+ERZ9wcc7cQ8lTkIA==}
- engines: {node: '>=18'}
- peerDependencies:
- '@types/node': '>=18'
- peerDependenciesMeta:
- '@types/node':
- optional: true
-
'@ioredis/commands@1.5.1':
resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==}
@@ -2740,8 +2640,8 @@ packages:
'@keyv/serialize@1.1.1':
resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==}
- '@langchain/core@1.1.34':
- resolution: {integrity: sha512-IDlZES5Vexo5meLQRCGkAU7NM0tPGPfPP5wcUzBd7Ot+JoFBmSXutC4gGzvZod5AKRVn3I0Qy5k8vkTraY21jA==}
+ '@langchain/core@1.1.39':
+ resolution: {integrity: sha512-DP9c7TREy6iA7HnywstmUAsNyJNYTFpRg2yBfQ+6H0l1HnvQzei9GsQ36GeOLxgRaD3vm9K8urCcawSC7yQpCw==}
engines: {node: '>=20'}
'@langchain/textsplitters@1.0.1':
@@ -2825,8 +2725,8 @@ packages:
'@mermaid-js/parser@1.1.0':
resolution: {integrity: sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==}
- '@modelcontextprotocol/sdk@1.27.1':
- resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==}
+ '@modelcontextprotocol/sdk@1.29.0':
+ resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==}
engines: {node: '>=18'}
peerDependencies:
'@cfworker/json-schema': ^4.1.1
@@ -3050,12 +2950,12 @@ packages:
keyv: '>=5'
rxjs: ^7.8.1
- '@nestjs/cli@11.0.16':
- resolution: {integrity: sha512-P0H+Vcjki6P5160E5QnMt3Q0X5FTg4PZkP99Ig4lm/4JWqfw32j3EXv3YBTJ2DmxLwOQ/IS9F7dzKpMAgzKTGg==}
+ '@nestjs/cli@11.0.21':
+ resolution: {integrity: sha512-F8mV0Sj/zVEouzR3NxBuJy08YHTUOmC5Xdcx3qIIaJWzrm8Vw86CHkhkaPBJ5ewRMHPDCShPmhsfwhpCcjts3A==}
engines: {node: '>= 20.11'}
hasBin: true
peerDependencies:
- '@swc/cli': ^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0
+ '@swc/cli': ^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 || ^0.8.0
'@swc/core': ^1.3.62
peerDependenciesMeta:
'@swc/cli':
@@ -3111,12 +3011,12 @@ packages:
peerDependencies:
'@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
- '@nestjs/mapped-types@2.1.0':
- resolution: {integrity: sha512-W+n+rM69XsFdwORF11UqJahn4J3xi4g/ZEOlJNL6KoW5ygWSmBB2p0S2BZ4FQeS/NDH72e6xIcu35SfJnE8bXw==}
+ '@nestjs/mapped-types@2.1.1':
+ resolution: {integrity: sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==}
peerDependencies:
'@nestjs/common': ^10.0.0 || ^11.0.0
class-transformer: ^0.4.0 || ^0.5.0
- class-validator: ^0.13.0 || ^0.14.0
+ class-validator: ^0.13.0 || ^0.14.0 || ^0.15.0
reflect-metadata: ^0.1.12 || ^0.2.0
peerDependenciesMeta:
class-transformer:
@@ -5369,9 +5269,6 @@ packages:
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
- '@types/uuid@10.0.0':
- resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
-
'@types/validator@13.15.10':
resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
@@ -5634,8 +5531,8 @@ packages:
resolution: {integrity: sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q==}
engines: {node: '>= 16'}
- '@xmldom/xmldom@0.8.10':
- resolution: {integrity: sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==}
+ '@xmldom/xmldom@0.8.12':
+ resolution: {integrity: sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==}
engines: {node: '>=10.0.0'}
'@xtuc/ieee754@1.2.0':
@@ -5690,6 +5587,11 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
+ acorn@8.16.0:
+ resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
+ engines: {node: '>=0.4.0'}
+ hasBin: true
+
address@1.2.2:
resolution: {integrity: sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==}
engines: {node: '>= 10.0.0'}
@@ -5865,11 +5767,8 @@ packages:
avvio@9.1.0:
resolution: {integrity: sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==}
- axios@1.13.5:
- resolution: {integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==}
-
- axios@1.13.6:
- resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==}
+ axios@1.15.0:
+ resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==}
babel-jest@30.3.0:
resolution: {integrity: sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ==}
@@ -6093,9 +5992,6 @@ packages:
resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==}
engines: {node: '>=10'}
- chardet@0.7.0:
- resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==}
-
chardet@2.1.1:
resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==}
@@ -6277,9 +6173,6 @@ packages:
resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
engines: {node: ^14.18.0 || >=16.10.0}
- console-table-printer@2.14.6:
- resolution: {integrity: sha512-MCBl5HNVaFuuHW6FGbL/4fB7N/ormCy+tQ+sxTrF6QtSbSNETvPuOVbkJBhzDgYhvjWGrTma4eYJa37ZuoQsPw==}
-
content-disposition@1.0.1:
resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==}
engines: {node: '>=18'}
@@ -6860,14 +6753,14 @@ packages:
resolution: {integrity: sha512-gmNvsYi9C8iErnZdVcJnvCpSKbWTt1E8+JZo8b+daLninywUWi5NQ5STSHZ9rFjFO7imNcvb8Pc5pe/wMR5xEw==}
engines: {node: '>=10.2.0'}
- enhanced-resolve@5.17.1:
- resolution: {integrity: sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==}
- engines: {node: '>=10.13.0'}
-
enhanced-resolve@5.19.0:
resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==}
engines: {node: '>=10.13.0'}
+ enhanced-resolve@5.20.1:
+ resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
+ engines: {node: '>=10.13.0'}
+
enquirer@2.3.6:
resolution: {integrity: sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==}
engines: {node: '>=8.6'}
@@ -7107,9 +7000,11 @@ packages:
exsolve@1.0.7:
resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==}
- external-editor@3.1.0:
- resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==}
- engines: {node: '>=4'}
+ fast-bm25@0.0.5:
+ resolution: {integrity: sha512-6HTiLmPkgeqcPJHccN0pXdqnA7OzhaEQZTFzWnfjIyPoX5sGVKUUpfRc2K2o6zMwK+g009miRhADYn/f2Ax0Mg==}
+ engines: {node: '>=14.0.0'}
+ peerDependencies:
+ typescript: ^5.6.3
fast-copy@4.0.2:
resolution: {integrity: sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==}
@@ -7233,8 +7128,8 @@ packages:
resolution: {integrity: sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==}
engines: {node: '>=12'}
- follow-redirects@1.15.11:
- resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
+ follow-redirects@1.16.0:
+ resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
@@ -7414,8 +7309,8 @@ packages:
hachure-fill@0.5.2:
resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==}
- handlebars@4.7.8:
- resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==}
+ handlebars@4.7.9:
+ resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==}
engines: {node: '>=0.4.7'}
hasBin: true
@@ -7476,8 +7371,8 @@ packages:
hoist-non-react-statics@3.3.2:
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
- hono@4.12.8:
- resolution: {integrity: sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==}
+ hono@4.12.12:
+ resolution: {integrity: sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==}
engines: {node: '>=16.9.0'}
hookified@1.15.1:
@@ -7537,10 +7432,6 @@ packages:
typescript:
optional: true
- iconv-lite@0.4.24:
- resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
- engines: {node: '>=0.10.0'}
-
iconv-lite@0.6.3:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
@@ -8275,13 +8166,14 @@ packages:
resolution: {integrity: sha512-zu9QWmjpzJcomzdJQAHgDVhLGq5bLosVak1KVa40NzQHXfqr4eAHupvnPOVXEoLkg6Ocefvf/93d//SB7du4YQ==}
engines: {node: '>=20.10.0', npm: '>=10.2.3'}
- langsmith@0.5.7:
- resolution: {integrity: sha512-FjYf2oBGMoSXnaT4SRaFguIiGJaonZ5VKWKJDPl9awLZjz2RkN29AcQWceecSINVzXzTvtRWPOjAWT+XggqNNg==}
+ langsmith@0.5.18:
+ resolution: {integrity: sha512-3zuZUWffTHQ+73EAwnodADtf534VNEZUpXr9jC12qyG8/IQuJET7PRsCpTb9wX2lmBspakwLUpqpj3tNm/0bVA==}
peerDependencies:
'@opentelemetry/api': '*'
'@opentelemetry/exporter-trace-otlp-proto': '*'
'@opentelemetry/sdk-trace-base': '*'
openai: '*'
+ ws: 8.20.0
peerDependenciesMeta:
'@opentelemetry/api':
optional: true
@@ -8291,6 +8183,8 @@ packages:
optional: true
openai:
optional: true
+ ws:
+ optional: true
layout-base@1.0.2:
resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==}
@@ -8431,8 +8325,8 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
- lodash-es@4.17.23:
- resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==}
+ lodash-es@4.18.1:
+ resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
lodash.debounce@4.0.8:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
@@ -8473,8 +8367,8 @@ packages:
lodash.throttle@4.1.1:
resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==}
- lodash@4.17.23:
- resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
+ lodash@4.18.1:
+ resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
log-symbols@4.1.0:
resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==}
@@ -8816,8 +8710,8 @@ packages:
node-releases@2.0.27:
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
- nodemailer@8.0.4:
- resolution: {integrity: sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==}
+ nodemailer@8.0.5:
+ resolution: {integrity: sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==}
engines: {node: '>=6.0.0'}
normalize-path@3.0.0:
@@ -8930,7 +8824,7 @@ packages:
resolution: {integrity: sha512-qqjzHls7F5xkXNGy9P1Ei1rorI5LWupUUFWP66zPU8FlZbiITX8SFcHMKNZg/NATJ0LpIZcMUFxSwQmdeQPwSw==}
hasBin: true
peerDependencies:
- ws: 8.19.0
+ ws: 8.20.0
zod: ^3.25 || ^4.0
peerDependenciesMeta:
ws:
@@ -9249,6 +9143,9 @@ packages:
points-on-path@0.2.1:
resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==}
+ porter2@1.1.0:
+ resolution: {integrity: sha512-Io2cLEdZn0O1dH60pRsjmr/cH/qJJ/j6Cjubz8wQWi0b6vPdQIUxSBQKyx9d+8CN7fSnY+5uOU3rErMFjNqcLw==}
+
possible-typed-array-names@1.0.0:
resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==}
engines: {node: '>= 0.4'}
@@ -9422,8 +9319,9 @@ packages:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
- proxy-from-env@1.1.0:
- resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
+ proxy-from-env@2.1.0:
+ resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
+ engines: {node: '>=10'}
prr@1.0.1:
resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==}
@@ -9922,10 +9820,6 @@ packages:
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
engines: {node: '>= 18'}
- serialize-javascript@7.0.3:
- resolution: {integrity: sha512-h+cZ/XXarqDgCjo+YSyQU/ulDEESGGf8AMK9pPNmhNSl/FzPl6L8pMp1leca5z6NuG6tvV/auC8/43tmovowww==}
- engines: {node: '>=20.0.0'}
-
serve-static@2.2.1:
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
engines: {node: '>= 18'}
@@ -9992,9 +9886,6 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
- simple-wcswidth@1.1.2:
- resolution: {integrity: sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw==}
-
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
@@ -10219,10 +10110,6 @@ packages:
tailwindcss@4.1.18:
resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==}
- tapable@2.2.1:
- resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==}
- engines: {node: '>=6'}
-
tapable@2.3.0:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'}
@@ -10231,8 +10118,8 @@ packages:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'}
- terser-webpack-plugin@5.3.16:
- resolution: {integrity: sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==}
+ terser-webpack-plugin@5.4.0:
+ resolution: {integrity: sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==}
engines: {node: '>= 10.13.0'}
peerDependencies:
'@swc/core': '*'
@@ -10367,8 +10254,8 @@ packages:
jest-util:
optional: true
- ts-loader@9.5.4:
- resolution: {integrity: sha512-nCz0rEwunlTZiy6rXFByQU1kVVpCIgUpc/psFiKVrUwrizdnIbRFu8w7bxhUF0X613DYwT4XzrZHpVyMe758hQ==}
+ ts-loader@9.5.7:
+ resolution: {integrity: sha512-/ZNrKgA3K3PtpMYOC71EeMWIloGw3IYEa5/t1cyz2r5/PyUwTXGzYJvcD3kfUvmhlfpz1rhV8B2O6IVTQ0avsg==}
engines: {node: '>=12.0.0'}
peerDependencies:
typescript: '*'
@@ -10656,14 +10543,14 @@ packages:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
- vite@8.0.3:
- resolution: {integrity: sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==}
+ vite@8.0.5:
+ resolution: {integrity: sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
'@types/node': ^20.19.0 || >=22.12.0
'@vitejs/devtools': ^0.1.0
- esbuild: ^0.27.0
+ esbuild: ^0.27.0 || ^0.28.0
jiti: '>=1.21.0'
less: ^4.0.0
sass: ^1.70.0
@@ -10760,12 +10647,12 @@ packages:
resolution: {integrity: sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==}
engines: {node: '>=6'}
- webpack-sources@3.3.3:
- resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==}
+ webpack-sources@3.3.4:
+ resolution: {integrity: sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==}
engines: {node: '>=10.13.0'}
- webpack@5.104.1:
- resolution: {integrity: sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==}
+ webpack@5.106.0:
+ resolution: {integrity: sha512-Pkx5joZ9RrdgO5LBkyX1L2ZAJeK/Taz3vqZ9CbcP0wS5LEMx5QkKsEwLl29QJfihZ+DKRBFldzy1O30pJ1MDpA==}
engines: {node: '>=10.13.0'}
hasBin: true
peerDependencies:
@@ -10861,8 +10748,8 @@ packages:
resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
- ws@8.19.0:
- resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==}
+ ws@8.20.0:
+ resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
@@ -11000,10 +10887,6 @@ packages:
resolution: {integrity: sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==}
engines: {node: '>=12.20'}
- yoctocolors-cjs@2.1.2:
- resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==}
- engines: {node: '>=18'}
-
yoctocolors-cjs@2.1.3:
resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==}
engines: {node: '>=18'}
@@ -11083,7 +10966,7 @@ snapshots:
dependencies:
json-schema: 0.4.0
- '@angular-devkit/core@19.2.19(chokidar@4.0.3)':
+ '@angular-devkit/core@19.2.23(chokidar@4.0.3)':
dependencies:
ajv: 8.18.0
ajv-formats: 3.0.1(ajv@8.18.0)
@@ -11094,7 +10977,7 @@ snapshots:
optionalDependencies:
chokidar: 4.0.3
- '@angular-devkit/core@19.2.23(chokidar@4.0.3)':
+ '@angular-devkit/core@19.2.24(chokidar@4.0.3)':
dependencies:
ajv: 8.18.0
ajv-formats: 3.0.1(ajv@8.18.0)
@@ -11105,10 +10988,10 @@ snapshots:
optionalDependencies:
chokidar: 4.0.3
- '@angular-devkit/schematics-cli@19.2.19(@types/node@25.5.2)(chokidar@4.0.3)':
+ '@angular-devkit/schematics-cli@19.2.24(@types/node@25.5.2)(chokidar@4.0.3)':
dependencies:
- '@angular-devkit/core': 19.2.19(chokidar@4.0.3)
- '@angular-devkit/schematics': 19.2.19(chokidar@4.0.3)
+ '@angular-devkit/core': 19.2.24(chokidar@4.0.3)
+ '@angular-devkit/schematics': 19.2.24(chokidar@4.0.3)
'@inquirer/prompts': 7.3.2(@types/node@25.5.2)
ansi-colors: 4.1.3
symbol-observable: 4.0.0
@@ -11117,9 +11000,9 @@ snapshots:
- '@types/node'
- chokidar
- '@angular-devkit/schematics@19.2.19(chokidar@4.0.3)':
+ '@angular-devkit/schematics@19.2.23(chokidar@4.0.3)':
dependencies:
- '@angular-devkit/core': 19.2.19(chokidar@4.0.3)
+ '@angular-devkit/core': 19.2.23(chokidar@4.0.3)
jsonc-parser: 3.3.1
magic-string: 0.30.17
ora: 5.4.1
@@ -11127,9 +11010,9 @@ snapshots:
transitivePeerDependencies:
- chokidar
- '@angular-devkit/schematics@19.2.23(chokidar@4.0.3)':
+ '@angular-devkit/schematics@19.2.24(chokidar@4.0.3)':
dependencies:
- '@angular-devkit/core': 19.2.23(chokidar@4.0.3)
+ '@angular-devkit/core': 19.2.24(chokidar@4.0.3)
jsonc-parser: 3.3.1
magic-string: 0.30.17
ora: 5.4.1
@@ -12433,23 +12316,23 @@ snapshots:
dependencies:
'@chevrotain/gast': 11.0.3
'@chevrotain/types': 11.0.3
- lodash-es: 4.17.23
+ lodash-es: 4.18.1
'@chevrotain/cst-dts-gen@11.1.2':
dependencies:
'@chevrotain/gast': 11.1.2
'@chevrotain/types': 11.1.2
- lodash-es: 4.17.23
+ lodash-es: 4.18.1
'@chevrotain/gast@11.0.3':
dependencies:
'@chevrotain/types': 11.0.3
- lodash-es: 4.17.23
+ lodash-es: 4.18.1
'@chevrotain/gast@11.1.2':
dependencies:
'@chevrotain/types': 11.1.2
- lodash-es: 4.17.23
+ lodash-es: 4.18.1
'@chevrotain/regexp-to-ast@11.0.3': {}
@@ -12901,7 +12784,7 @@ snapshots:
'@hocuspocus/common': 3.4.4
'@lifeomic/attempt': 3.0.3
lib0: 0.2.117
- ws: 8.19.0
+ ws: 8.20.0
y-protocols: 1.0.6(yjs@13.6.30)
yjs: 13.6.30
transitivePeerDependencies:
@@ -12915,7 +12798,7 @@ snapshots:
async-mutex: 0.5.0
kleur: 4.1.5
lib0: 0.2.117
- ws: 8.19.0
+ ws: 8.20.0
y-protocols: 1.0.6(yjs@13.6.30)
yjs: 13.6.30
transitivePeerDependencies:
@@ -12930,9 +12813,9 @@ snapshots:
y-prosemirror: 1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30)
yjs: 13.6.30
- '@hono/node-server@1.19.10(hono@4.12.8)':
+ '@hono/node-server@1.19.13(hono@4.12.12)':
dependencies:
- hono: 4.12.8
+ hono: 4.12.12
'@humanfs/core@0.19.1': {}
@@ -12957,16 +12840,6 @@ snapshots:
'@inquirer/ansi@1.0.2': {}
- '@inquirer/checkbox@4.1.2(@types/node@25.5.2)':
- dependencies:
- '@inquirer/core': 10.1.7(@types/node@25.5.2)
- '@inquirer/figures': 1.0.10
- '@inquirer/type': 3.0.4(@types/node@25.5.2)
- ansi-escapes: 4.3.2
- yoctocolors-cjs: 2.1.2
- optionalDependencies:
- '@types/node': 25.5.2
-
'@inquirer/checkbox@4.3.2(@types/node@25.5.2)':
dependencies:
'@inquirer/ansi': 1.0.2
@@ -12984,26 +12857,6 @@ snapshots:
optionalDependencies:
'@types/node': 25.5.2
- '@inquirer/confirm@5.1.6(@types/node@25.5.2)':
- dependencies:
- '@inquirer/core': 10.1.7(@types/node@25.5.2)
- '@inquirer/type': 3.0.4(@types/node@25.5.2)
- optionalDependencies:
- '@types/node': 25.5.2
-
- '@inquirer/core@10.1.7(@types/node@25.5.2)':
- dependencies:
- '@inquirer/figures': 1.0.10
- '@inquirer/type': 3.0.4(@types/node@25.5.2)
- ansi-escapes: 4.3.2
- cli-width: 4.1.0
- mute-stream: 2.0.0
- signal-exit: 4.1.0
- wrap-ansi: 6.2.0
- yoctocolors-cjs: 2.1.2
- optionalDependencies:
- '@types/node': 25.5.2
-
'@inquirer/core@10.3.2(@types/node@25.5.2)':
dependencies:
'@inquirer/ansi': 1.0.2
@@ -13025,14 +12878,6 @@ snapshots:
optionalDependencies:
'@types/node': 25.5.2
- '@inquirer/editor@4.2.7(@types/node@25.5.2)':
- dependencies:
- '@inquirer/core': 10.1.7(@types/node@25.5.2)
- '@inquirer/type': 3.0.4(@types/node@25.5.2)
- external-editor: 3.1.0
- optionalDependencies:
- '@types/node': 25.5.2
-
'@inquirer/expand@4.0.23(@types/node@25.5.2)':
dependencies:
'@inquirer/core': 10.3.2(@types/node@25.5.2)
@@ -13041,14 +12886,6 @@ snapshots:
optionalDependencies:
'@types/node': 25.5.2
- '@inquirer/expand@4.0.9(@types/node@25.5.2)':
- dependencies:
- '@inquirer/core': 10.1.7(@types/node@25.5.2)
- '@inquirer/type': 3.0.4(@types/node@25.5.2)
- yoctocolors-cjs: 2.1.2
- optionalDependencies:
- '@types/node': 25.5.2
-
'@inquirer/external-editor@1.0.3(@types/node@25.5.2)':
dependencies:
chardet: 2.1.1
@@ -13056,17 +12893,8 @@ snapshots:
optionalDependencies:
'@types/node': 25.5.2
- '@inquirer/figures@1.0.10': {}
-
'@inquirer/figures@1.0.15': {}
- '@inquirer/input@4.1.6(@types/node@25.5.2)':
- dependencies:
- '@inquirer/core': 10.1.7(@types/node@25.5.2)
- '@inquirer/type': 3.0.4(@types/node@25.5.2)
- optionalDependencies:
- '@types/node': 25.5.2
-
'@inquirer/input@4.3.1(@types/node@25.5.2)':
dependencies:
'@inquirer/core': 10.3.2(@types/node@25.5.2)
@@ -13081,13 +12909,6 @@ snapshots:
optionalDependencies:
'@types/node': 25.5.2
- '@inquirer/number@3.0.9(@types/node@25.5.2)':
- dependencies:
- '@inquirer/core': 10.1.7(@types/node@25.5.2)
- '@inquirer/type': 3.0.4(@types/node@25.5.2)
- optionalDependencies:
- '@types/node': 25.5.2
-
'@inquirer/password@4.0.23(@types/node@25.5.2)':
dependencies:
'@inquirer/ansi': 1.0.2
@@ -13096,14 +12917,6 @@ snapshots:
optionalDependencies:
'@types/node': 25.5.2
- '@inquirer/password@4.0.9(@types/node@25.5.2)':
- dependencies:
- '@inquirer/core': 10.1.7(@types/node@25.5.2)
- '@inquirer/type': 3.0.4(@types/node@25.5.2)
- ansi-escapes: 4.3.2
- optionalDependencies:
- '@types/node': 25.5.2
-
'@inquirer/prompts@7.10.1(@types/node@25.5.2)':
dependencies:
'@inquirer/checkbox': 4.3.2(@types/node@25.5.2)
@@ -13121,24 +12934,16 @@ snapshots:
'@inquirer/prompts@7.3.2(@types/node@25.5.2)':
dependencies:
- '@inquirer/checkbox': 4.1.2(@types/node@25.5.2)
- '@inquirer/confirm': 5.1.6(@types/node@25.5.2)
- '@inquirer/editor': 4.2.7(@types/node@25.5.2)
- '@inquirer/expand': 4.0.9(@types/node@25.5.2)
- '@inquirer/input': 4.1.6(@types/node@25.5.2)
- '@inquirer/number': 3.0.9(@types/node@25.5.2)
- '@inquirer/password': 4.0.9(@types/node@25.5.2)
- '@inquirer/rawlist': 4.0.9(@types/node@25.5.2)
- '@inquirer/search': 3.0.9(@types/node@25.5.2)
- '@inquirer/select': 4.0.9(@types/node@25.5.2)
- optionalDependencies:
- '@types/node': 25.5.2
-
- '@inquirer/rawlist@4.0.9(@types/node@25.5.2)':
- dependencies:
- '@inquirer/core': 10.1.7(@types/node@25.5.2)
- '@inquirer/type': 3.0.4(@types/node@25.5.2)
- yoctocolors-cjs: 2.1.2
+ '@inquirer/checkbox': 4.3.2(@types/node@25.5.2)
+ '@inquirer/confirm': 5.1.21(@types/node@25.5.2)
+ '@inquirer/editor': 4.2.23(@types/node@25.5.2)
+ '@inquirer/expand': 4.0.23(@types/node@25.5.2)
+ '@inquirer/input': 4.3.1(@types/node@25.5.2)
+ '@inquirer/number': 3.0.23(@types/node@25.5.2)
+ '@inquirer/password': 4.0.23(@types/node@25.5.2)
+ '@inquirer/rawlist': 4.1.11(@types/node@25.5.2)
+ '@inquirer/search': 3.2.2(@types/node@25.5.2)
+ '@inquirer/select': 4.4.2(@types/node@25.5.2)
optionalDependencies:
'@types/node': 25.5.2
@@ -13150,15 +12955,6 @@ snapshots:
optionalDependencies:
'@types/node': 25.5.2
- '@inquirer/search@3.0.9(@types/node@25.5.2)':
- dependencies:
- '@inquirer/core': 10.1.7(@types/node@25.5.2)
- '@inquirer/figures': 1.0.10
- '@inquirer/type': 3.0.4(@types/node@25.5.2)
- yoctocolors-cjs: 2.1.2
- optionalDependencies:
- '@types/node': 25.5.2
-
'@inquirer/search@3.2.2(@types/node@25.5.2)':
dependencies:
'@inquirer/core': 10.3.2(@types/node@25.5.2)
@@ -13168,16 +12964,6 @@ snapshots:
optionalDependencies:
'@types/node': 25.5.2
- '@inquirer/select@4.0.9(@types/node@25.5.2)':
- dependencies:
- '@inquirer/core': 10.1.7(@types/node@25.5.2)
- '@inquirer/figures': 1.0.10
- '@inquirer/type': 3.0.4(@types/node@25.5.2)
- ansi-escapes: 4.3.2
- yoctocolors-cjs: 2.1.2
- optionalDependencies:
- '@types/node': 25.5.2
-
'@inquirer/select@4.4.2(@types/node@25.5.2)':
dependencies:
'@inquirer/ansi': 1.0.2
@@ -13192,10 +12978,6 @@ snapshots:
optionalDependencies:
'@types/node': 25.5.2
- '@inquirer/type@3.0.4(@types/node@25.5.2)':
- optionalDependencies:
- '@types/node': 25.5.2
-
'@ioredis/commands@1.5.1': {}
'@istanbuljs/load-nyc-config@1.1.0':
@@ -13454,7 +13236,7 @@ snapshots:
'@keyv/serialize@1.1.1': {}
- '@langchain/core@1.1.34(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@4.3.6))':
+ '@langchain/core@1.1.39(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0)':
dependencies:
'@cfworker/json-schema': 4.1.1
'@standard-schema/spec': 1.1.0
@@ -13462,7 +13244,7 @@ snapshots:
camelcase: 6.3.0
decamelize: 1.2.0
js-tiktoken: 1.0.21
- langsmith: 0.5.7(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@4.3.6))
+ langsmith: 0.5.18(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0)
mustache: 4.2.0
p-queue: 6.6.2
uuid: 11.1.0
@@ -13472,10 +13254,11 @@ snapshots:
- '@opentelemetry/exporter-trace-otlp-proto'
- '@opentelemetry/sdk-trace-base'
- openai
+ - ws
- '@langchain/textsplitters@1.0.1(@langchain/core@1.1.34(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@4.3.6)))':
+ '@langchain/textsplitters@1.0.1(@langchain/core@1.1.39(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))':
dependencies:
- '@langchain/core': 1.1.34(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@4.3.6))
+ '@langchain/core': 1.1.39(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0)
js-tiktoken: 1.0.21
'@lifeomic/attempt@3.0.3': {}
@@ -13555,19 +13338,19 @@ snapshots:
dependencies:
langium: 4.2.1
- '@modelcontextprotocol/sdk@1.27.1(@cfworker/json-schema@4.1.1)(zod@4.3.6)':
+ '@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)':
dependencies:
- '@hono/node-server': 1.19.10(hono@4.12.8)
+ '@hono/node-server': 1.19.13(hono@4.12.12)
ajv: 8.18.0
ajv-formats: 3.0.1(ajv@8.18.0)
content-type: 1.0.5
cors: 2.8.5
- cross-spawn: 7.0.5
+ cross-spawn: 7.0.6
eventsource: 3.0.7
eventsource-parser: 3.0.6
express: 5.2.1
express-rate-limit: 8.2.2(express@5.2.1)
- hono: 4.12.8
+ hono: 4.12.12
jose: 6.1.3
json-schema-typed: 8.0.2
pkce-challenge: 5.0.1
@@ -13747,25 +13530,25 @@ snapshots:
keyv: 5.6.0
rxjs: 7.8.2
- '@nestjs/cli@11.0.16(@swc/core@1.5.25(@swc/helpers@0.5.5))(@types/node@25.5.2)':
+ '@nestjs/cli@11.0.21(@swc/core@1.5.25(@swc/helpers@0.5.5))(@types/node@25.5.2)':
dependencies:
- '@angular-devkit/core': 19.2.19(chokidar@4.0.3)
- '@angular-devkit/schematics': 19.2.19(chokidar@4.0.3)
- '@angular-devkit/schematics-cli': 19.2.19(@types/node@25.5.2)(chokidar@4.0.3)
+ '@angular-devkit/core': 19.2.24(chokidar@4.0.3)
+ '@angular-devkit/schematics': 19.2.24(chokidar@4.0.3)
+ '@angular-devkit/schematics-cli': 19.2.24(@types/node@25.5.2)(chokidar@4.0.3)
'@inquirer/prompts': 7.10.1(@types/node@25.5.2)
'@nestjs/schematics': 11.0.10(chokidar@4.0.3)(typescript@5.9.3)
ansis: 4.2.0
chokidar: 4.0.3
cli-table3: 0.6.5
commander: 4.1.1
- fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.5.25(@swc/helpers@0.5.5)))
+ fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.106.0(@swc/core@1.5.25(@swc/helpers@0.5.5)))
glob: 13.0.6
node-emoji: 1.11.0
ora: 5.4.1
tsconfig-paths: 4.2.0
tsconfig-paths-webpack-plugin: 4.2.0
typescript: 5.9.3
- webpack: 5.104.1(@swc/core@1.5.25(@swc/helpers@0.5.5))
+ webpack: 5.106.0(@swc/core@1.5.25(@swc/helpers@0.5.5))
webpack-node-externals: 3.0.0
optionalDependencies:
'@swc/core': 1.5.25(@swc/helpers@0.5.5)
@@ -13795,7 +13578,7 @@ snapshots:
'@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
dotenv: 17.2.3
dotenv-expand: 12.0.3
- lodash: 4.17.23
+ lodash: 4.18.1
rxjs: 7.8.2
'@nestjs/core@11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
@@ -13824,7 +13607,7 @@ snapshots:
'@types/jsonwebtoken': 9.0.10
jsonwebtoken: 9.0.3
- '@nestjs/mapped-types@2.1.0(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)':
+ '@nestjs/mapped-types@2.1.1(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)':
dependencies:
'@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
reflect-metadata: 0.2.2
@@ -13927,7 +13710,7 @@ snapshots:
'@types/xml-encryption': 1.2.4
'@types/xml2js': 0.4.14
'@xmldom/is-dom-node': 1.0.1
- '@xmldom/xmldom': 0.8.10
+ '@xmldom/xmldom': 0.8.12
debug: 4.4.0
xml-crypto: 6.1.2
xml-encryption: 3.1.0
@@ -16235,8 +16018,6 @@ snapshots:
'@types/use-sync-external-store@0.0.6': {}
- '@types/uuid@10.0.0': {}
-
'@types/validator@13.15.10': {}
'@types/whatwg-mimetype@3.0.2': {}
@@ -16438,10 +16219,10 @@ snapshots:
'@vercel/oidc@3.1.0': {}
- '@vitejs/plugin-react@6.0.1(vite@8.0.3(@emnapi/core@1.8.1)(@emnapi/runtime@1.5.0)(@types/node@22.19.1)(esbuild@0.27.7)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.8))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))':
+ '@vitejs/plugin-react@6.0.1(vite@8.0.5(@emnapi/core@1.8.1)(@emnapi/runtime@1.5.0)(@types/node@22.19.1)(esbuild@0.27.7)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.8))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.7
- vite: 8.0.3(@emnapi/core@1.8.1)(@emnapi/runtime@1.5.0)(@types/node@22.19.1)(esbuild@0.27.7)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.8))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)
+ vite: 8.0.5(@emnapi/core@1.8.1)(@emnapi/runtime@1.5.0)(@types/node@22.19.1)(esbuild@0.27.7)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.8))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)
'@webassemblyjs/ast@1.14.1':
dependencies:
@@ -16521,7 +16302,7 @@ snapshots:
'@xmldom/is-dom-node@1.0.1': {}
- '@xmldom/xmldom@0.8.10': {}
+ '@xmldom/xmldom@0.8.12': {}
'@xtuc/ieee754@1.2.0': {}
@@ -16552,9 +16333,9 @@ snapshots:
accessor-fn@1.5.3: {}
- acorn-import-phases@1.0.4(acorn@8.15.0):
+ acorn-import-phases@1.0.4(acorn@8.16.0):
dependencies:
- acorn: 8.15.0
+ acorn: 8.16.0
acorn-jsx@5.3.2(acorn@8.15.0):
dependencies:
@@ -16564,6 +16345,8 @@ snapshots:
acorn@8.15.0: {}
+ acorn@8.16.0: {}
+
address@1.2.2: {}
agent-base@7.1.1:
@@ -16765,19 +16548,11 @@ snapshots:
'@fastify/error': 4.0.0
fastq: 1.17.1
- axios@1.13.5:
+ axios@1.15.0:
dependencies:
- follow-redirects: 1.15.11
+ follow-redirects: 1.16.0
form-data: 4.0.5
- proxy-from-env: 1.1.0
- transitivePeerDependencies:
- - debug
-
- axios@1.13.6:
- dependencies:
- follow-redirects: 1.15.11
- form-data: 4.0.5
- proxy-from-env: 1.1.0
+ proxy-from-env: 2.1.0
transitivePeerDependencies:
- debug
@@ -16915,7 +16690,7 @@ snapshots:
bytes: 3.1.2
content-type: 1.0.5
debug: 4.4.3
- http-errors: 2.0.0
+ http-errors: 2.0.1
iconv-lite: 0.7.2
on-finished: 2.4.1
qs: 6.14.2
@@ -17059,8 +16834,6 @@ snapshots:
char-regex@1.0.2: {}
- chardet@0.7.0: {}
-
chardet@2.1.1: {}
check-disk-space@3.4.0: {}
@@ -17091,12 +16864,12 @@ snapshots:
chevrotain-allstar@0.3.1(chevrotain@11.0.3):
dependencies:
chevrotain: 11.0.3
- lodash-es: 4.17.23
+ lodash-es: 4.18.1
chevrotain-allstar@0.3.1(chevrotain@11.1.2):
dependencies:
chevrotain: 11.1.2
- lodash-es: 4.17.23
+ lodash-es: 4.18.1
chevrotain@11.0.3:
dependencies:
@@ -17105,7 +16878,7 @@ snapshots:
'@chevrotain/regexp-to-ast': 11.0.3
'@chevrotain/types': 11.0.3
'@chevrotain/utils': 11.0.3
- lodash-es: 4.17.23
+ lodash-es: 4.18.1
chevrotain@11.1.2:
dependencies:
@@ -17114,7 +16887,7 @@ snapshots:
'@chevrotain/regexp-to-ast': 11.1.2
'@chevrotain/types': 11.1.2
'@chevrotain/utils': 11.1.2
- lodash-es: 4.17.23
+ lodash-es: 4.18.1
chokidar@3.6.0:
dependencies:
@@ -17261,10 +17034,6 @@ snapshots:
consola@3.4.2: {}
- console-table-printer@2.14.6:
- dependencies:
- simple-wcswidth: 1.1.2
-
content-disposition@1.0.1: {}
content-type@1.0.5: {}
@@ -17586,7 +17355,7 @@ snapshots:
dagre-d3-es@7.0.14:
dependencies:
d3: 7.9.0
- lodash-es: 4.17.23
+ lodash-es: 4.18.1
data-urls@5.0.0:
dependencies:
@@ -17834,7 +17603,7 @@ snapshots:
'@socket.io/component-emitter': 3.1.0
debug: 4.3.7
engine.io-parser: 5.2.2
- ws: 8.19.0
+ ws: 8.20.0
xmlhttprequest-ssl: 2.1.2
transitivePeerDependencies:
- bufferutil
@@ -17854,18 +17623,18 @@ snapshots:
cors: 2.8.5
debug: 4.3.7
engine.io-parser: 5.2.2
- ws: 8.19.0
+ ws: 8.20.0
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
- enhanced-resolve@5.17.1:
+ enhanced-resolve@5.19.0:
dependencies:
graceful-fs: 4.2.11
tapable: 2.3.0
- enhanced-resolve@5.19.0:
+ enhanced-resolve@5.20.1:
dependencies:
graceful-fs: 4.2.11
tapable: 2.3.0
@@ -18304,7 +18073,7 @@ snapshots:
etag: 1.8.1
finalhandler: 2.1.1
fresh: 2.0.0
- http-errors: 2.0.0
+ http-errors: 2.0.1
merge-descriptors: 2.0.0
mime-types: 3.0.2
on-finished: 2.4.1
@@ -18316,7 +18085,7 @@ snapshots:
router: 2.2.0
send: 1.2.1
serve-static: 2.2.1
- statuses: 2.0.1
+ statuses: 2.0.2
type-is: 2.0.1
vary: 1.1.2
transitivePeerDependencies:
@@ -18324,11 +18093,10 @@ snapshots:
exsolve@1.0.7: {}
- external-editor@3.1.0:
+ fast-bm25@0.0.5(typescript@5.9.3):
dependencies:
- chardet: 0.7.0
- iconv-lite: 0.4.24
- tmp: 0.2.5
+ porter2: 1.1.0
+ typescript: 5.9.3
fast-copy@4.0.2: {}
@@ -18443,7 +18211,7 @@ snapshots:
escape-html: 1.0.3
on-finished: 2.4.1
parseurl: 1.3.3
- statuses: 2.0.1
+ statuses: 2.0.2
transitivePeerDependencies:
- supports-color
@@ -18478,7 +18246,7 @@ snapshots:
kapsule: 1.16.3
preact: 10.28.3
- follow-redirects@1.15.11: {}
+ follow-redirects@1.16.0: {}
for-each@0.3.3:
dependencies:
@@ -18504,9 +18272,9 @@ snapshots:
float-tooltip: 1.7.5
index-array-by: 1.4.2
kapsule: 1.16.3
- lodash-es: 4.17.23
+ lodash-es: 4.18.1
- fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.5.25(@swc/helpers@0.5.5))):
+ fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.106.0(@swc/core@1.5.25(@swc/helpers@0.5.5))):
dependencies:
'@babel/code-frame': 7.27.1
chalk: 4.1.2
@@ -18519,9 +18287,9 @@ snapshots:
node-abort-controller: 3.1.1
schema-utils: 3.3.0
semver: 7.7.4
- tapable: 2.2.1
+ tapable: 2.3.0
typescript: 5.9.3
- webpack: 5.104.1(@swc/core@1.5.25(@swc/helpers@0.5.5))
+ webpack: 5.106.0(@swc/core@1.5.25(@swc/helpers@0.5.5))
form-data@4.0.5:
dependencies:
@@ -18673,7 +18441,7 @@ snapshots:
hachure-fill@0.5.2: {}
- handlebars@4.7.8:
+ handlebars@4.7.9:
dependencies:
minimist: 1.2.8
neo-async: 2.6.2
@@ -18689,7 +18457,7 @@ snapshots:
'@types/ws': 8.18.1
entities: 7.0.1
whatwg-mimetype: 3.0.0
- ws: 8.19.0
+ ws: 8.20.0
transitivePeerDependencies:
- bufferutil
- utf-8-validate
@@ -18738,7 +18506,7 @@ snapshots:
dependencies:
react-is: 16.13.1
- hono@4.12.8: {}
+ hono@4.12.12: {}
hookified@1.15.1: {}
@@ -18820,10 +18588,6 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
- iconv-lite@0.4.24:
- dependencies:
- safer-buffer: 2.1.2
-
iconv-lite@0.6.3:
dependencies:
safer-buffer: 2.1.2
@@ -19577,7 +19341,7 @@ snapshots:
whatwg-encoding: 3.1.1
whatwg-mimetype: 4.0.0
whatwg-url: 14.2.0
- ws: 8.19.0
+ ws: 8.20.0
xml-name-validator: 5.0.0
transitivePeerDependencies:
- bufferutil
@@ -19662,7 +19426,7 @@ snapshots:
kapsule@1.16.3:
dependencies:
- lodash-es: 4.17.23
+ lodash-es: 4.18.1
katex@0.16.40:
dependencies:
@@ -19730,18 +19494,15 @@ snapshots:
vscode-languageserver-textdocument: 1.0.12
vscode-uri: 3.1.0
- langsmith@0.5.7(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@4.3.6)):
+ langsmith@0.5.18(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0):
dependencies:
- '@types/uuid': 10.0.0
- chalk: 5.6.2
- console-table-printer: 2.14.6
p-queue: 6.6.2
- semver: 7.7.4
uuid: 10.0.0
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0)
- openai: 6.2.0(ws@8.19.0)(zod@4.3.6)
+ openai: 6.2.0(ws@8.20.0)(zod@4.3.6)
+ ws: 8.20.0
layout-base@1.0.2: {}
@@ -19862,7 +19623,7 @@ snapshots:
dependencies:
p-locate: 5.0.0
- lodash-es@4.17.23: {}
+ lodash-es@4.18.1: {}
lodash.debounce@4.0.8: {}
@@ -19890,7 +19651,7 @@ snapshots:
lodash.throttle@4.1.1: {}
- lodash@4.17.23: {}
+ lodash@4.18.1: {}
log-symbols@4.1.0:
dependencies:
@@ -19963,7 +19724,7 @@ snapshots:
mammoth@1.12.0:
dependencies:
- '@xmldom/xmldom': 0.8.10
+ '@xmldom/xmldom': 0.8.12
argparse: 1.0.10
base64-js: 1.5.1
bluebird: 3.4.7
@@ -20031,7 +19792,7 @@ snapshots:
dompurify: 3.3.3
katex: 0.16.40
khroma: 2.1.0
- lodash-es: 4.17.23
+ lodash-es: 4.18.1
marked: 16.4.2
roughjs: 4.6.6
stylis: 4.3.6
@@ -20181,7 +19942,7 @@ snapshots:
node-emoji@1.11.0:
dependencies:
- lodash: 4.17.23
+ lodash: 4.18.1
node-fetch@2.7.0:
dependencies:
@@ -20199,7 +19960,7 @@ snapshots:
node-releases@2.0.27: {}
- nodemailer@8.0.4: {}
+ nodemailer@8.0.5: {}
normalize-path@3.0.0: {}
@@ -20222,7 +19983,7 @@ snapshots:
'@yarnpkg/lockfile': 1.1.0
'@yarnpkg/parsers': 3.0.2
'@zkochan/js-yaml': 0.0.7
- axios: 1.13.6
+ axios: 1.15.0
cli-cursor: 3.1.0
cli-spinners: 2.6.1
cliui: 8.0.1
@@ -20354,9 +20115,9 @@ snapshots:
is-docker: 2.2.1
is-wsl: 2.2.0
- openai@6.2.0(ws@8.19.0)(zod@4.3.6):
+ openai@6.2.0(ws@8.20.0)(zod@4.3.6):
optionalDependencies:
- ws: 8.19.0
+ ws: 8.20.0
zod: 4.3.6
optional: true
@@ -20721,6 +20482,8 @@ snapshots:
path-data-parser: 0.1.0
points-on-curve: 0.2.0
+ porter2@1.1.0: {}
+
possible-typed-array-names@1.0.0: {}
postcss-js@4.0.1(postcss@8.5.8):
@@ -20796,7 +20559,7 @@ snapshots:
postmark@4.0.7:
dependencies:
- axios: 1.13.6
+ axios: 1.15.0
transitivePeerDependencies:
- debug
@@ -20960,7 +20723,7 @@ snapshots:
forwarded: 0.2.0
ipaddr.js: 1.9.1
- proxy-from-env@1.1.0: {}
+ proxy-from-env@2.1.0: {}
prr@1.0.1:
optional: true
@@ -21588,8 +21351,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
- serialize-javascript@7.0.3: {}
-
serve-static@2.2.1:
dependencies:
encodeurl: 2.0.0
@@ -21671,8 +21432,6 @@ snapshots:
signal-exit@4.1.0: {}
- simple-wcswidth@1.1.2: {}
-
sisteransi@1.0.5: {}
slash@3.0.0: {}
@@ -21682,7 +21441,7 @@ snapshots:
socket.io-adapter@2.5.4:
dependencies:
debug: 4.3.7
- ws: 8.19.0
+ ws: 8.20.0
transitivePeerDependencies:
- bufferutil
- supports-color
@@ -21942,8 +21701,6 @@ snapshots:
tailwindcss@4.1.18: {}
- tapable@2.2.1: {}
-
tapable@2.3.0: {}
tar-stream@2.2.0:
@@ -21954,21 +21711,20 @@ snapshots:
inherits: 2.0.4
readable-stream: 3.6.2
- terser-webpack-plugin@5.3.16(@swc/core@1.5.25(@swc/helpers@0.5.5))(webpack@5.104.1(@swc/core@1.5.25(@swc/helpers@0.5.5))):
+ terser-webpack-plugin@5.4.0(@swc/core@1.5.25(@swc/helpers@0.5.5))(webpack@5.106.0(@swc/core@1.5.25(@swc/helpers@0.5.5))):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
jest-worker: 27.5.1
schema-utils: 4.3.3
- serialize-javascript: 7.0.3
terser: 5.39.0
- webpack: 5.104.1(@swc/core@1.5.25(@swc/helpers@0.5.5))
+ webpack: 5.106.0(@swc/core@1.5.25(@swc/helpers@0.5.5))
optionalDependencies:
'@swc/core': 1.5.25(@swc/helpers@0.5.5)
terser@5.39.0:
dependencies:
'@jridgewell/source-map': 0.3.6
- acorn: 8.15.0
+ acorn: 8.16.0
commander: 2.20.3
source-map-support: 0.5.21
@@ -22051,7 +21807,7 @@ snapshots:
dependencies:
bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0
- handlebars: 4.7.8
+ handlebars: 4.7.9
jest: 30.3.0(@types/node@25.5.2)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.5.25(@swc/helpers@0.5.5))(@types/node@25.5.2)(typescript@5.9.3))
json5: 2.2.3
lodash.memoize: 4.1.2
@@ -22067,7 +21823,7 @@ snapshots:
babel-jest: 30.3.0(@babel/core@7.28.5)
jest-util: 30.3.0
- ts-loader@9.5.4(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.5.25(@swc/helpers@0.5.5))):
+ ts-loader@9.5.7(typescript@5.9.3)(webpack@5.106.0(@swc/core@1.5.25(@swc/helpers@0.5.5))):
dependencies:
chalk: 4.1.2
enhanced-resolve: 5.19.0
@@ -22075,7 +21831,7 @@ snapshots:
semver: 7.7.4
source-map: 0.7.4
typescript: 5.9.3
- webpack: 5.104.1(@swc/core@1.5.25(@swc/helpers@0.5.5))
+ webpack: 5.106.0(@swc/core@1.5.25(@swc/helpers@0.5.5))
ts-node@10.9.2(@swc/core@1.5.25(@swc/helpers@0.5.5))(@types/node@25.5.2)(typescript@5.9.3):
dependencies:
@@ -22100,8 +21856,8 @@ snapshots:
tsconfig-paths-webpack-plugin@4.2.0:
dependencies:
chalk: 4.1.2
- enhanced-resolve: 5.17.1
- tapable: 2.2.1
+ enhanced-resolve: 5.19.0
+ tapable: 2.3.0
tsconfig-paths: 4.2.0
tsconfig-paths@4.2.0:
@@ -22224,7 +21980,7 @@ snapshots:
typesense@3.0.5(@babel/runtime@7.29.2):
dependencies:
'@babel/runtime': 7.29.2
- axios: 1.13.5
+ axios: 1.15.0
loglevel: 1.9.2
tslib: 2.8.1
transitivePeerDependencies:
@@ -22382,7 +22138,7 @@ snapshots:
vary@1.1.2: {}
- vite@8.0.3(@emnapi/core@1.8.1)(@emnapi/runtime@1.5.0)(@types/node@22.19.1)(esbuild@0.27.7)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.8))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3):
+ vite@8.0.5(@emnapi/core@1.8.1)(@emnapi/runtime@1.5.0)(@types/node@22.19.1)(esbuild@0.27.7)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.8))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
@@ -22455,9 +22211,9 @@ snapshots:
webpack-node-externals@3.0.0: {}
- webpack-sources@3.3.3: {}
+ webpack-sources@3.3.4: {}
- webpack@5.104.1(@swc/core@1.5.25(@swc/helpers@0.5.5)):
+ webpack@5.106.0(@swc/core@1.5.25(@swc/helpers@0.5.5)):
dependencies:
'@types/eslint-scope': 3.7.7
'@types/estree': 1.0.8
@@ -22465,11 +22221,11 @@ snapshots:
'@webassemblyjs/ast': 1.14.1
'@webassemblyjs/wasm-edit': 1.14.1
'@webassemblyjs/wasm-parser': 1.14.1
- acorn: 8.15.0
- acorn-import-phases: 1.0.4(acorn@8.15.0)
+ acorn: 8.16.0
+ acorn-import-phases: 1.0.4(acorn@8.16.0)
browserslist: 4.28.1
chrome-trace-event: 1.0.3
- enhanced-resolve: 5.19.0
+ enhanced-resolve: 5.20.1
es-module-lexer: 2.0.0
eslint-scope: 5.1.1
events: 3.3.0
@@ -22481,9 +22237,9 @@ snapshots:
neo-async: 2.6.2
schema-utils: 4.3.3
tapable: 2.3.0
- terser-webpack-plugin: 5.3.16(@swc/core@1.5.25(@swc/helpers@0.5.5))(webpack@5.104.1(@swc/core@1.5.25(@swc/helpers@0.5.5)))
+ terser-webpack-plugin: 5.4.0(@swc/core@1.5.25(@swc/helpers@0.5.5))(webpack@5.106.0(@swc/core@1.5.25(@swc/helpers@0.5.5)))
watchpack: 2.5.1
- webpack-sources: 3.3.3
+ webpack-sources: 3.3.4
transitivePeerDependencies:
- '@swc/core'
- esbuild
@@ -22617,17 +22373,17 @@ snapshots:
imurmurhash: 0.1.4
signal-exit: 4.1.0
- ws@8.19.0: {}
+ ws@8.20.0: {}
xml-crypto@6.1.2:
dependencies:
'@xmldom/is-dom-node': 1.0.1
- '@xmldom/xmldom': 0.8.10
+ '@xmldom/xmldom': 0.8.12
xpath: 0.0.33
xml-encryption@3.1.0:
dependencies:
- '@xmldom/xmldom': 0.8.10
+ '@xmldom/xmldom': 0.8.12
escape-html: 1.0.3
xpath: 0.0.32
@@ -22732,8 +22488,6 @@ snapshots:
yocto-queue@1.2.1: {}
- yoctocolors-cjs@2.1.2: {}
-
yoctocolors-cjs@2.1.3: {}
yoctocolors@2.1.2: {}