Compare commits

...

12 Commits

Author SHA1 Message Date
e96cf0ed46 notifications module - POC 2025-07-10 15:45:11 -07:00
29388636bf feat: find and replace in editor (#689)
* feat: page find and replace

* * Refactor search and replace directory

* bugfix scroll

* Fix search and replace functionality for macOS and improve UX

- Fixed cmd+f shortcut to work on macOS (using 'Mod' key instead of 'Control')
- Added search functionality to title editor
- Fixed "Not found" message showing when search term is empty
- Fixed tooltip error when clicking replace button
- Changed replace button from icon to text for consistency
- Reduced width of search input fields for better UI
- Fixed result index after replace operation to prevent out-of-bounds error
- Added missing translation strings for search and replace dialog
- Updated tooltip to show platform-specific shortcuts (⌘F on Mac, Ctrl-F on others)

* Hide replace functionality for users with view-only permissions

- Added editable prop to SearchAndReplaceDialog component
- Pass editable state from PageEditor to SearchAndReplaceDialog
- Conditionally render replace button based on edit permissions
- Hide replace input section for view-only users
- Disable Alt+R shortcut when user lacks edit permissions

* Fix search dialog not closing properly when navigating away

- Clear all state (search text, replace text) when closing dialog
- Reset replace button visibility state on close
- Clear editor search term to remove highlights
- Ensure dialog closes properly when route changes

* fix: preserve text marks (comments, etc.) when replacing text in search and replace

- Collect all marks that span the text being replaced using nodesBetween
- Apply collected marks to the replacement text to maintain formatting
- Fixes issue where comment marks were being removed during text replacement

* ignore type error

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2025-07-10 04:40:07 +01:00
f80004817c sync 2025-07-08 16:05:34 -07:00
ac79a185de fix ctrl-a for codeblocks (#1336) 2025-07-08 22:13:21 +01:00
27a9c0ebe4 sync 2025-07-07 14:55:09 -07:00
81ffa6f459 sync 2025-07-03 04:12:24 -07:00
5364702b69 fix: comments block on edge and older browser (#1310)
* fix: overflow on edge and older browser
2025-07-01 05:14:08 +01:00
232cea8cc9 sync 2025-06-27 03:20:01 -07:00
b9643d3584 sync 2025-06-27 03:07:51 -07:00
9f144d35fb posthog integration (cloud) (#1304) 2025-06-27 10:58:36 +01:00
e44c170873 fix editor flickers on collab reconnection (#1295)
* fix editor flickers on reconnection

* cleanup

* adjust copy
2025-06-27 10:58:18 +01:00
1be39d4353 sync 2025-06-27 02:22:11 -07:00
60 changed files with 5007 additions and 123 deletions

View File

@ -41,6 +41,7 @@
"lowlight": "^3.3.0",
"mermaid": "^11.6.0",
"mitt": "^3.0.1",
"posthog-js": "^1.255.1",
"react": "^18.3.1",
"react-arborist": "3.4.0",
"react-clear-modal": "^2.0.15",

View File

@ -389,5 +389,15 @@
"Failed to share page": "Failed to share page",
"Copy page": "Copy page",
"Copy page to a different space.": "Copy page to a different space.",
"Page copied successfully": "Page copied successfully"
"Page copied successfully": "Page copied successfully",
"Find": "Find",
"Not found": "Not found",
"Previous Match (Shift+Enter)": "Previous Match (Shift+Enter)",
"Next match (Enter)": "Next match (Enter)",
"Match case (Alt+C)": "Match case (Alt+C)",
"Replace": "Replace",
"Close (Escape)": "Close (Escape)",
"Replace (Enter)": "Replace (Enter)",
"Replace all (Ctrl+Alt+Enter)": "Replace all (Ctrl+Alt+Enter)",
"Replace all": "Replace all"
}

View File

@ -1,6 +1,8 @@
import { UserProvider } from "@/features/user/user-provider.tsx";
import { Outlet } from "react-router-dom";
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
import { PosthogUser } from "@/ee/components/posthog-user.tsx";
import { isCloud } from "@/lib/config.ts";
export default function Layout() {
return (
@ -8,6 +10,7 @@ export default function Layout() {
<GlobalAppShell>
<Outlet />
</GlobalAppShell>
{isCloud() && <PosthogUser />}
</UserProvider>
);
}

View File

@ -30,12 +30,12 @@ export default function BillingDetails() {
>
Plan
</Text>
<Text fw={700} fz="lg">
{
plans.find(
(plan) => plan.productId === billing.stripeProductId,
)?.name
}
<Text fw={700} fz="lg" tt="capitalize">
{plans.find(
(plan) => plan.productId === billing.stripeProductId,
)?.name ||
billing.planName ||
"Standard"}
</Text>
</div>
</Group>
@ -154,7 +154,7 @@ export default function BillingDetails() {
Current Tier
</Text>
<Text fw={700} fz="lg">
For up to {billing.tieredUpTo} users
For {billing.tieredUpTo} users
</Text>
{/*billing.tieredFlatAmount && (
<Text c="dimmed" fz="sm">

View File

@ -155,7 +155,7 @@ export default function BillingPlans() {
</Text>
)}
<Text size="md" fw={500}>
for up to {planSelectedTier.upTo} users
For {planSelectedTier.upTo} users
</Text>
</Stack>

View File

@ -0,0 +1,41 @@
import { usePostHog } from "posthog-js/react";
import { useEffect } from "react";
import { useAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
export function PosthogUser() {
const posthog = usePostHog();
const [currentUser] = useAtom(currentUserAtom);
useEffect(() => {
if (currentUser) {
const user = currentUser?.user;
const workspace = currentUser?.workspace;
if (!user || !workspace) return;
posthog?.identify(user.id, {
name: user.name,
email: user.email,
workspaceId: user.workspaceId,
workspaceHostname: workspace.hostname,
lastActiveAt: new Date().toISOString(),
createdAt: user.createdAt,
source: "docmost-app",
});
posthog?.group("workspace", workspace.id, {
name: workspace.name,
hostname: workspace.hostname,
plan: workspace?.plan,
status: workspace.status,
isOnTrial: !!workspace.trialEndAt,
hasStripeCustomerId: !!workspace.stripeCustomerId,
memberCount: workspace.memberCount,
lastActiveAt: new Date().toISOString(),
createdAt: workspace.createdAt,
source: "docmost-app",
});
}
}, [posthog, currentUser]);
return null;
}

View File

@ -12,6 +12,12 @@
padding: 8px;
background: var(--mantine-color-gray-light);
cursor: pointer;
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
-ms-word-break: break-word;
max-width: 100%;
box-sizing: border-box;
}
.commentEditor {

View File

@ -0,0 +1,9 @@
import { atom } from "jotai";
type SearchAndReplaceAtomType = {
isOpen: boolean;
};
export const searchAndReplaceStateAtom = atom<SearchAndReplaceAtomType>({
isOpen: false,
});

View File

@ -0,0 +1,312 @@
import {
ActionIcon,
Button,
Dialog,
Flex,
Input,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import {
IconArrowNarrowDown,
IconArrowNarrowUp,
IconLetterCase,
IconReplace,
IconSearch,
IconX,
} from "@tabler/icons-react";
import { useEditor } from "@tiptap/react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { searchAndReplaceStateAtom } from "@/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts";
import { useAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { getHotkeyHandler, useToggle } from "@mantine/hooks";
import { useLocation } from "react-router-dom";
import classes from "./search-replace.module.css";
interface PageFindDialogDialogProps {
editor: ReturnType<typeof useEditor>;
editable?: boolean;
}
function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialogProps) {
const { t } = useTranslation();
const [searchText, setSearchText] = useState("");
const [replaceText, setReplaceText] = useState("");
const [pageFindState, setPageFindState] = useAtom(searchAndReplaceStateAtom);
const inputRef = useRef(null);
const [replaceButton, replaceButtonToggle] = useToggle([
{ isReplaceShow: false, color: "gray" },
{ isReplaceShow: true, color: "blue" },
]);
const [caseSensitive, caseSensitiveToggle] = useToggle([
{ isCaseSensitive: false, color: "gray" },
{ isCaseSensitive: true, color: "blue" },
]);
const searchInputEvent = (event: React.ChangeEvent<HTMLInputElement>) => {
setSearchText(event.target.value);
};
const replaceInputEvent = (event: React.ChangeEvent<HTMLInputElement>) => {
setReplaceText(event.target.value);
};
const closeDialog = () => {
setSearchText("");
setReplaceText("");
setPageFindState({ isOpen: false });
// Reset replace button state when closing
if (replaceButton.isReplaceShow) {
replaceButtonToggle();
}
// Clear search term in editor
if (editor) {
editor.commands.setSearchTerm("");
}
};
const goToSelection = () => {
if (!editor) return;
const { results, resultIndex } = editor.storage.searchAndReplace;
const position: Range = results[resultIndex];
if (!position) return;
// @ts-ignore
editor.commands.setTextSelection(position);
const element = document.querySelector(".search-result-current");
if (element)
element.scrollIntoView({ behavior: "smooth", block: "center" });
editor.commands.setTextSelection(0);
};
const next = () => {
editor.commands.nextSearchResult();
goToSelection();
};
const previous = () => {
editor.commands.previousSearchResult();
goToSelection();
};
const replace = () => {
editor.commands.setReplaceTerm(replaceText);
editor.commands.replace();
goToSelection();
};
const replaceAll = () => {
editor.commands.setReplaceTerm(replaceText);
editor.commands.replaceAll();
};
useEffect(() => {
editor.commands.setSearchTerm(searchText);
editor.commands.resetIndex();
editor.commands.selectCurrentItem();
}, [searchText]);
const handleOpenEvent = (e) => {
setPageFindState({ isOpen: true });
const selectedText = editor.state.doc.textBetween(
editor.state.selection.from,
editor.state.selection.to,
);
if (selectedText !== "") {
setSearchText(selectedText);
}
inputRef.current?.focus();
inputRef.current?.select();
};
const handleCloseEvent = (e) => {
closeDialog();
};
useEffect(() => {
!pageFindState.isOpen && closeDialog();
document.addEventListener("openFindDialogFromEditor", handleOpenEvent);
document.addEventListener("closeFindDialogFromEditor", handleCloseEvent);
return () => {
document.removeEventListener("openFindDialogFromEditor", handleOpenEvent);
document.removeEventListener(
"closeFindDialogFromEditor",
handleCloseEvent,
);
};
}, [pageFindState.isOpen]);
useEffect(() => {
editor.commands.setCaseSensitive(caseSensitive.isCaseSensitive);
editor.commands.resetIndex();
goToSelection();
}, [caseSensitive]);
const resultsCount = useMemo(
() =>
searchText.trim() === ""
? ""
: editor?.storage?.searchAndReplace?.results.length > 0
? editor?.storage?.searchAndReplace?.resultIndex +
1 +
"/" +
editor?.storage?.searchAndReplace?.results.length
: t("Not found"),
[
searchText,
editor?.storage?.searchAndReplace?.resultIndex,
editor?.storage?.searchAndReplace?.results.length,
],
);
const location = useLocation();
useEffect(() => {
closeDialog();
}, [location]);
return (
<Dialog
className={classes.findDialog}
opened={pageFindState.isOpen}
size="lg"
radius="md"
w={"auto"}
position={{ top: 90, right: 50 }}
withBorder
transitionProps={{ transition: "slide-down" }}
>
<Stack gap="xs">
<Flex align="center" gap="xs">
<Input
ref={inputRef}
placeholder={t("Find")}
leftSection={<IconSearch size={16} />}
rightSection={
<Text size="xs" ta="right">
{resultsCount}
</Text>
}
rightSectionWidth="70"
rightSectionPointerEvents="all"
size="xs"
w={220}
onChange={searchInputEvent}
value={searchText}
autoFocus
onKeyDown={getHotkeyHandler([
["Enter", next],
["shift+Enter", previous],
["alt+C", caseSensitiveToggle],
//@ts-ignore
...(editable ? [["alt+R", replaceButtonToggle]] : []),
])}
/>
<ActionIcon.Group>
<Tooltip label={t("Previous match (Shift+Enter)")}>
<ActionIcon variant="subtle" color="gray" onClick={previous}>
<IconArrowNarrowUp
style={{ width: "70%", height: "70%" }}
stroke={1.5}
/>
</ActionIcon>
</Tooltip>
<Tooltip label={t("Next match (Enter)")}>
<ActionIcon variant="subtle" color="gray" onClick={next}>
<IconArrowNarrowDown
style={{ width: "70%", height: "70%" }}
stroke={1.5}
/>
</ActionIcon>
</Tooltip>
<Tooltip label={t("Match case (Alt+C)")}>
<ActionIcon
variant="subtle"
color={caseSensitive.color}
onClick={() => caseSensitiveToggle()}
>
<IconLetterCase
style={{ width: "70%", height: "70%" }}
stroke={1.5}
/>
</ActionIcon>
</Tooltip>
{editable && (
<Tooltip label={t("Replace")}>
<ActionIcon
variant="subtle"
color={replaceButton.color}
onClick={() => replaceButtonToggle()}
>
<IconReplace
style={{ width: "70%", height: "70%" }}
stroke={1.5}
/>
</ActionIcon>
</Tooltip>
)}
<Tooltip label={t("Close (Escape)")}>
<ActionIcon variant="subtle" color="gray" onClick={closeDialog}>
<IconX style={{ width: "70%", height: "70%" }} stroke={1.5} />
</ActionIcon>
</Tooltip>
</ActionIcon.Group>
</Flex>
{replaceButton.isReplaceShow && editable && (
<Flex align="center" gap="xs">
<Input
placeholder={t("Replace")}
leftSection={<IconReplace size={16} />}
rightSection={<div></div>}
rightSectionPointerEvents="all"
size="xs"
w={180}
autoFocus
onChange={replaceInputEvent}
value={replaceText}
onKeyDown={getHotkeyHandler([
["Enter", replace],
["ctrl+alt+Enter", replaceAll],
])}
/>
<ActionIcon.Group>
<Tooltip label={t("Replace (Enter)")}>
<Button
size="xs"
variant="subtle"
color="gray"
onClick={replace}
>
{t("Replace")}
</Button>
</Tooltip>
<Tooltip label={t("Replace all (Ctrl+Alt+Enter)")}>
<Button
size="xs"
variant="subtle"
color="gray"
onClick={replaceAll}
>
{t("Replace all")}
</Button>
</Tooltip>
</ActionIcon.Group>
</Flex>
)}
</Stack>
</Dialog>
);
}
export default SearchAndReplaceDialog;

View File

@ -0,0 +1,10 @@
.findDialog{
@media print {
display: none;
}
}
.findDialog div[data-position="right"].mantine-Input-section {
justify-content: right;
padding-right: 8px;
}

View File

@ -36,6 +36,7 @@ import {
Drawio,
Excalidraw,
Embed,
SearchAndReplace,
Mention,
} from "@docmost/editor-ext";
import {
@ -217,6 +218,22 @@ export const mainExtensions = [
CharacterCount.configure({
wordCounter: (text) => countWords(text),
}),
SearchAndReplace.extend({
addKeyboardShortcuts() {
return {
'Mod-f': () => {
const event = new CustomEvent("openFindDialogFromEditor", {});
document.dispatchEvent(event);
return true;
},
'Escape': () => {
const event = new CustomEvent("closeFindDialogFromEditor", {});
document.dispatchEvent(event);
return true;
},
}
},
}).configure(),
] as any;
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];

View File

@ -1,7 +1,6 @@
import "@/features/editor/styles/index.css";
import React, {
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
@ -45,6 +44,7 @@ import LinkMenu from "@/features/editor/components/link/link-menu.tsx";
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
import DrawioMenu from "./components/drawio/drawio-menu";
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
import { useIdle } from "@/hooks/use-idle.ts";
import { queryClient } from "@/main.tsx";
@ -72,7 +72,11 @@ export default function PageEditor({
const [, setAsideState] = useAtom(asideStateAtom);
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const ydoc = useMemo(() => new Y.Doc(), [pageId]);
const ydocRef = useRef<Y.Doc | null>(null);
if (!ydocRef.current) {
ydocRef.current = new Y.Doc();
}
const ydoc = ydocRef.current;
const [isLocalSynced, setLocalSynced] = useState(false);
const [isRemoteSynced, setRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
@ -89,66 +93,100 @@ export default function PageEditor({
const userPageEditMode =
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const localProvider = useMemo(() => {
const provider = new IndexeddbPersistence(documentName, ydoc);
// Providers only created once per pageId
const providersRef = useRef<{
local: IndexeddbPersistence;
remote: HocuspocusProvider;
} | null>(null);
const [providersReady, setProvidersReady] = useState(false);
provider.on("synced", () => {
setLocalSynced(true);
});
const localProvider = providersRef.current?.local;
const remoteProvider = providersRef.current?.remote;
return provider;
}, [pageId, ydoc]);
// Track when collaborative provider is ready and synced
const [collabReady, setCollabReady] = useState(false);
useEffect(() => {
if (
remoteProvider?.status === WebSocketStatus.Connected &&
isLocalSynced &&
isRemoteSynced
) {
setCollabReady(true);
}
}, [remoteProvider?.status, isLocalSynced, isRemoteSynced]);
const remoteProvider = useMemo(() => {
const provider = new HocuspocusProvider({
name: documentName,
url: collaborationURL,
document: ydoc,
token: collabQuery?.token,
connect: false,
preserveConnection: false,
onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => {
const payload = jwtDecode(collabQuery?.token);
const now = Date.now().valueOf() / 1000;
const isTokenExpired = now >= payload.exp;
if (isTokenExpired) {
refetchCollabToken();
}
},
onStatus: (status) => {
if (status.status === "connected") {
setYjsConnectionStatus(status.status);
}
},
});
provider.on("synced", () => {
setRemoteSynced(true);
});
provider.on("disconnect", () => {
setYjsConnectionStatus(WebSocketStatus.Disconnected);
});
return provider;
}, [ydoc, pageId, collabQuery?.token]);
useLayoutEffect(() => {
remoteProvider.connect();
useEffect(() => {
if (!providersRef.current) {
const local = new IndexeddbPersistence(documentName, ydoc);
local.on("synced", () => setLocalSynced(true));
const remote = new HocuspocusProvider({
name: documentName,
url: collaborationURL,
document: ydoc,
token: collabQuery?.token,
connect: true,
preserveConnection: false,
onAuthenticationFailed: (auth: onAuthenticationFailedParameters) => {
const payload = jwtDecode(collabQuery?.token);
const now = Date.now().valueOf() / 1000;
const isTokenExpired = now >= payload.exp;
if (isTokenExpired) {
refetchCollabToken();
}
},
onStatus: (status) => {
if (status.status === "connected") {
setYjsConnectionStatus(status.status);
}
},
});
remote.on("synced", () => setRemoteSynced(true));
remote.on("disconnect", () => {
setYjsConnectionStatus(WebSocketStatus.Disconnected);
});
providersRef.current = { local, remote };
setProvidersReady(true);
} else {
setProvidersReady(true);
}
// Only destroy on final unmount
return () => {
setRemoteSynced(false);
setLocalSynced(false);
remoteProvider.destroy();
localProvider.destroy();
providersRef.current?.remote.destroy();
providersRef.current?.local.destroy();
providersRef.current = null;
};
}, [remoteProvider, localProvider]);
}, [pageId]);
// Only connect/disconnect on tab/idle, not destroy
useEffect(() => {
if (!providersReady || !providersRef.current) return;
const remoteProvider = providersRef.current.remote;
if (
isIdle &&
documentState === "hidden" &&
remoteProvider.status === WebSocketStatus.Connected
) {
remoteProvider.disconnect();
setIsCollabReady(false);
return;
}
if (
documentState === "visible" &&
remoteProvider.status === WebSocketStatus.Disconnected
) {
resetIdle();
remoteProvider.connect();
setTimeout(() => setIsCollabReady(true), 500);
}
}, [isIdle, documentState, providersReady, resetIdle]);
const extensions = useMemo(() => {
if (!remoteProvider || !currentUser?.user) return mainExtensions;
return [
...mainExtensions,
...collabExtensions(remoteProvider, currentUser?.user),
];
}, [ydoc, pageId, remoteProvider, currentUser?.user]);
}, [remoteProvider, currentUser?.user]);
const editor = useEditor(
{
@ -202,7 +240,7 @@ export default function PageEditor({
debouncedUpdateContent(editorJson);
},
},
[pageId, editable, remoteProvider?.status],
[pageId, editable, remoteProvider],
);
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
@ -255,29 +293,6 @@ export default function PageEditor({
}
}, [remoteProvider?.status]);
useEffect(() => {
if (
isIdle &&
documentState === "hidden" &&
remoteProvider?.status === WebSocketStatus.Connected
) {
remoteProvider.disconnect();
setIsCollabReady(false);
return;
}
if (
documentState === "visible" &&
remoteProvider?.status === WebSocketStatus.Disconnected
) {
resetIdle();
remoteProvider.connect();
setTimeout(() => {
setIsCollabReady(true);
}, 600);
}
}, [isIdle, documentState, remoteProvider]);
const isSynced = isLocalSynced && isRemoteSynced;
useEffect(() => {
@ -294,20 +309,49 @@ export default function PageEditor({
}, [isRemoteSynced, isLocalSynced, remoteProvider?.status]);
useEffect(() => {
// honor user default page edit mode preference
if (userPageEditMode && editor && editable && isSynced) {
if (userPageEditMode === PageEditMode.Edit) {
editor.setEditable(true);
} else if (userPageEditMode === PageEditMode.Read) {
// Only honor user default page edit mode preference and permissions
if (editor) {
if (userPageEditMode && editable) {
if (userPageEditMode === PageEditMode.Edit) {
editor.setEditable(true);
} else if (userPageEditMode === PageEditMode.Read) {
editor.setEditable(false);
}
} else {
editor.setEditable(false);
}
}
}, [userPageEditMode, editor, editable, isSynced]);
}, [userPageEditMode, editor, editable]);
return isCollabReady ? (
<div>
const hasConnectedOnceRef = useRef(false);
const [showStatic, setShowStatic] = useState(true);
useEffect(() => {
if (
!hasConnectedOnceRef.current &&
remoteProvider?.status === WebSocketStatus.Connected
) {
hasConnectedOnceRef.current = true;
setShowStatic(false);
}
}, [remoteProvider?.status]);
if (showStatic) {
return (
<EditorProvider
editable={false}
immediatelyRender={true}
extensions={mainExtensions}
content={content}
/>
);
}
return (
<div style={{ position: "relative" }}>
<div ref={menuContainerRef}>
<EditorContent editor={editor} />
<SearchAndReplaceDialog editor={editor} editable={editable} />
{editor && editor.isEditable && (
<div>
@ -322,21 +366,12 @@ export default function PageEditor({
<LinkMenu editor={editor} appendTo={menuContainerRef} />
</div>
)}
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
</div>
<div
onClick={() => editor.commands.focus("end")}
style={{ paddingBottom: "20vh" }}
></div>
</div>
) : (
<EditorProvider
editable={false}
immediatelyRender={true}
extensions={mainExtensions}
content={content}
></EditorProvider>
);
}

View File

@ -71,4 +71,12 @@
[data-type="details"][open] > [data-type="detailsButton"] .ProseMirror-icon{
transform: rotateZ(90deg);
}
}
[data-type="details"]:has(.search-result) > [data-type="detailsContainer"] > [data-type="detailsContent"]{
display: block;
}
[data-type="details"]:has(.search-result) > [data-type="detailsButton"] .ProseMirror-icon{
transform: rotateZ(90deg);
}
}

View File

@ -0,0 +1,9 @@
.search-result{
background: #ffff65;
color: #212529;
}
.search-result-current{
background: #ffc266 !important;
color: #212529;
}

View File

@ -9,5 +9,5 @@
@import "./media.css";
@import "./code.css";
@import "./print.css";
@import "./find.css";
@import "./mention.css";

View File

@ -10,8 +10,11 @@ import {
pageEditorAtom,
titleEditorAtom,
} from "@/features/editor/atoms/editor-atoms";
import { updatePageData, useUpdateTitlePageMutation } from "@/features/page/queries/page-query";
import { useDebouncedCallback } from "@mantine/hooks";
import {
updatePageData,
useUpdateTitlePageMutation,
} from "@/features/page/queries/page-query";
import { useDebouncedCallback, getHotkeyHandler } from "@mantine/hooks";
import { useAtom } from "jotai";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
import { History } from "@tiptap/extension-history";
@ -40,7 +43,8 @@ export function TitleEditor({
editable,
}: TitleEditorProps) {
const { t } = useTranslation();
const { mutateAsync: updateTitlePageMutationAsync } = useUpdateTitlePageMutation();
const { mutateAsync: updateTitlePageMutationAsync } =
useUpdateTitlePageMutation();
const pageEditor = useAtomValue(pageEditorAtom);
const [, setTitleEditor] = useAtom(titleEditorAtom);
const emit = useQueryEmit();
@ -108,7 +112,12 @@ export function TitleEditor({
spaceId: page.spaceId,
entity: ["pages"],
id: page.id,
payload: { title: page.title, slugId: page.slugId, parentPageId: page.parentPageId, icon: page.icon },
payload: {
title: page.title,
slugId: page.slugId,
parentPageId: page.parentPageId,
icon: page.icon,
},
};
if (page.title !== titleEditor.getText()) return;
@ -152,13 +161,19 @@ export function TitleEditor({
}
}, [userPageEditMode, titleEditor, editable]);
const openSearchDialog = () => {
const event = new CustomEvent("openFindDialogFromEditor", {});
document.dispatchEvent(event);
};
function handleTitleKeyDown(event: any) {
if (!titleEditor || !pageEditor || event.shiftKey) return;
// Prevent focus shift when IME composition is active
// Prevent focus shift when IME composition is active
// `keyCode === 229` is added to support Safari where `isComposing` may not be reliable
if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return;
if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229)
return;
const { key } = event;
const { $head } = titleEditor.state.selection;
@ -172,5 +187,16 @@ export function TitleEditor({
}
}
return <EditorContent editor={titleEditor} onKeyDown={handleTitleKeyDown} />;
return (
<EditorContent
editor={titleEditor}
onKeyDown={(event) => {
// First handle the search hotkey
getHotkeyHandler([["mod+F", openSearchDialog]])(event);
// Then handle other key events
handleTitleKeyDown(event);
}}
/>
);
}

View File

@ -9,6 +9,7 @@ import {
IconList,
IconMessage,
IconPrinter,
IconSearch,
IconTrash,
IconWifiOff,
} from "@tabler/icons-react";
@ -16,7 +17,12 @@ import React from "react";
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
import { useAtom } from "jotai";
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
import { useClipboard, useDisclosure } from "@mantine/hooks";
import {
getHotkeyHandler,
useClipboard,
useDisclosure,
useHotkeys,
} from "@mantine/hooks";
import { useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
@ -32,6 +38,7 @@ import {
pageEditorAtom,
yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
import { searchAndReplaceStateAtom } from "@/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts";
import { formattedDate, timeAgo } from "@/lib/time.ts";
import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx";
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
@ -46,6 +53,26 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
const toggleAside = useToggleAside();
const [yjsConnectionStatus] = useAtom(yjsConnectionStatusAtom);
useHotkeys(
[
[
"mod+F",
() => {
const event = new CustomEvent("openFindDialogFromEditor", {});
document.dispatchEvent(event);
},
],
[
"Escape",
() => {
const event = new CustomEvent("closeFindDialogFromEditor", {});
document.dispatchEvent(event);
},
],
],
[],
);
return (
<>
{yjsConnectionStatus === "disconnected" && (

View File

@ -12,6 +12,7 @@ export interface IWorkspace {
settings: any;
status: string;
enforceSso: boolean;
stripeCustomerId: string;
billingEmail: string;
trialEndAt: Date;
createdAt: Date;

View File

@ -83,6 +83,18 @@ export function getBillingTrialDays() {
return getConfigValue("BILLING_TRIAL_DAYS");
}
export function getPostHogHost() {
return getConfigValue("POSTHOG_HOST");
}
export function isPostHogEnabled(): boolean {
return Boolean(getPostHogHost() && getPostHogKey());
}
export function getPostHogKey() {
return getConfigValue("POSTHOG_KEY");
}
function getConfigValue(key: string, defaultValue: string = undefined): string {
const rawValue = import.meta.env.DEV
? process?.env?.[key]

View File

@ -3,7 +3,7 @@ import "@mantine/spotlight/styles.css";
import "@mantine/notifications/styles.css";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import { mantineCssResolver, theme } from '@/theme';
import { mantineCssResolver, theme } from "@/theme";
import { MantineProvider } from "@mantine/core";
import { BrowserRouter } from "react-router-dom";
import { ModalsProvider } from "@mantine/modals";
@ -11,6 +11,14 @@ import { Notifications } from "@mantine/notifications";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { HelmetProvider } from "react-helmet-async";
import "./i18n";
import { PostHogProvider } from "posthog-js/react";
import {
getPostHogHost,
getPostHogKey,
isCloud,
isPostHogEnabled,
} from "@/lib/config.ts";
import posthog from "posthog-js";
export const queryClient = new QueryClient({
defaultOptions: {
@ -23,9 +31,17 @@ export const queryClient = new QueryClient({
},
});
if (isCloud() && isPostHogEnabled) {
posthog.init(getPostHogKey(), {
api_host: getPostHogHost(),
defaults: "2025-05-24",
disable_session_recording: true,
capture_pageleave: false,
});
}
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement
document.getElementById("root") as HTMLElement,
);
root.render(
@ -35,10 +51,12 @@ root.render(
<QueryClientProvider client={queryClient}>
<Notifications position="bottom-center" limit={3} />
<HelmetProvider>
<App />
<PostHogProvider client={posthog}>
<App />
</PostHogProvider>
</HelmetProvider>
</QueryClientProvider>
</ModalsProvider>
</MantineProvider>
</BrowserRouter>
</BrowserRouter>,
);

View File

@ -14,6 +14,8 @@ export default defineConfig(({ mode }) => {
SUBDOMAIN_HOST,
COLLAB_URL,
BILLING_TRIAL_DAYS,
POSTHOG_HOST,
POSTHOG_KEY,
} = loadEnv(mode, envPath, "");
return {
@ -27,6 +29,8 @@ export default defineConfig(({ mode }) => {
SUBDOMAIN_HOST,
COLLAB_URL,
BILLING_TRIAL_DAYS,
POSTHOG_HOST,
POSTHOG_KEY,
},
APP_VERSION: JSON.stringify(process.env.npm_package_version),
},

View File

@ -16,6 +16,7 @@ import { GroupModule } from './group/group.module';
import { CaslModule } from './casl/casl.module';
import { DomainMiddleware } from '../common/middlewares/domain.middleware';
import { ShareModule } from './share/share.module';
import { NotificationModule } from './notification/notification.module';
@Module({
imports: [
@ -30,6 +31,7 @@ import { ShareModule } from './share/share.module';
GroupModule,
CaslModule,
ShareModule,
NotificationModule,
],
})
export class CoreModule implements NestModule {

View File

@ -0,0 +1,276 @@
# Notification System Integration Guide
This guide explains how to integrate the notification system into existing services.
## Quick Start
### 1. Import NotificationService
```typescript
import { NotificationService } from '@/core/notification/services/notification.service';
import { NotificationType } from '@/core/notification/types/notification.types';
```
### 2. Inject the Service
```typescript
constructor(
private readonly notificationService: NotificationService,
// ... other dependencies
) {}
```
### 3. Create Notifications
```typescript
// Example: Notify user when mentioned in a comment
await this.notificationService.createNotification({
workspaceId: workspace.id,
recipientId: mentionedUserId,
actorId: currentUser.id,
type: NotificationType.MENTION_IN_COMMENT,
entityType: 'comment',
entityId: comment.id,
context: {
pageId: page.id,
pageTitle: page.title,
commentText: comment.content.substring(0, 100),
actorName: currentUser.name,
threadRootId: comment.parentCommentId || comment.id,
},
priority: NotificationPriority.HIGH,
groupKey: `comment:${comment.id}:mentions`,
deduplicationKey: `mention:${mentionedUserId}:comment:${comment.id}`,
});
```
## Integration Examples
### CommentService Integration
```typescript
// In comment.service.ts
import { NotificationService } from '@/core/notification/services/notification.service';
import { NotificationType, NotificationPriority } from '@/core/notification/types/notification.types';
export class CommentService {
constructor(
private readonly notificationService: NotificationService,
// ... other dependencies
) {}
async createComment(dto: CreateCommentDto, user: User): Promise<Comment> {
const comment = await this.commentRepo.create(dto);
// Notify page owner about new comment
if (page.creatorId !== user.id) {
await this.notificationService.createNotification({
workspaceId: workspace.id,
recipientId: page.creatorId,
actorId: user.id,
type: NotificationType.COMMENT_ON_PAGE,
entityType: 'comment',
entityId: comment.id,
context: {
pageId: page.id,
pageTitle: page.title,
commentText: comment.content.substring(0, 100),
actorName: user.name,
},
groupKey: `page:${page.id}:comments`,
});
}
// Check for mentions and notify mentioned users
const mentionedUserIds = this.extractMentions(comment.content);
for (const mentionedUserId of mentionedUserIds) {
await this.notificationService.createNotification({
workspaceId: workspace.id,
recipientId: mentionedUserId,
actorId: user.id,
type: NotificationType.MENTION_IN_COMMENT,
entityType: 'comment',
entityId: comment.id,
context: {
pageId: page.id,
pageTitle: page.title,
commentText: comment.content.substring(0, 100),
actorName: user.name,
threadRootId: comment.parentCommentId || comment.id,
},
priority: NotificationPriority.HIGH,
deduplicationKey: `mention:${mentionedUserId}:comment:${comment.id}`,
});
}
return comment;
}
async resolveComment(commentId: string, user: User): Promise<void> {
const comment = await this.commentRepo.findById(commentId);
// Notify comment creator that their comment was resolved
if (comment.creatorId !== user.id) {
await this.notificationService.createNotification({
workspaceId: workspace.id,
recipientId: comment.creatorId,
actorId: user.id,
type: NotificationType.COMMENT_RESOLVED,
entityType: 'comment',
entityId: comment.id,
context: {
pageId: page.id,
pageTitle: page.title,
resolverName: user.name,
},
});
}
}
}
```
### PageService Integration
```typescript
// In page.service.ts
async exportPage(pageId: string, format: string, user: User): Promise<void> {
// Start export process...
// When export is complete
await this.notificationService.createNotification({
workspaceId: workspace.id,
recipientId: user.id,
actorId: user.id, // System notification
type: NotificationType.EXPORT_COMPLETED,
entityType: 'page',
entityId: pageId,
context: {
pageTitle: page.title,
exportFormat: format,
downloadUrl: exportUrl,
expiresAt: expiryDate.toISOString(),
},
priority: NotificationPriority.LOW,
});
}
async updatePage(pageId: string, content: any, user: User): Promise<void> {
// Check for mentions in the content
const mentionedUserIds = this.extractMentionsFromContent(content);
for (const mentionedUserId of mentionedUserIds) {
await this.notificationService.createNotification({
workspaceId: workspace.id,
recipientId: mentionedUserId,
actorId: user.id,
type: NotificationType.MENTION_IN_PAGE,
entityType: 'page',
entityId: pageId,
context: {
pageTitle: page.title,
actorName: user.name,
mentionContext: this.extractMentionContext(content, mentionedUserId),
},
priority: NotificationPriority.HIGH,
deduplicationKey: `mention:${mentionedUserId}:page:${pageId}:${Date.now()}`,
});
}
}
```
### WsGateway Integration for Real-time Notifications
The notification system automatically sends real-time updates through WebSocket. The WsGateway is already injected into NotificationDeliveryService.
```typescript
// In ws.gateway.ts - Already implemented in NotificationDeliveryService
async sendNotificationToUser(userId: string, notification: any): Promise<void> {
const userSockets = await this.getUserSockets(userId);
for (const socketId of userSockets) {
this.server.to(socketId).emit('notification:new', {
id: notification.id,
type: notification.type,
entityType: notification.entityType,
entityId: notification.entityId,
context: notification.context,
createdAt: notification.createdAt,
readAt: notification.readAt,
});
}
}
```
## Notification Types
Available notification types:
- `MENTION_IN_PAGE` - User mentioned in a page
- `MENTION_IN_COMMENT` - User mentioned in a comment
- `COMMENT_ON_PAGE` - New comment on user's page
- `COMMENT_IN_THREAD` - Reply to user's comment
- `COMMENT_RESOLVED` - User's comment was resolved
- `EXPORT_COMPLETED` - Export job finished
## Best Practices
1. **Use Deduplication Keys**: Prevent duplicate notifications for the same event
```typescript
deduplicationKey: `mention:${userId}:comment:${commentId}`
```
2. **Set Appropriate Priority**:
- HIGH: Mentions, direct replies
- NORMAL: Comments on owned content
- LOW: System notifications, exports
3. **Group Related Notifications**: Use groupKey for notifications that should be batched
```typescript
groupKey: `page:${pageId}:comments`
```
4. **Include Relevant Context**: Provide enough information for email templates
```typescript
context: {
pageId: page.id,
pageTitle: page.title,
actorName: user.name,
// ... other relevant data
}
```
5. **Check User Preferences**: The notification service automatically checks user preferences, but you can pre-check if needed:
```typescript
const preferences = await notificationPreferenceService.getUserPreferences(userId, workspaceId);
if (preferences.emailEnabled) {
// Create notification
}
```
## Testing Notifications
Use the test endpoint to send test notifications:
```bash
curl -X POST http://localhost:3000/api/notifications/test \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"type": "MENTION_IN_PAGE",
"recipientId": "USER_ID"
}'
```
## Email Templates
Email templates are located in `/core/notification/templates/`. To add a new template:
1. Create a new React component in the templates directory
2. Update the email sending logic in NotificationDeliveryService
3. Test the template using the React Email preview server
## Monitoring
Monitor notification delivery through logs:
- Check for `NotificationService` logs for creation events
- Check for `NotificationDeliveryService` logs for delivery status
- Check for `NotificationBatchProcessor` logs for batch processing

View File

@ -0,0 +1,122 @@
import {
Controller,
Get,
Post,
Put,
Body,
Param,
Query,
UseGuards,
} from '@nestjs/common';
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 { User, Workspace } from '@docmost/db/types/entity.types';
import { NotificationService } from '../services/notification.service';
import { NotificationPreferenceService } from '../services/notification-preference.service';
import { GetNotificationsDto } from '../dto/get-notifications.dto';
import { UpdateNotificationPreferencesDto } from '../dto/update-preference.dto';
import { NotificationType } from '../types/notification.types';
@Controller('notifications')
@UseGuards(JwtAuthGuard)
export class NotificationController {
constructor(
private readonly notificationService: NotificationService,
private readonly preferenceService: NotificationPreferenceService,
) {}
@Get()
async getNotifications(
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
@Query() query: GetNotificationsDto,
) {
const { grouped = true, status, limit = 20, offset = 0 } = query;
if (grouped) {
return await this.notificationService.getGroupedNotifications(
user.id,
workspace.id,
{ status, limit, offset },
);
}
return await this.notificationService.getNotifications(
user.id,
workspace.id,
{ status, limit, offset },
);
}
@Get('unread-count')
async getUnreadCount(@AuthUser() user: User) {
const count = await this.notificationService.getUnreadCount(user.id);
return { count };
}
@Post(':id/read')
async markAsRead(
@AuthUser() user: User,
@Param('id') notificationId: string,
) {
await this.notificationService.markAsRead(notificationId, user.id);
return { success: true };
}
@Post('mark-all-read')
async markAllAsRead(@AuthUser() user: User) {
await this.notificationService.markAllAsRead(user.id);
return { success: true };
}
@Get('preferences')
async getPreferences(
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
return await this.preferenceService.getUserPreferences(
user.id,
workspace.id,
);
}
@Put('preferences')
async updatePreferences(
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
@Body() dto: UpdateNotificationPreferencesDto,
) {
return await this.preferenceService.updateUserPreferences(
user.id,
workspace.id,
dto,
);
}
@Get('preferences/stats')
async getNotificationStats(
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
return await this.preferenceService.getNotificationStats(
user.id,
workspace.id,
);
}
@Post('test')
async sendTestNotification(
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
@Body() dto: { type: NotificationType },
) {
await this.notificationService.createTestNotification(
user.id,
workspace.id,
dto.type,
);
return { success: true, message: 'Test notification sent' };
}
}

View File

@ -0,0 +1,58 @@
import {
IsEnum,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import {
NotificationType,
NotificationPriority,
} from '../types/notification.types';
export class CreateNotificationDto {
@IsUUID()
@IsNotEmpty()
workspaceId: string;
@IsUUID()
@IsNotEmpty()
recipientId: string;
@IsUUID()
@IsOptional()
actorId?: string;
@IsEnum(NotificationType)
@IsNotEmpty()
type: NotificationType;
@IsString()
@IsNotEmpty()
entityType: string;
@IsUUID()
@IsNotEmpty()
entityId: string;
@IsObject()
@IsNotEmpty()
context: Record<string, any>;
@IsEnum(NotificationPriority)
@IsOptional()
priority?: NotificationPriority;
@IsString()
@IsOptional()
groupKey?: string;
@IsString()
@IsOptional()
deduplicationKey?: string;
// For scheduling notifications (quiet hours, etc.)
@IsOptional()
scheduledFor?: Date;
}

View File

@ -0,0 +1,34 @@
import {
IsEnum,
IsOptional,
IsBoolean,
IsNumber,
Min,
Max,
} from 'class-validator';
import { Transform } from 'class-transformer';
import { NotificationStatus } from '../types/notification.types';
export class GetNotificationsDto {
@IsEnum(NotificationStatus)
@IsOptional()
status?: NotificationStatus;
@IsBoolean()
@Transform(({ value }) => value === 'true' || value === true)
@IsOptional()
grouped?: boolean = true;
@IsNumber()
@Transform(({ value }) => parseInt(value, 10))
@Min(1)
@Max(100)
@IsOptional()
limit?: number = 20;
@IsNumber()
@Transform(({ value }) => parseInt(value, 10))
@Min(0)
@IsOptional()
offset?: number = 0;
}

View File

@ -0,0 +1,75 @@
import {
IsBoolean,
IsEnum,
IsNumber,
IsObject,
IsOptional,
IsString,
Max,
Min,
Matches,
IsArray,
} from 'class-validator';
import {
EmailFrequency,
NotificationTypeSettings,
} from '../types/notification.types';
export class UpdateNotificationPreferencesDto {
@IsBoolean()
@IsOptional()
emailEnabled?: boolean;
@IsBoolean()
@IsOptional()
inAppEnabled?: boolean;
@IsObject()
@IsOptional()
notificationSettings?: Record<string, NotificationTypeSettings>;
@IsNumber()
@Min(5)
@Max(60)
@IsOptional()
batchWindowMinutes?: number;
@IsNumber()
@Min(1)
@Max(100)
@IsOptional()
maxBatchSize?: number;
@IsArray()
@IsString({ each: true })
@IsOptional()
batchTypes?: string[];
@IsEnum(EmailFrequency)
@IsOptional()
emailFrequency?: EmailFrequency;
@Matches(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/)
@IsOptional()
digestTime?: string;
@IsBoolean()
@IsOptional()
quietHoursEnabled?: boolean;
@Matches(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/)
@IsOptional()
quietHoursStart?: string;
@Matches(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/)
@IsOptional()
quietHoursEnd?: string;
@IsString()
@IsOptional()
timezone?: string;
@IsBoolean()
@IsOptional()
weekendNotifications?: boolean;
}

View File

@ -0,0 +1,45 @@
import { Notification } from '@docmost/db/types/entity.types';
export class NotificationCreatedEvent {
constructor(
public readonly notification: Notification,
public readonly workspaceId: string,
) {}
}
export class NotificationReadEvent {
constructor(
public readonly notificationId: string,
public readonly userId: string,
) {}
}
export class NotificationAllReadEvent {
constructor(
public readonly userId: string,
public readonly notificationIds: string[],
) {}
}
export class NotificationBatchScheduledEvent {
constructor(
public readonly batchId: string,
public readonly scheduledFor: Date,
) {}
}
export class NotificationAggregatedEvent {
constructor(
public readonly aggregationId: string,
public readonly notificationIds: string[],
) {}
}
// Event names as constants
export const NOTIFICATION_EVENTS = {
CREATED: 'notification.created',
READ: 'notification.read',
ALL_READ: 'notification.allRead',
BATCH_SCHEDULED: 'notification.batchScheduled',
AGGREGATED: 'notification.aggregated',
} as const;

View File

@ -0,0 +1,32 @@
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { NotificationService } from './services/notification.service';
import { NotificationPreferenceService } from './services/notification-preference.service';
import { NotificationDeduplicationService } from './services/notification-deduplication.service';
import { NotificationDeliveryService } from './services/notification-delivery.service';
import { NotificationBatchingService } from './services/notification-batching.service';
import { NotificationAggregationService } from './services/notification-aggregation.service';
import { NotificationController } from './controllers/notification.controller';
import { NotificationBatchProcessor } from './queues/notification-batch.processor';
import { WsModule } from '../../ws/ws.module';
@Module({
imports: [
BullModule.registerQueue({
name: 'notification-batch',
}),
WsModule,
],
controllers: [NotificationController],
providers: [
NotificationService,
NotificationPreferenceService,
NotificationDeduplicationService,
NotificationDeliveryService,
NotificationBatchingService,
NotificationAggregationService,
NotificationBatchProcessor,
],
exports: [NotificationService, NotificationPreferenceService],
})
export class NotificationModule {}

View File

@ -0,0 +1,70 @@
import { Processor } from '@nestjs/bullmq';
import { WorkerHost } from '@nestjs/bullmq';
import { Injectable, Logger } from '@nestjs/common';
import { Job } from 'bullmq';
import { NotificationBatchingService } from '../services/notification-batching.service';
@Processor('notification-batch')
export class NotificationBatchProcessor extends WorkerHost {
private readonly logger = new Logger(NotificationBatchProcessor.name);
constructor(private readonly batchingService: NotificationBatchingService) {
super();
}
async process(job: Job<any, any, string>) {
if (job.name === 'process-batch') {
return this.processBatch(job);
} else if (job.name === 'check-pending-batches') {
return this.checkPendingBatches(job);
}
}
async processBatch(job: Job<{ batchId: string }>) {
this.logger.debug(`Processing notification batch: ${job.data.batchId}`);
try {
await this.batchingService.processBatch(job.data.batchId);
return { success: true, batchId: job.data.batchId };
} catch (error) {
this.logger.error(
`Failed to process batch ${job.data.batchId}:`,
error instanceof Error ? error.stack : String(error),
);
throw error;
}
}
async checkPendingBatches(job: Job) {
this.logger.debug('Checking for pending notification batches');
try {
const pendingBatches = await this.batchingService.getPendingBatches();
for (const batch of pendingBatches) {
// Calculate delay
const delay = Math.max(0, batch.scheduled_for.getTime() - Date.now());
// Add to queue with appropriate delay
await this.queue.add('process-batch', { batchId: batch.id }, { delay });
this.logger.debug(
`Scheduled batch ${batch.id} for processing in ${delay}ms`,
);
}
return { processedCount: pendingBatches.length };
} catch (error) {
this.logger.error(
'Failed to check pending batches:',
error instanceof Error ? error.stack : String(error),
);
throw error;
}
}
// Reference to the queue (injected by Bull)
private get queue() {
return (this as any).queue;
}
}

View File

@ -0,0 +1,259 @@
import { Injectable, Logger } from '@nestjs/common';
import { NotificationAggregationRepo } from '@docmost/db/repos/notification/notification-aggregation.repo';
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
import { Notification, NotificationAggregation } from '@docmost/db/types/entity.types';
import {
NotificationType,
AggregationType,
AggregatedNotificationMessage,
} from '../types/notification.types';
interface AggregationRule {
types: NotificationType[];
timeWindow: number;
minCount: number;
aggregationType: 'actor_based' | 'time_based' | 'count_based';
}
@Injectable()
export class NotificationAggregationService {
private readonly logger = new Logger(NotificationAggregationService.name);
private readonly aggregationRules: Map<NotificationType, AggregationRule> =
new Map([
[
NotificationType.COMMENT_ON_PAGE,
{
types: [NotificationType.COMMENT_ON_PAGE],
timeWindow: 3600000, // 1 hour
minCount: 2,
aggregationType: 'actor_based',
},
],
[
NotificationType.MENTION_IN_COMMENT,
{
types: [
NotificationType.MENTION_IN_COMMENT,
NotificationType.MENTION_IN_PAGE,
],
timeWindow: 1800000, // 30 minutes
minCount: 3,
aggregationType: 'count_based',
},
],
[
NotificationType.COMMENT_IN_THREAD,
{
types: [NotificationType.COMMENT_IN_THREAD],
timeWindow: 3600000, // 1 hour
minCount: 2,
aggregationType: 'actor_based',
},
],
]);
constructor(
private readonly aggregationRepo: NotificationAggregationRepo,
private readonly notificationRepo: NotificationRepo,
) {}
async aggregateNotifications(
recipientId: string,
type: NotificationType,
entityId: string,
timeWindow: number = 3600000, // 1 hour default
): Promise<NotificationAggregation | null> {
const aggregationKey = this.generateAggregationKey(
recipientId,
type,
entityId,
);
// Check if there's an existing aggregation within time window
const existing = await this.aggregationRepo.findByKey(aggregationKey);
if (existing && this.isWithinTimeWindow(existing.updatedAt, timeWindow)) {
return existing;
}
// Find recent notifications to aggregate
const recentNotifications = await this.notificationRepo.findRecent({
recipientId,
type,
entityId,
since: new Date(Date.now() - timeWindow),
});
const rule = this.aggregationRules.get(type);
if (!rule || recentNotifications.length < rule.minCount) {
return null;
}
// Create new aggregation
return await this.createAggregation(
aggregationKey,
recentNotifications,
type,
);
}
async updateAggregation(
aggregation: NotificationAggregation,
notification: Notification,
): Promise<void> {
await this.aggregationRepo.addNotificationToAggregation(
aggregation.aggregationKey,
notification.id,
notification.actorId || undefined,
);
this.logger.debug(
`Updated aggregation ${aggregation.id} with notification ${notification.id}`,
);
}
private async createAggregation(
key: string,
notifications: Notification[],
type: NotificationType,
): Promise<NotificationAggregation> {
const actors = [
...new Set(notifications.map((n) => n.actorId).filter(Boolean)),
];
const notificationIds = notifications.map((n) => n.id);
const summaryData = {
totalCount: notifications.length,
actorCount: actors.length,
firstActorId: actors[0],
recentActors: actors.slice(0, 3),
timeSpan: {
start: notifications[notifications.length - 1].createdAt.toISOString(),
end: notifications[0].createdAt.toISOString(),
},
};
const aggregation = await this.aggregationRepo.insertAggregation({
aggregationKey: key,
recipientId: notifications[0].recipientId,
aggregationType: this.getAggregationType(type),
entityType: notifications[0].entityType,
entityId: notifications[0].entityId,
actorIds: actors,
notificationIds: notificationIds,
summaryData: summaryData,
});
this.logger.log(
`Created aggregation ${aggregation.id} for ${notifications.length} notifications`,
);
return aggregation;
}
private generateAggregationKey(
recipientId: string,
type: NotificationType,
entityId: string,
): string {
return `${recipientId}:${type}:${entityId}`;
}
private isWithinTimeWindow(updatedAt: Date, timeWindow: number): boolean {
return Date.now() - updatedAt.getTime() < timeWindow;
}
private getAggregationType(type: NotificationType): AggregationType {
switch (type) {
case NotificationType.COMMENT_ON_PAGE:
case NotificationType.COMMENT_RESOLVED:
return AggregationType.COMMENTS_ON_PAGE;
case NotificationType.MENTION_IN_PAGE:
return AggregationType.MENTIONS_IN_PAGE;
case NotificationType.MENTION_IN_COMMENT:
return AggregationType.MENTIONS_IN_COMMENTS;
case NotificationType.COMMENT_IN_THREAD:
return AggregationType.THREAD_ACTIVITY;
default:
return AggregationType.COMMENTS_ON_PAGE;
}
}
async createAggregatedNotificationMessage(
aggregation: NotificationAggregation,
): Promise<AggregatedNotificationMessage> {
// TODO: Load actor information from user service
// For now, return a simplified version
const actors = aggregation.actorIds.slice(0, 3).map((id) => ({
id,
name: 'User', // TODO: Load actual user name
avatarUrl: undefined,
}));
const primaryActor = actors[0];
const otherActorsCount = aggregation.actorIds.length - 1;
let message: string;
let title: string;
switch (aggregation.aggregationType) {
case AggregationType.COMMENTS_ON_PAGE:
if (otherActorsCount === 0) {
title = `${primaryActor.name} commented on a page`;
message = 'View the comment';
} else if (otherActorsCount === 1) {
title = `${primaryActor.name} and 1 other commented on a page`;
message = 'View 2 comments';
} else {
title = `${primaryActor.name} and ${otherActorsCount} others commented on a page`;
message = `View ${aggregation.notificationIds.length} comments`;
}
break;
case AggregationType.MENTIONS_IN_PAGE:
case AggregationType.MENTIONS_IN_COMMENTS: {
const totalMentions = aggregation.notificationIds.length;
if (totalMentions === 1) {
title = `${primaryActor.name} mentioned you`;
message = 'View mention';
} else {
title = `You were mentioned ${totalMentions} times`;
message = `By ${primaryActor.name} and ${otherActorsCount} others`;
}
break;
}
default:
title = `${aggregation.notificationIds.length} new notifications`;
message = 'View all';
}
return {
id: aggregation.id,
title,
message,
actors,
totalCount: aggregation.notificationIds.length,
entityId: aggregation.entityId,
entityType: aggregation.entityType,
createdAt: aggregation.createdAt,
updatedAt: aggregation.updatedAt,
};
}
async cleanupOldAggregations(olderThan: Date): Promise<number> {
const deletedCount =
await this.aggregationRepo.deleteOldAggregations(olderThan);
if (deletedCount > 0) {
this.logger.log(`Cleaned up ${deletedCount} old aggregations`);
}
return deletedCount;
}
}

View File

@ -0,0 +1,215 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { NotificationRepo } from '../../../database/repos/notification/notification.repo';
import { NotificationBatchRepo } from '../../../database/repos/notification/notification-batch.repo';
import { NotificationPreferenceService } from './notification-preference.service';
import { Notification } from '@docmost/db/types/entity.types';
import { NotificationType, BatchType } from '../types/notification.types';
interface NotificationGroup {
type: NotificationType;
entityId: string;
entityType: string;
notifications: Notification[];
actors: Set<string>;
summary: string;
}
@Injectable()
export class NotificationBatchingService {
private readonly logger = new Logger(NotificationBatchingService.name);
constructor(
private readonly notificationRepo: NotificationRepo,
private readonly batchRepo: NotificationBatchRepo,
private readonly preferenceService: NotificationPreferenceService,
@InjectQueue('notification-batch') private readonly batchQueue: Queue,
) {}
async addToBatch(notification: Notification): Promise<void> {
try {
const preferences = await this.preferenceService.getUserPreferences(
notification.recipientId,
notification.workspaceId,
);
const batchKey = this.generateBatchKey(notification);
// Find or create batch
let batch = await this.batchRepo.findByBatchKey(
batchKey,
notification.recipientId,
true, // notSentOnly
);
if (!batch) {
// Create new batch
const scheduledFor = new Date();
scheduledFor.setMinutes(scheduledFor.getMinutes() + preferences.batchWindowMinutes);
batch = await this.batchRepo.insertBatch({
recipientId: notification.recipientId,
workspaceId: notification.workspaceId,
batchType: BatchType.SIMILAR_ACTIVITY,
batchKey: batchKey,
notificationCount: 1,
firstNotificationId: notification.id,
scheduledFor: scheduledFor,
});
// Schedule batch processing
await this.batchQueue.add(
'process-batch',
{ batchId: batch.id },
{
delay: preferences.batchWindowMinutes * 60 * 1000,
},
);
} else {
// Add to existing batch
await this.batchRepo.incrementNotificationCount(batch.id);
}
// Update notification with batch ID
await this.notificationRepo.updateNotification(notification.id, {
batchId: batch.id,
isBatched: true,
});
this.logger.debug(`Notification ${notification.id} added to batch ${batch.id}`);
} catch (error) {
this.logger.error(
`Failed to batch notification ${notification.id}: ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error.stack : undefined,
);
// Fall back to instant delivery on error
throw error;
}
}
private generateBatchKey(notification: Notification): string {
switch (notification.type) {
case NotificationType.COMMENT_ON_PAGE:
case NotificationType.COMMENT_RESOLVED: {
const context = notification.context as any;
return `page:${context?.pageId}:comments`;
}
case NotificationType.MENTION_IN_PAGE:
return `page:${notification.entityId}:mentions`;
case NotificationType.COMMENT_IN_THREAD: {
const mentionContext = notification.context as any;
return `thread:${mentionContext?.threadRootId}`;
}
default:
return `${notification.entityType}:${notification.entityId}:${notification.type}`;
}
}
async processBatch(batchId: string): Promise<void> {
const batch = await this.batchRepo.findById(batchId);
if (!batch || batch.sentAt) {
this.logger.debug(`Batch ${batchId} not found or already sent`);
return;
}
const notifications = await this.notificationRepo.findByBatchId(batchId);
if (notifications.length === 0) {
this.logger.debug(`No notifications found for batch ${batchId}`);
return;
}
// Group notifications by type for smart formatting
const grouped = this.groupNotificationsByType(notifications);
// Send batch email
await this.sendBatchEmail(batch.recipientId, batch.workspaceId, grouped);
// Mark batch as sent
await this.batchRepo.markAsSent(batchId);
// Update email sent timestamp for all notifications
const notificationIds = notifications.map(n => n.id);
await Promise.all(
notificationIds.map(id =>
this.notificationRepo.updateNotification(id, { emailSentAt: new Date() })
),
);
this.logger.log(`Batch ${batchId} processed with ${notifications.length} notifications`);
}
private groupNotificationsByType(notifications: Notification[]): NotificationGroup[] {
const groups = new Map<string, NotificationGroup>();
for (const notification of notifications) {
const key = `${notification.type}:${notification.entityId}`;
if (!groups.has(key)) {
groups.set(key, {
type: notification.type as NotificationType,
entityId: notification.entityId,
entityType: notification.entityType,
notifications: [],
actors: new Set(),
summary: '',
});
}
const group = groups.get(key)!;
group.notifications.push(notification);
if (notification.actorId) {
group.actors.add(notification.actorId);
}
}
// Generate summaries for each group
for (const group of groups.values()) {
group.summary = this.generateSummary(group.type, group.notifications);
}
return Array.from(groups.values());
}
private generateSummary(type: NotificationType, notifications: Notification[]): string {
const count = notifications.length;
const actors = new Set(notifications.map(n => n.actorId).filter(Boolean));
switch (type) {
case NotificationType.COMMENT_ON_PAGE:
if (count === 1) return 'commented on a page you follow';
return `and ${actors.size - 1} others commented on a page you follow`;
case NotificationType.MENTION_IN_COMMENT:
if (count === 1) return 'mentioned you in a comment';
return `mentioned you ${count} times in comments`;
case NotificationType.COMMENT_RESOLVED:
if (count === 1) return 'resolved a comment';
return `resolved ${count} comments`;
default:
return `${count} new activities`;
}
}
private async sendBatchEmail(
recipientId: string,
workspaceId: string,
groups: NotificationGroup[],
): Promise<void> {
// TODO: Implement email sending with batch template
// This will be implemented when we create email templates
this.logger.log(
`Sending batch email to ${recipientId} with ${groups.length} notification groups`,
);
}
async getPendingBatches(): Promise<any[]> {
return await this.batchRepo.getPendingBatches();
}
}

View File

@ -0,0 +1,151 @@
import { Injectable } from '@nestjs/common';
import { NotificationType } from '../types/notification.types';
import { CreateNotificationDto } from '../dto/create-notification.dto';
import { createHash } from 'crypto';
@Injectable()
export class NotificationDeduplicationService {
/**
* Generate a unique deduplication key based on notification type and context
*/
generateDeduplicationKey(params: CreateNotificationDto): string | null {
switch (params.type) {
case NotificationType.MENTION_IN_PAGE:
// Only one notification per mention in a page (until page is updated again)
return this.hash([
'mention',
'page',
params.entityId,
params.actorId,
params.recipientId,
]);
case NotificationType.MENTION_IN_COMMENT:
// One notification per comment mention
return this.hash([
'mention',
'comment',
params.entityId,
params.actorId,
params.recipientId,
]);
case NotificationType.COMMENT_ON_PAGE:
// Allow multiple notifications for different comments on the same page
return null; // No deduplication, rely on batching instead
case NotificationType.REPLY_TO_COMMENT:
// One notification per reply
return this.hash([
'reply',
params.entityId,
params.actorId,
params.recipientId,
]);
case NotificationType.COMMENT_RESOLVED:
// One notification per comment resolution
return this.hash([
'resolved',
params.context.commentId,
params.actorId,
params.recipientId,
]);
case NotificationType.EXPORT_COMPLETED:
case NotificationType.EXPORT_FAILED:
// One notification per export job
return this.hash([
'export',
params.context.jobId || params.entityId,
params.recipientId,
]);
case NotificationType.PAGE_SHARED:
// One notification per page share action
return this.hash([
'share',
params.entityId,
params.actorId,
params.recipientId,
Date.now().toString(), // Include timestamp to allow re-sharing
]);
default:
// For other types, generate a key based on common fields
return this.hash([
params.type,
params.entityId,
params.actorId,
params.recipientId,
]);
}
}
/**
* Check if a notification should be deduplicated based on recent activity
*/
shouldDeduplicate(type: NotificationType): boolean {
const deduplicatedTypes = [
NotificationType.MENTION_IN_PAGE,
NotificationType.MENTION_IN_COMMENT,
NotificationType.REPLY_TO_COMMENT,
NotificationType.COMMENT_RESOLVED,
NotificationType.EXPORT_COMPLETED,
NotificationType.EXPORT_FAILED,
];
return deduplicatedTypes.includes(type);
}
/**
* Get the time window for deduplication (in milliseconds)
*/
getDeduplicationWindow(type: NotificationType): number {
switch (type) {
case NotificationType.MENTION_IN_PAGE:
return 24 * 60 * 60 * 1000; // 24 hours
case NotificationType.MENTION_IN_COMMENT:
return 60 * 60 * 1000; // 1 hour
case NotificationType.EXPORT_COMPLETED:
case NotificationType.EXPORT_FAILED:
return 5 * 60 * 1000; // 5 minutes
default:
return 30 * 60 * 1000; // 30 minutes default
}
}
/**
* Create a hash from array of values
*/
private hash(values: (string | null | undefined)[]): string {
const filtered = values.filter((v) => v !== null && v !== undefined);
const input = filtered.join(':');
return createHash('sha256').update(input).digest('hex').substring(0, 32);
}
/**
* Generate a key for custom deduplication scenarios
*/
generateCustomKey(
type: string,
entityId: string,
recipientId: string,
additionalData?: Record<string, any>,
): string {
const baseValues = [type, entityId, recipientId];
if (additionalData) {
// Sort keys for consistent hashing
const sortedKeys = Object.keys(additionalData).sort();
for (const key of sortedKeys) {
baseValues.push(`${key}:${additionalData[key]}`);
}
}
return this.hash(baseValues);
}
}

View File

@ -0,0 +1,194 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { QueueName } from '../../../integrations/queue/constants';
import { WsGateway } from '../../../ws/ws.gateway';
import { NotificationBatchingService } from './notification-batching.service';
import { NotificationPreferenceService } from './notification-preference.service';
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
import { Notification } from '@docmost/db/types/entity.types';
import {
NotificationCreatedEvent,
NotificationReadEvent,
NotificationAllReadEvent,
NOTIFICATION_EVENTS,
} from '../events/notification.events';
import { NotificationType, NotificationPriority } from '../types/notification.types';
@Injectable()
export class NotificationDeliveryService {
private readonly logger = new Logger(NotificationDeliveryService.name);
constructor(
@InjectQueue(QueueName.EMAIL_QUEUE) private readonly mailQueue: Queue,
private readonly wsGateway: WsGateway,
private readonly batchingService: NotificationBatchingService,
private readonly preferenceService: NotificationPreferenceService,
private readonly notificationRepo: NotificationRepo,
) {}
@OnEvent(NOTIFICATION_EVENTS.CREATED)
async handleNotificationCreated(event: NotificationCreatedEvent) {
const { notification, workspaceId } = event;
try {
const decision = await this.preferenceService.makeNotificationDecision(
notification.recipientId,
workspaceId,
notification.type as NotificationType,
notification.priority as NotificationPriority,
);
// In-app delivery (always immediate)
if (decision.channels.includes('in_app')) {
await this.deliverInApp(notification, workspaceId);
}
// Email delivery (may be batched)
if (decision.channels.includes('email')) {
if (decision.batchingEnabled) {
await this.batchingService.addToBatch(notification);
} else {
await this.deliverEmailInstant(notification);
}
}
} catch (error) {
this.logger.error(
`Failed to deliver notification ${notification.id}: ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error.stack : undefined,
);
}
}
private async deliverInApp(notification: Notification, workspaceId: string) {
try {
// Send notification via WebSocket to user's workspace room
const notificationData = {
id: notification.id,
type: notification.type,
status: notification.status,
priority: notification.priority,
actorId: notification.actorId,
entityType: notification.entityType,
entityId: notification.entityId,
context: notification.context,
createdAt: notification.createdAt,
};
// Emit to user-specific room
this.wsGateway.emitToUser(
notification.recipientId,
'notification:new',
notificationData,
);
// Update unread count
const unreadCount = await this.notificationRepo.getUnreadCount(
notification.recipientId,
);
this.wsGateway.emitToUser(
notification.recipientId,
'notification:unreadCount',
{ count: unreadCount },
);
// Update delivery status
await this.notificationRepo.updateNotification(notification.id, {
inAppDeliveredAt: new Date(),
});
this.logger.debug(`In-app notification delivered: ${notification.id}`);
} catch (error) {
this.logger.error(
`Failed to deliver in-app notification ${notification.id}: ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error.stack : undefined,
);
}
}
private async deliverEmailInstant(notification: Notification) {
try {
await this.mailQueue.add(
'send-notification-email',
{
notificationId: notification.id,
type: notification.type,
},
{
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000,
},
},
);
this.logger.debug(`Email notification queued: ${notification.id}`);
} catch (error) {
this.logger.error(
`Failed to queue email notification ${notification.id}: ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error.stack : undefined,
);
}
}
@OnEvent(NOTIFICATION_EVENTS.READ)
async handleNotificationRead(event: NotificationReadEvent) {
const { notificationId, userId } = event;
// Send real-time update to user
this.wsGateway.emitToUser(userId, 'notification:read', {
notificationId,
});
// Update unread count
const unreadCount = await this.notificationRepo.getUnreadCount(userId);
this.wsGateway.emitToUser(userId, 'notification:unreadCount', {
count: unreadCount,
});
}
@OnEvent(NOTIFICATION_EVENTS.ALL_READ)
async handleAllNotificationsRead(event: NotificationAllReadEvent) {
const { userId, notificationIds } = event;
// Send real-time update to user
this.wsGateway.emitToUser(userId, 'notification:allRead', {
notificationIds,
});
// Update unread count (should be 0)
this.wsGateway.emitToUser(userId, 'notification:unreadCount', { count: 0 });
}
/**
* Process email delivery for a notification
* Called by the mail queue processor
*/
async processEmailNotification(notificationId: string) {
const notification = await this.notificationRepo.findById(notificationId);
if (!notification) {
throw new Error(`Notification not found: ${notificationId}`);
}
// Check if already sent
if (notification.emailSentAt) {
this.logger.debug(
`Notification already sent via email: ${notificationId}`,
);
return;
}
// TODO: Load user and workspace data
// TODO: Render appropriate email template based on notification type
// TODO: Send email using mail service
// For now, just mark as sent
await this.notificationRepo.updateNotification(notificationId, {
emailSentAt: new Date(),
});
this.logger.log(`Email notification sent: ${notificationId}`);
}
}

View File

@ -0,0 +1,303 @@
import { Injectable, Logger } from '@nestjs/common';
import { NotificationPreferenceRepo } from '@docmost/db/repos/notification/notification-preference.repo';
import { UpdateNotificationPreferencesDto } from '../dto/update-preference.dto';
import { NotificationPreference } from '@docmost/db/types/entity.types';
import {
NotificationType,
NotificationPriority,
} from '../types/notification.types';
import {
addDays,
setHours,
setMinutes,
setSeconds,
getDay,
differenceInMilliseconds,
startOfDay,
addHours
} from 'date-fns';
interface NotificationDecision {
shouldNotify: boolean;
channels: ('email' | 'in_app')[];
delay?: number;
batchingEnabled: boolean;
}
@Injectable()
export class NotificationPreferenceService {
private readonly logger = new Logger(NotificationPreferenceService.name);
constructor(private readonly preferenceRepo: NotificationPreferenceRepo) {}
async getUserPreferences(
userId: string,
workspaceId: string,
): Promise<NotificationPreference> {
return await this.preferenceRepo.findOrCreate(userId, workspaceId);
}
async updateUserPreferences(
userId: string,
workspaceId: string,
updates: UpdateNotificationPreferencesDto,
): Promise<NotificationPreference> {
const existing = await this.getUserPreferences(userId, workspaceId);
// Merge notification settings if provided
let mergedSettings = existing.notificationSettings;
if (updates.notificationSettings) {
mergedSettings = {
...((existing.notificationSettings as Record<string, any>) || {}),
...(updates.notificationSettings || {}),
};
}
// Validate batch window
if (updates.batchWindowMinutes !== undefined) {
updates.batchWindowMinutes = Math.max(
5,
Math.min(60, updates.batchWindowMinutes),
);
}
const updated = await this.preferenceRepo.updatePreference(
userId,
workspaceId,
{
...updates,
notificationSettings: mergedSettings,
},
);
this.logger.log(`User ${userId} updated notification preferences`, {
userId,
workspaceId,
changes: updates,
});
return updated;
}
async shouldNotify(
recipientId: string,
type: NotificationType,
workspaceId: string,
): Promise<boolean> {
const preferences = await this.getUserPreferences(recipientId, workspaceId);
const decision = await this.makeNotificationDecision(
recipientId,
workspaceId,
type,
NotificationPriority.NORMAL,
);
return decision.shouldNotify;
}
async makeNotificationDecision(
userId: string,
workspaceId: string,
type: NotificationType,
priority: NotificationPriority = NotificationPriority.NORMAL,
): Promise<NotificationDecision> {
const preferences = await this.getUserPreferences(userId, workspaceId);
// Global check
if (!preferences.emailEnabled && !preferences.inAppEnabled) {
return {
shouldNotify: false,
channels: [],
batchingEnabled: false,
};
}
// Type-specific settings
const typeSettings = this.getTypeSettings(preferences, type);
const channels: ('email' | 'in_app')[] = [];
if (preferences.emailEnabled && typeSettings.email) channels.push('email');
if (preferences.inAppEnabled && typeSettings.in_app)
channels.push('in_app');
if (channels.length === 0) {
return {
shouldNotify: false,
channels: [],
batchingEnabled: false,
};
}
// Check quiet hours
const quietHoursDelay = this.calculateQuietHoursDelay(
preferences,
priority,
);
// Check weekend preferences
if (
!preferences.weekendNotifications &&
this.isWeekend(preferences.timezone)
) {
if (priority !== NotificationPriority.HIGH) {
const mondayDelay = this.getDelayUntilMonday(preferences.timezone);
return {
shouldNotify: true,
channels,
delay: mondayDelay,
batchingEnabled: true,
};
}
}
return {
shouldNotify: true,
channels,
delay: quietHoursDelay,
batchingEnabled:
typeSettings.batch && preferences.emailFrequency === 'smart',
};
}
private getTypeSettings(
preferences: NotificationPreference,
type: NotificationType,
): any {
const settings = preferences.notificationSettings as any;
return settings[type] || { email: true, in_app: true, batch: false };
}
private calculateQuietHoursDelay(
preferences: NotificationPreference,
priority: NotificationPriority,
): number | undefined {
if (
!preferences.quietHoursEnabled ||
priority === NotificationPriority.HIGH
) {
return undefined;
}
// TODO: Implement proper timezone conversion
const now = new Date();
const quietStart = this.parseTime(
preferences.quietHoursStart,
preferences.timezone,
);
const quietEnd = this.parseTime(
preferences.quietHoursEnd,
preferences.timezone,
);
if (this.isInQuietHours(now, quietStart, quietEnd)) {
return this.getDelayUntilEndOfQuietHours(now, quietEnd);
}
return undefined;
}
private parseTime(timeStr: string, timezone: string): Date {
const [hours, minutes, seconds] = timeStr.split(':').map(Number);
// TODO: Implement proper timezone conversion
const now = new Date();
return setSeconds(setMinutes(setHours(now, hours), minutes), seconds || 0);
}
private isInQuietHours(
now: Date,
start: Date,
end: Date,
): boolean {
const nowMinutes = now.getHours() * 60 + now.getMinutes();
const startMinutes = start.getHours() * 60 + start.getMinutes();
const endMinutes = end.getHours() * 60 + end.getMinutes();
if (startMinutes <= endMinutes) {
// Quiet hours don't cross midnight
return nowMinutes >= startMinutes && nowMinutes < endMinutes;
} else {
// Quiet hours cross midnight
return nowMinutes >= startMinutes || nowMinutes < endMinutes;
}
}
private getDelayUntilEndOfQuietHours(now: Date, end: Date): number {
let endTime = end;
// If end time is before current time, it means quiet hours end tomorrow
if (
end.getHours() < now.getHours() ||
(end.getHours() === now.getHours() && end.getMinutes() <= now.getMinutes())
) {
endTime = addDays(endTime, 1);
}
return differenceInMilliseconds(endTime, now);
}
private isWeekend(timezone: string): boolean {
// TODO: Implement proper timezone conversion
const now = new Date();
const dayOfWeek = getDay(now);
return dayOfWeek === 0 || dayOfWeek === 6; // 0 = Sunday, 6 = Saturday
}
private getDelayUntilMonday(timezone: string): number {
// TODO: Implement proper timezone conversion
const now = new Date();
const currentDay = getDay(now);
const daysUntilMonday = currentDay === 0 ? 1 : (8 - currentDay) % 7 || 7;
const nextMonday = addDays(now, daysUntilMonday);
const mondayMorning = addHours(startOfDay(nextMonday), 9); // 9 AM Monday
return differenceInMilliseconds(mondayMorning, now);
}
async getNotificationStats(
userId: string,
workspaceId: string,
): Promise<{
preferences: NotificationPreference;
stats: {
emailEnabled: boolean;
inAppEnabled: boolean;
quietHoursActive: boolean;
batchingEnabled: boolean;
typesDisabled: string[];
};
}> {
const preferences = await this.getUserPreferences(userId, workspaceId);
// TODO: Implement proper timezone conversion
const now = new Date();
const quietStart = this.parseTime(
preferences.quietHoursStart,
preferences.timezone,
);
const quietEnd = this.parseTime(
preferences.quietHoursEnd,
preferences.timezone,
);
const typesDisabled: string[] = [];
const settings = preferences.notificationSettings as any;
for (const [type, config] of Object.entries(settings)) {
const typeSettings = config as any;
if (!typeSettings.email && !typeSettings.in_app) {
typesDisabled.push(type);
}
}
return {
preferences,
stats: {
emailEnabled: preferences.emailEnabled,
inAppEnabled: preferences.inAppEnabled,
quietHoursActive:
preferences.quietHoursEnabled &&
this.isInQuietHours(now, quietStart, quietEnd),
batchingEnabled: preferences.emailFrequency !== 'instant',
typesDisabled,
},
};
}
}

View File

@ -0,0 +1,275 @@
import { Injectable, Logger } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
import { NotificationDeduplicationService } from './notification-deduplication.service';
import { NotificationPreferenceService } from './notification-preference.service';
import { CreateNotificationDto } from '../dto/create-notification.dto';
import { Notification } from '@docmost/db/types/entity.types';
import {
NotificationStatus,
NotificationPriority,
NotificationType,
} from '../types/notification.types';
import {
NotificationCreatedEvent,
NotificationReadEvent,
NotificationAllReadEvent,
NOTIFICATION_EVENTS,
} from '../events/notification.events';
@Injectable()
export class NotificationService {
private readonly logger = new Logger(NotificationService.name);
constructor(
private readonly notificationRepo: NotificationRepo,
private readonly eventEmitter: EventEmitter2,
private readonly deduplicationService: NotificationDeduplicationService,
private readonly preferenceService: NotificationPreferenceService,
) {}
async createNotification(
dto: CreateNotificationDto,
): Promise<Notification | null> {
try {
// Set default priority if not provided
const priority = dto.priority || NotificationPriority.NORMAL;
// Check user preferences first
const decision = await this.preferenceService.makeNotificationDecision(
dto.recipientId,
dto.workspaceId,
dto.type,
priority,
);
if (!decision.shouldNotify) {
this.logger.debug(
`Notification blocked by user preferences: ${dto.type} for ${dto.recipientId}`,
);
return null;
}
// Generate deduplication key
let deduplicationKey = dto.deduplicationKey;
if (
!deduplicationKey &&
this.deduplicationService.shouldDeduplicate(dto.type)
) {
deduplicationKey =
this.deduplicationService.generateDeduplicationKey(dto);
}
// Check if duplicate
if (
deduplicationKey &&
(await this.notificationRepo.existsByDeduplicationKey(deduplicationKey))
) {
this.logger.debug(
`Duplicate notification prevented: ${deduplicationKey}`,
);
return null;
}
// Generate group key if not provided
const groupKey = dto.groupKey || this.generateGroupKey(dto);
// Calculate expiration
const expiresAt = this.calculateExpiration(dto.type);
// Create notification
const notification = await this.notificationRepo.insertNotification({
workspaceId: dto.workspaceId,
recipientId: dto.recipientId,
actorId: dto.actorId || null,
type: dto.type,
status: NotificationStatus.UNREAD,
priority,
entityType: dto.entityType,
entityId: dto.entityId,
context: dto.context,
groupKey: groupKey,
groupCount: 1,
deduplicationKey: deduplicationKey,
batchId: null,
isBatched: false,
emailSentAt: null,
inAppDeliveredAt: null,
readAt: null,
expiresAt: expiresAt,
});
// Emit event for delivery processing
this.eventEmitter.emit(
NOTIFICATION_EVENTS.CREATED,
new NotificationCreatedEvent(notification, dto.workspaceId),
);
this.logger.debug(
`Notification created: ${notification.id} for user ${dto.recipientId}`,
);
return notification;
} catch (error) {
this.logger.error(
`Failed to create notification: ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error.stack : undefined,
);
throw error;
}
}
async getNotifications(
userId: string,
workspaceId: string,
options: {
status?: NotificationStatus;
limit?: number;
offset?: number;
} = {},
): Promise<Notification[]> {
return await this.notificationRepo.findByRecipient(userId, options);
}
async getGroupedNotifications(
userId: string,
workspaceId: string,
options: {
status?: NotificationStatus;
limit?: number;
offset?: number;
} = {},
): Promise<{
notifications: Notification[];
groups: Map<string, Notification[]>;
}> {
const notifications = await this.getNotifications(
userId,
workspaceId,
options,
);
// Group notifications by group_key
const groups = new Map<string, Notification[]>();
for (const notification of notifications) {
if (notification.groupKey) {
const group = groups.get(notification.groupKey) || [];
group.push(notification);
groups.set(notification.groupKey, group);
}
}
return { notifications, groups };
}
async markAsRead(notificationId: string, userId: string): Promise<void> {
const notification = await this.notificationRepo.findById(notificationId);
if (!notification || notification.recipientId !== userId) {
throw new Error('Notification not found or unauthorized');
}
if (notification.status === NotificationStatus.READ) {
return; // Already read
}
await this.notificationRepo.markAsRead(notificationId);
// Emit event for real-time update
this.eventEmitter.emit(
NOTIFICATION_EVENTS.READ,
new NotificationReadEvent(notificationId, userId),
);
this.logger.debug(`Notification marked as read: ${notificationId}`);
}
async markAllAsRead(userId: string): Promise<void> {
const unreadNotifications = await this.notificationRepo.findByRecipient(
userId,
{
status: NotificationStatus.UNREAD,
},
);
const ids = unreadNotifications.map((n) => n.id);
if (ids.length > 0) {
await this.notificationRepo.markManyAsRead(ids);
// Emit event for real-time update
this.eventEmitter.emit(
NOTIFICATION_EVENTS.ALL_READ,
new NotificationAllReadEvent(userId, ids),
);
this.logger.debug(
`Marked ${ids.length} notifications as read for user ${userId}`,
);
}
}
async getUnreadCount(userId: string): Promise<number> {
return await this.notificationRepo.getUnreadCount(userId);
}
async deleteExpiredNotifications(): Promise<number> {
const deletedCount = await this.notificationRepo.deleteExpired();
if (deletedCount > 0) {
this.logger.log(`Deleted ${deletedCount} expired notifications`);
}
return deletedCount;
}
private generateGroupKey(dto: CreateNotificationDto): string {
// Generate a group key based on notification type and entity
return `${dto.type}:${dto.entityType}:${dto.entityId}`;
}
private calculateExpiration(type: string): Date | null {
// Set expiration based on notification type
const expirationDays = {
[NotificationType.EXPORT_COMPLETED]: 7, // Expire after 7 days
[NotificationType.EXPORT_FAILED]: 3, // Expire after 3 days
[NotificationType.MENTION_IN_PAGE]: 30, // Expire after 30 days
[NotificationType.MENTION_IN_COMMENT]: 30,
[NotificationType.COMMENT_ON_PAGE]: 60, // Expire after 60 days
[NotificationType.REPLY_TO_COMMENT]: 60,
[NotificationType.COMMENT_IN_THREAD]: 60,
[NotificationType.COMMENT_RESOLVED]: 90, // Expire after 90 days
[NotificationType.PAGE_SHARED]: 90,
};
const days = expirationDays[type as NotificationType];
if (!days) {
return null; // No expiration
}
const expirationDate = new Date();
expirationDate.setDate(expirationDate.getDate() + days);
return expirationDate;
}
async createTestNotification(
userId: string,
workspaceId: string,
type: NotificationType,
): Promise<Notification | null> {
return await this.createNotification({
workspaceId,
recipientId: userId,
actorId: userId,
type,
entityType: 'test',
entityId: 'test-notification',
context: {
message: 'This is a test notification',
timestamp: new Date(),
},
priority: NotificationPriority.NORMAL,
});
}
}

View File

@ -0,0 +1,153 @@
import * as React from 'react';
import { Button, Section, Text, Link, Hr, Heading } from '@react-email/components';
import { MailBody } from '@docmost/transactional/partials/partials';
import { content, paragraph, button, h1 } from '@docmost/transactional/css/styles';
interface NotificationGroup {
type: string;
title: string;
summary: string;
count: number;
actors: string[];
url: string;
preview: string[];
}
interface BatchNotificationEmailProps {
recipientName: string;
groups: NotificationGroup[];
totalCount: number;
workspaceName: string;
settingsUrl: string;
viewAllUrl: string;
}
export const BatchNotificationEmail = ({
recipientName,
groups,
totalCount,
workspaceName,
settingsUrl,
viewAllUrl,
}: BatchNotificationEmailProps) => {
return (
<MailBody>
<Section style={content}>
<Text style={h1}>Hi {recipientName},</Text>
<Text style={paragraph}>
You have {totalCount} new notifications in {workspaceName}:
</Text>
{groups.map((group, index) => (
<Section key={index} style={notificationGroup}>
<Heading as="h3" style={groupTitle}>
{group.title}
</Heading>
<Text style={actorList}>
{formatActors(group.actors)} {group.summary}
</Text>
{group.preview.slice(0, 3).map((item, i) => (
<Text key={i} style={notificationItem}>
{item}
</Text>
))}
{group.count > 3 && (
<Text style={moreText}>
And {group.count - 3} more...
</Text>
)}
<Button href={group.url} style={viewButton}>
View All
</Button>
</Section>
))}
<Hr style={divider} />
<Button href={viewAllUrl} style={viewAllButton}>
View All Notifications
</Button>
<Text style={footerText}>
You received this because you have smart notifications enabled.{' '}
<Link href={settingsUrl} style={{ color: '#176ae5' }}>
Manage your preferences
</Link>
</Text>
</Section>
</MailBody>
);
};
function formatActors(actors: string[]): string {
if (actors.length === 0) return '';
if (actors.length === 1) return actors[0];
if (actors.length === 2) return `${actors[0]} and ${actors[1]}`;
return `${actors[0]}, ${actors[1]} and ${actors.length - 2} others`;
}
const notificationGroup: React.CSSProperties = {
backgroundColor: '#f9f9f9',
borderRadius: '4px',
padding: '16px',
marginBottom: '16px',
};
const groupTitle: React.CSSProperties = {
...paragraph,
fontSize: '16px',
fontWeight: 'bold',
marginBottom: '8px',
};
const actorList: React.CSSProperties = {
...paragraph,
marginBottom: '12px',
};
const notificationItem: React.CSSProperties = {
...paragraph,
marginLeft: '8px',
marginBottom: '4px',
color: '#666',
};
const moreText: React.CSSProperties = {
...paragraph,
fontStyle: 'italic',
color: '#999',
marginLeft: '8px',
marginBottom: '12px',
};
const viewButton: React.CSSProperties = {
...button,
width: 'auto',
padding: '8px 16px',
fontSize: '14px',
marginTop: '8px',
};
const viewAllButton: React.CSSProperties = {
...button,
width: 'auto',
padding: '12px 24px',
margin: '16px auto',
};
const divider: React.CSSProperties = {
borderColor: '#e0e0e0',
margin: '24px 0',
};
const footerText: React.CSSProperties = {
...paragraph,
fontSize: '12px',
color: '#666',
marginTop: '24px',
};

View File

@ -0,0 +1,72 @@
import * as React from 'react';
import { Button, Section, Text, Link } from '@react-email/components';
import { MailBody } from '../../../integrations/transactional/partials/partials';
import { content, paragraph, button, h1 } from '../../../integrations/transactional/css/styles';
interface CommentOnPageEmailProps {
recipientName: string;
actorName: string;
pageTitle: string;
commentExcerpt: string;
pageUrl: string;
workspaceName: string;
settingsUrl: string;
}
export const CommentOnPageEmail = ({
recipientName,
actorName,
pageTitle,
commentExcerpt,
pageUrl,
workspaceName,
settingsUrl,
}: CommentOnPageEmailProps) => {
return (
<MailBody>
<Section style={content}>
<Text style={h1}>Hi {recipientName},</Text>
<Text style={paragraph}>
{actorName} commented on "{pageTitle}":
</Text>
<Section style={commentSection}>
<Text style={commentText}>
{commentExcerpt}
</Text>
</Section>
<Button href={pageUrl} style={button}>
View Comment
</Button>
<Text style={footerText}>
This notification was sent from {workspaceName}.{' '}
<Link href={settingsUrl} style={{ color: '#176ae5' }}>
Manage your notification preferences
</Link>
</Text>
</Section>
</MailBody>
);
};
const commentSection: React.CSSProperties = {
backgroundColor: '#f5f5f5',
borderRadius: '4px',
padding: '16px',
margin: '16px 0',
};
const commentText: React.CSSProperties = {
...paragraph,
margin: 0,
};
const footerText: React.CSSProperties = {
...paragraph,
fontSize: '12px',
color: '#666',
marginTop: '24px',
};

View File

@ -0,0 +1,117 @@
import * as React from 'react';
import { Button, Section, Text, Link, Row, Column } from '@react-email/components';
import { MailBody } from '../../../integrations/transactional/partials/partials';
import { content, paragraph, button, h1 } from '../../../integrations/transactional/css/styles';
interface ExportCompletedEmailProps {
recipientName: string;
exportType: string;
entityName: string;
fileSize: string;
downloadUrl: string;
expiresAt: string;
workspaceName: string;
settingsUrl: string;
}
export const ExportCompletedEmail = ({
recipientName,
exportType,
entityName,
fileSize,
downloadUrl,
expiresAt,
workspaceName,
settingsUrl,
}: ExportCompletedEmailProps) => {
return (
<MailBody>
<Section style={content}>
<Text style={h1}>Export Complete!</Text>
<Text style={paragraph}>
Hi {recipientName},
</Text>
<Text style={paragraph}>
Your {exportType.toUpperCase()} export of "{entityName}" has been completed successfully.
</Text>
<Section style={exportDetails}>
<Row>
<Column style={detailLabel}>File Size:</Column>
<Column style={detailValue}>{fileSize}</Column>
</Row>
<Row>
<Column style={detailLabel}>Format:</Column>
<Column style={detailValue}>{exportType.toUpperCase()}</Column>
</Row>
<Row>
<Column style={detailLabel}>Expires:</Column>
<Column style={detailValue}>{expiresAt}</Column>
</Row>
</Section>
<Button href={downloadUrl} style={downloadButton}>
Download Export
</Button>
<Text style={warningText}>
This download link will expire on {expiresAt}.
Please download your file before then.
</Text>
<Text style={footerText}>
This notification was sent from {workspaceName}.{' '}
<Link href={settingsUrl} style={{ color: '#176ae5' }}>
Manage your notification preferences
</Link>
</Text>
</Section>
</MailBody>
);
};
const exportDetails: React.CSSProperties = {
backgroundColor: '#f5f5f5',
borderRadius: '4px',
padding: '16px',
margin: '16px 0',
};
const detailLabel: React.CSSProperties = {
...paragraph,
fontWeight: 'bold',
width: '120px',
paddingBottom: '8px',
};
const detailValue: React.CSSProperties = {
...paragraph,
paddingBottom: '8px',
};
const downloadButton: React.CSSProperties = {
...button,
backgroundColor: '#28a745',
width: 'auto',
padding: '12px 24px',
margin: '0 auto',
};
const warningText: React.CSSProperties = {
...paragraph,
backgroundColor: '#fff3cd',
border: '1px solid #ffeeba',
borderRadius: '4px',
color: '#856404',
padding: '12px',
marginTop: '16px',
};
const footerText: React.CSSProperties = {
...paragraph,
fontSize: '12px',
color: '#666',
marginTop: '24px',
};

View File

@ -0,0 +1,93 @@
import * as React from 'react';
import { Button, Section, Text, Link } from '@react-email/components';
import { MailBody } from '../../../integrations/transactional/partials/partials';
import { content, paragraph, button, h1 } from '../../../integrations/transactional/css/styles';
interface MentionInCommentEmailProps {
recipientName: string;
actorName: string;
pageTitle: string;
commentExcerpt: string;
mentionContext: string;
commentUrl: string;
workspaceName: string;
settingsUrl: string;
}
export const MentionInCommentEmail = ({
recipientName,
actorName,
pageTitle,
commentExcerpt,
mentionContext,
commentUrl,
workspaceName,
settingsUrl,
}: MentionInCommentEmailProps) => {
return (
<MailBody>
<Section style={content}>
<Text style={h1}>Hi {recipientName},</Text>
<Text style={paragraph}>
{actorName} mentioned you in a comment on "{pageTitle}":
</Text>
<Section style={commentSection}>
<Text style={commentAuthor}>{actorName} commented:</Text>
<Text style={commentText}>
{commentExcerpt}
</Text>
{mentionContext && (
<Text style={mentionHighlight}>
Context: ...{mentionContext}...
</Text>
)}
</Section>
<Button href={commentUrl} style={button}>
View Comment
</Button>
<Text style={footerText}>
This notification was sent from {workspaceName}.{' '}
<Link href={settingsUrl} style={{ color: '#176ae5' }}>
Manage your notification preferences
</Link>
</Text>
</Section>
</MailBody>
);
};
const commentSection: React.CSSProperties = {
backgroundColor: '#f5f5f5',
borderRadius: '4px',
padding: '16px',
margin: '16px 0',
};
const commentAuthor: React.CSSProperties = {
...paragraph,
fontWeight: 'bold',
marginBottom: '8px',
};
const commentText: React.CSSProperties = {
...paragraph,
margin: '0 0 8px 0',
};
const mentionHighlight: React.CSSProperties = {
...paragraph,
fontStyle: 'italic',
color: '#666',
margin: 0,
};
const footerText: React.CSSProperties = {
...paragraph,
fontSize: '12px',
color: '#666',
marginTop: '24px',
};

View File

@ -0,0 +1,73 @@
import * as React from 'react';
import { Button, Section, Text, Link } from '@react-email/components';
import { MailBody } from '../../../integrations/transactional/partials/partials';
import { content, paragraph, button, h1 } from '../../../integrations/transactional/css/styles';
interface MentionInPageEmailProps {
recipientName: string;
actorName: string;
pageTitle: string;
mentionContext: string;
pageUrl: string;
workspaceName: string;
settingsUrl: string;
}
export const MentionInPageEmail = ({
recipientName,
actorName,
pageTitle,
mentionContext,
pageUrl,
workspaceName,
settingsUrl,
}: MentionInPageEmailProps) => {
return (
<MailBody>
<Section style={content}>
<Text style={h1}>Hi {recipientName},</Text>
<Text style={paragraph}>
{actorName} mentioned you in the page "{pageTitle}":
</Text>
<Section style={mentionSection}>
<Text style={mentionText}>
...{mentionContext}...
</Text>
</Section>
<Button href={pageUrl} style={button}>
View Page
</Button>
<Text style={footerText}>
This notification was sent from {workspaceName}.{' '}
<Link href={settingsUrl} style={{ color: '#176ae5' }}>
Manage your notification preferences
</Link>
</Text>
</Section>
</MailBody>
);
};
const mentionSection: React.CSSProperties = {
backgroundColor: '#f5f5f5',
borderRadius: '4px',
padding: '16px',
margin: '16px 0',
};
const mentionText: React.CSSProperties = {
...paragraph,
fontStyle: 'italic',
margin: 0,
};
const footerText: React.CSSProperties = {
...paragraph,
fontSize: '12px',
color: '#666',
marginTop: '24px',
};

View File

@ -0,0 +1,220 @@
export enum NotificationType {
// Mentions
MENTION_IN_PAGE = 'mention_in_page',
MENTION_IN_COMMENT = 'mention_in_comment',
// Comments
COMMENT_ON_PAGE = 'comment_on_page',
REPLY_TO_COMMENT = 'reply_to_comment',
COMMENT_IN_THREAD = 'comment_in_thread',
COMMENT_RESOLVED = 'comment_resolved',
// Exports
EXPORT_COMPLETED = 'export_completed',
EXPORT_FAILED = 'export_failed',
// Pages
PAGE_SHARED = 'page_shared',
PAGE_UPDATED = 'page_updated',
// Tasks (Future)
TASK_ASSIGNED = 'task_assigned',
TASK_DUE = 'task_due',
TASK_COMPLETED = 'task_completed',
// System
SYSTEM_UPDATE = 'system_update',
SYSTEM_ANNOUNCEMENT = 'system_announcement',
}
export enum NotificationStatus {
UNREAD = 'unread',
READ = 'read',
ARCHIVED = 'archived',
}
export enum NotificationPriority {
HIGH = 'high',
NORMAL = 'normal',
LOW = 'low',
}
export enum EmailFrequency {
INSTANT = 'instant',
SMART = 'smart',
DIGEST_DAILY = 'digest_daily',
DIGEST_WEEKLY = 'digest_weekly',
}
export interface NotificationTypeSettings {
email: boolean;
in_app: boolean;
batch: boolean;
}
export enum BatchType {
SIMILAR_ACTIVITY = 'similar_activity',
DIGEST = 'digest',
GROUPED_MENTIONS = 'grouped_mentions',
}
export enum AggregationType {
COMMENTS_ON_PAGE = 'comments_on_page',
MENTIONS_IN_PAGE = 'mentions_in_page',
MENTIONS_IN_COMMENTS = 'mentions_in_comments',
THREAD_ACTIVITY = 'thread_activity',
}
export interface AggregationSummaryData {
total_count: number;
actor_count: number;
first_actor_id: string;
recent_actors: string[];
time_span: {
start: Date;
end: Date;
};
[key: string]: any; // Allow additional type-specific data
}
export interface AggregatedNotificationMessage {
id: string;
title: string;
message: string;
actors: Array<{
id: string;
name: string;
avatarUrl?: string;
}>;
totalCount: number;
entityId: string;
entityType: string;
createdAt: Date;
updatedAt: Date;
}
// Context interfaces for different notification types
export interface MentionInPageContext {
pageId: string;
pageTitle: string;
mentionContext: string;
mentionBy: string;
}
export interface MentionInCommentContext {
pageId: string;
pageTitle: string;
commentId: string;
commentText: string;
mentionBy: string;
}
export interface CommentOnPageContext {
pageId: string;
pageTitle: string;
commentId: string;
commentText: string;
commentBy: string;
}
export interface ReplyToCommentContext {
pageId: string;
pageTitle: string;
commentId: string;
parentCommentId: string;
commentText: string;
replyBy: string;
}
export interface CommentInThreadContext {
pageId: string;
pageTitle: string;
threadId: string;
commentId: string;
commentText: string;
commentBy: string;
}
export interface CommentResolvedContext {
pageId: string;
pageTitle: string;
threadId: string;
resolvedBy: string;
}
export interface ExportCompletedContext {
exportId: string;
exportType: string;
downloadUrl: string;
expiresAt: string;
}
export interface ExportFailedContext {
exportId: string;
exportType: string;
errorMessage: string;
}
export interface PageSharedContext {
pageId: string;
pageTitle: string;
sharedBy: string;
sharedWith: string[];
permissions: string[];
}
export interface PageUpdatedContext {
pageId: string;
pageTitle: string;
updatedBy: string;
changeType: 'content' | 'title' | 'permissions';
}
export interface TaskAssignedContext {
taskId: string;
taskTitle: string;
assignedBy: string;
dueDate?: string;
}
export interface TaskDueContext {
taskId: string;
taskTitle: string;
dueDate: string;
daysOverdue?: number;
}
export interface TaskCompletedContext {
taskId: string;
taskTitle: string;
completedBy: string;
}
export interface SystemUpdateContext {
updateType: string;
version?: string;
description: string;
}
export interface SystemAnnouncementContext {
message: string;
link?: string;
}
export type NotificationContext =
| MentionInPageContext
| MentionInCommentContext
| CommentOnPageContext
| ReplyToCommentContext
| CommentInThreadContext
| CommentResolvedContext
| ExportCompletedContext
| ExportFailedContext
| PageSharedContext
| PageUpdatedContext
| TaskAssignedContext
| TaskDueContext
| TaskCompletedContext
| SystemUpdateContext
| SystemAnnouncementContext
| Record<string, unknown>;

View File

@ -25,6 +25,10 @@ import { MigrationService } from '@docmost/db/services/migration.service';
import { UserTokenRepo } from './repos/user-token/user-token.repo';
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { NotificationRepo } from './repos/notification/notification.repo';
import { NotificationPreferenceRepo } from './repos/notification/notification-preference.repo';
import { NotificationBatchRepo } from './repos/notification/notification-batch.repo';
import { NotificationAggregationRepo } from './repos/notification/notification-aggregation.repo';
// https://github.com/brianc/node-postgres/issues/811
types.setTypeParser(types.builtins.INT8, (val) => Number(val));
@ -75,7 +79,11 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
AttachmentRepo,
UserTokenRepo,
BacklinkRepo,
ShareRepo
ShareRepo,
NotificationRepo,
NotificationPreferenceRepo,
NotificationBatchRepo,
NotificationAggregationRepo,
],
exports: [
WorkspaceRepo,
@ -90,7 +98,11 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
AttachmentRepo,
UserTokenRepo,
BacklinkRepo,
ShareRepo
ShareRepo,
NotificationRepo,
NotificationPreferenceRepo,
NotificationBatchRepo,
NotificationAggregationRepo,
],
})
export class DatabaseModule

View File

@ -0,0 +1,233 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
// Create notifications table
await db.schema
.createTable('notifications')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.notNull().references('workspaces.id').onDelete('cascade'),
)
.addColumn('recipient_id', 'uuid', (col) =>
col.notNull().references('users.id').onDelete('cascade'),
)
.addColumn('actor_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.addColumn('type', 'varchar(50)', (col) => col.notNull())
.addColumn('status', 'varchar(20)', (col) =>
col.notNull().defaultTo('unread'),
)
.addColumn('priority', 'varchar(20)', (col) =>
col.notNull().defaultTo('normal'),
)
.addColumn('entity_type', 'varchar(50)', (col) => col.notNull())
.addColumn('entity_id', 'uuid', (col) => col.notNull())
.addColumn('context', 'jsonb', (col) => col.notNull().defaultTo('{}'))
.addColumn('group_key', 'varchar(255)')
.addColumn('group_count', 'integer', (col) => col.defaultTo(1))
.addColumn('deduplication_key', 'varchar(255)')
.addColumn('batch_id', 'uuid')
.addColumn('is_batched', 'boolean', (col) => col.defaultTo(false))
.addColumn('email_sent_at', 'timestamp')
.addColumn('in_app_delivered_at', 'timestamp')
.addColumn('read_at', 'timestamp')
.addColumn('created_at', 'timestamp', (col) =>
col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`),
)
.addColumn('updated_at', 'timestamp', (col) =>
col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`),
)
.addColumn('expires_at', 'timestamp')
.execute();
// Create indexes for notifications
await db.schema
.createIndex('idx_notifications_recipient_status')
.on('notifications')
.columns(['recipient_id', 'status'])
.execute();
await db.schema
.createIndex('idx_notifications_group_key')
.on('notifications')
.columns(['group_key', 'created_at'])
.execute();
await db.schema
.createIndex('idx_notifications_batch_pending')
.on('notifications')
.columns(['batch_id', 'is_batched'])
.where('is_batched', '=', false)
.execute();
await db.schema
.createIndex('idx_notifications_expires')
.on('notifications')
.column('expires_at')
.where('expires_at', 'is not', null)
.execute();
await db.schema
.createIndex('idx_notifications_deduplication')
.unique()
.on('notifications')
.column('deduplication_key')
.where('deduplication_key', 'is not', null)
.execute();
// Create notification_preferences table
await db.schema
.createTable('notification_preferences')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('user_id', 'uuid', (col) =>
col.notNull().references('users.id').onDelete('cascade'),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.notNull().references('workspaces.id').onDelete('cascade'),
)
.addColumn('email_enabled', 'boolean', (col) =>
col.notNull().defaultTo(true),
)
.addColumn('in_app_enabled', 'boolean', (col) =>
col.notNull().defaultTo(true),
)
.addColumn('notification_settings', 'jsonb', (col) =>
col.notNull().defaultTo(sql`'{
"mention_in_page": {"email": true, "in_app": true, "batch": false},
"mention_in_comment": {"email": true, "in_app": true, "batch": false},
"comment_on_page": {"email": true, "in_app": true, "batch": true},
"reply_to_comment": {"email": true, "in_app": true, "batch": false},
"comment_in_thread": {"email": true, "in_app": true, "batch": true},
"comment_resolved": {"email": true, "in_app": true, "batch": true},
"export_completed": {"email": true, "in_app": true, "batch": false},
"page_shared": {"email": true, "in_app": true, "batch": true},
"task_assigned": {"email": true, "in_app": true, "batch": false}
}'::jsonb`),
)
.addColumn('batch_window_minutes', 'integer', (col) => col.defaultTo(15))
.addColumn('max_batch_size', 'integer', (col) => col.defaultTo(20))
.addColumn('batch_types', sql`text[]`, (col) =>
col.defaultTo(
sql`ARRAY['comment_on_page', 'comment_in_thread', 'comment_resolved']`,
),
)
.addColumn('email_frequency', 'varchar(20)', (col) =>
col.notNull().defaultTo('smart'),
)
.addColumn('digest_time', 'time', (col) => col.defaultTo('09:00:00'))
.addColumn('quiet_hours_enabled', 'boolean', (col) => col.defaultTo(false))
.addColumn('quiet_hours_start', 'time', (col) => col.defaultTo('18:00:00'))
.addColumn('quiet_hours_end', 'time', (col) => col.defaultTo('09:00:00'))
.addColumn('timezone', 'varchar(50)', (col) => col.defaultTo('UTC'))
.addColumn('weekend_notifications', 'boolean', (col) => col.defaultTo(true))
.addColumn('created_at', 'timestamp', (col) =>
col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`),
)
.addColumn('updated_at', 'timestamp', (col) =>
col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`),
)
.execute();
// Create unique index for user_workspace
await db.schema
.createIndex('idx_notification_preferences_user_workspace')
.unique()
.on('notification_preferences')
.columns(['user_id', 'workspace_id'])
.execute();
// Create notification_batches table
await db.schema
.createTable('notification_batches')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('recipient_id', 'uuid', (col) =>
col.notNull().references('users.id').onDelete('cascade'),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.notNull().references('workspaces.id').onDelete('cascade'),
)
.addColumn('batch_type', 'varchar(50)', (col) => col.notNull())
.addColumn('batch_key', 'varchar(255)', (col) => col.notNull())
.addColumn('notification_count', 'integer', (col) =>
col.notNull().defaultTo(0),
)
.addColumn('first_notification_id', 'uuid', (col) =>
col.references('notifications.id').onDelete('set null'),
)
.addColumn('scheduled_for', 'timestamp', (col) => col.notNull())
.addColumn('sent_at', 'timestamp')
.addColumn('created_at', 'timestamp', (col) =>
col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`),
)
.execute();
// Create indexes for notification_batches
await db.schema
.createIndex('idx_notification_batches_scheduled_pending')
.on('notification_batches')
.columns(['scheduled_for', 'sent_at'])
.where('sent_at', 'is', null)
.execute();
await db.schema
.createIndex('idx_notification_batches_batch_key')
.on('notification_batches')
.columns(['batch_key', 'recipient_id'])
.execute();
// Create notification_aggregations table
await db.schema
.createTable('notification_aggregations')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('aggregation_key', 'varchar(255)', (col) => col.notNull())
.addColumn('recipient_id', 'uuid', (col) =>
col.notNull().references('users.id').onDelete('cascade'),
)
.addColumn('aggregation_type', 'varchar(50)', (col) => col.notNull())
.addColumn('entity_type', 'varchar(50)', (col) => col.notNull())
.addColumn('entity_id', 'uuid', (col) => col.notNull())
.addColumn('actor_ids', sql`uuid[]`, (col) =>
col.notNull().defaultTo(sql`'{}'`),
)
.addColumn('notification_ids', sql`uuid[]`, (col) =>
col.notNull().defaultTo(sql`'{}'`),
)
.addColumn('summary_data', 'jsonb', (col) => col.notNull().defaultTo('{}'))
.addColumn('created_at', 'timestamp', (col) =>
col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`),
)
.addColumn('updated_at', 'timestamp', (col) =>
col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`),
)
.execute();
// Create indexes for notification_aggregations
await db.schema
.createIndex('idx_notification_aggregations_key')
.unique()
.on('notification_aggregations')
.column('aggregation_key')
.execute();
await db.schema
.createIndex('idx_notification_aggregations_recipient_updated')
.on('notification_aggregations')
.columns(['recipient_id', 'updated_at'])
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('notification_aggregations').ifExists().execute();
await db.schema.dropTable('notification_batches').ifExists().execute();
await db.schema.dropTable('notification_preferences').ifExists().execute();
await db.schema.dropTable('notifications').ifExists().execute();
}

View File

@ -0,0 +1,125 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import {
NotificationAggregation,
InsertableNotificationAggregation,
UpdatableNotificationAggregation
} from '@docmost/db/types/entity.types';
@Injectable()
export class NotificationAggregationRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
async insertAggregation(aggregation: InsertableNotificationAggregation): Promise<NotificationAggregation> {
return await this.db
.insertInto('notificationAggregations')
.values(aggregation)
.returningAll()
.executeTakeFirstOrThrow();
}
async findById(id: string): Promise<NotificationAggregation | undefined> {
return await this.db
.selectFrom('notificationAggregations')
.selectAll()
.where('id', '=', id)
.executeTakeFirst();
}
async findByKey(aggregationKey: string): Promise<NotificationAggregation | undefined> {
return await this.db
.selectFrom('notificationAggregations')
.selectAll()
.where('aggregationKey', '=', aggregationKey)
.executeTakeFirst();
}
async updateAggregation(
aggregationKey: string,
update: UpdatableNotificationAggregation
): Promise<NotificationAggregation> {
return await this.db
.updateTable('notificationAggregations')
.set({
...update,
updatedAt: new Date(),
})
.where('aggregationKey', '=', aggregationKey)
.returningAll()
.executeTakeFirstOrThrow();
}
async addNotificationToAggregation(
aggregationKey: string,
notificationId: string,
actorId?: string
): Promise<void> {
const aggregation = await this.findByKey(aggregationKey);
if (!aggregation) {
throw new Error(`Aggregation not found: ${aggregationKey}`);
}
const updates: UpdatableNotificationAggregation = {
notificationIds: [...aggregation.notificationIds, notificationId],
updatedAt: new Date(),
};
if (actorId && !aggregation.actorIds.includes(actorId)) {
updates.actorIds = [...aggregation.actorIds, actorId];
}
// Update summary data
updates.summaryData = {
...(aggregation.summaryData as Record<string, any> || {}),
totalCount: aggregation.notificationIds.length + 1,
actorCount: updates.actorIds?.length || aggregation.actorIds.length,
timeSpan: {
...((aggregation.summaryData as any).timeSpan || {}),
end: new Date(),
},
};
await this.updateAggregation(aggregationKey, updates);
}
async findRecentByRecipient(
recipientId: string,
limit = 10
): Promise<NotificationAggregation[]> {
return await this.db
.selectFrom('notificationAggregations')
.selectAll()
.where('recipientId', '=', recipientId)
.orderBy('updatedAt', 'desc')
.limit(limit)
.execute();
}
async deleteOldAggregations(olderThan: Date): Promise<number> {
const result = await this.db
.deleteFrom('notificationAggregations')
.where('updatedAt', '<', olderThan)
.execute();
return Number(result[0].numDeletedRows);
}
async getAggregationsByEntity(
entityType: string,
entityId: string,
recipientId?: string
): Promise<NotificationAggregation[]> {
let query = this.db
.selectFrom('notificationAggregations')
.selectAll()
.where('entityType', '=', entityType)
.where('entityId', '=', entityId);
if (recipientId) {
query = query.where('recipientId', '=', recipientId);
}
return await query.orderBy('updatedAt', 'desc').execute();
}
}

View File

@ -0,0 +1,110 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import {
NotificationBatch,
InsertableNotificationBatch,
UpdatableNotificationBatch
} from '@docmost/db/types/entity.types';
@Injectable()
export class NotificationBatchRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
async insertBatch(batch: InsertableNotificationBatch): Promise<NotificationBatch> {
return await this.db
.insertInto('notificationBatches')
.values(batch)
.returningAll()
.executeTakeFirstOrThrow();
}
async findById(id: string): Promise<NotificationBatch | undefined> {
return await this.db
.selectFrom('notificationBatches')
.selectAll()
.where('id', '=', id)
.executeTakeFirst();
}
async findByBatchKey(
batchKey: string,
recipientId: string,
notSentOnly = true
): Promise<NotificationBatch | undefined> {
let query = this.db
.selectFrom('notificationBatches')
.selectAll()
.where('batchKey', '=', batchKey)
.where('recipientId', '=', recipientId);
if (notSentOnly) {
query = query.where('sentAt', 'is', null);
}
return await query.executeTakeFirst();
}
async getPendingBatches(limit = 100): Promise<NotificationBatch[]> {
return await this.db
.selectFrom('notificationBatches')
.selectAll()
.where('sentAt', 'is', null)
.where('scheduledFor', '<=', new Date())
.orderBy('scheduledFor', 'asc')
.limit(limit)
.execute();
}
async updateBatch(id: string, update: UpdatableNotificationBatch): Promise<NotificationBatch> {
return await this.db
.updateTable('notificationBatches')
.set(update)
.where('id', '=', id)
.returningAll()
.executeTakeFirstOrThrow();
}
async markAsSent(id: string): Promise<void> {
await this.db
.updateTable('notificationBatches')
.set({
sentAt: new Date(),
})
.where('id', '=', id)
.execute();
}
async incrementNotificationCount(id: string): Promise<void> {
await this.db
.updateTable('notificationBatches')
.set((eb) => ({
notificationCount: eb('notificationCount', '+', 1),
}))
.where('id', '=', id)
.execute();
}
async deleteOldBatches(olderThan: Date): Promise<number> {
const result = await this.db
.deleteFrom('notificationBatches')
.where('sentAt', '<', olderThan)
.execute();
return Number(result[0].numDeletedRows);
}
async getScheduledBatchesForUser(
recipientId: string,
workspaceId: string
): Promise<NotificationBatch[]> {
return await this.db
.selectFrom('notificationBatches')
.selectAll()
.where('recipientId', '=', recipientId)
.where('workspaceId', '=', workspaceId)
.where('sentAt', 'is', null)
.orderBy('scheduledFor', 'asc')
.execute();
}
}

View File

@ -0,0 +1,134 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { sql } from 'kysely';
import {
NotificationPreference,
InsertableNotificationPreference,
UpdatableNotificationPreference
} from '@docmost/db/types/entity.types';
export const DEFAULT_NOTIFICATION_SETTINGS = {
mention_in_page: { email: true, in_app: true, batch: false },
mention_in_comment: { email: true, in_app: true, batch: false },
comment_on_page: { email: true, in_app: true, batch: true },
reply_to_comment: { email: true, in_app: true, batch: false },
comment_in_thread: { email: true, in_app: true, batch: true },
comment_resolved: { email: true, in_app: true, batch: true },
export_completed: { email: true, in_app: true, batch: false },
export_failed: { email: true, in_app: true, batch: false },
page_shared: { email: true, in_app: true, batch: true },
page_updated: { email: false, in_app: true, batch: true },
task_assigned: { email: true, in_app: true, batch: false },
task_due_soon: { email: true, in_app: true, batch: false },
};
@Injectable()
export class NotificationPreferenceRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
async insertPreference(preference: InsertableNotificationPreference): Promise<NotificationPreference> {
return await this.db
.insertInto('notificationPreferences')
.values(preference)
.returningAll()
.executeTakeFirstOrThrow();
}
async findByUserAndWorkspace(
userId: string,
workspaceId: string
): Promise<NotificationPreference | undefined> {
return await this.db
.selectFrom('notificationPreferences')
.selectAll()
.where('userId', '=', userId)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
}
async findOrCreate(
userId: string,
workspaceId: string
): Promise<NotificationPreference> {
const existing = await this.findByUserAndWorkspace(userId, workspaceId);
if (existing) {
return existing;
}
return await this.insertPreference({
userId: userId,
workspaceId: workspaceId,
emailEnabled: true,
inAppEnabled: true,
notificationSettings: DEFAULT_NOTIFICATION_SETTINGS,
batchWindowMinutes: 15,
maxBatchSize: 20,
batchTypes: ['comment_on_page', 'comment_in_thread', 'comment_resolved'],
emailFrequency: 'smart',
digestTime: '09:00:00',
quietHoursEnabled: false,
quietHoursStart: '18:00:00',
quietHoursEnd: '09:00:00',
timezone: 'UTC',
weekendNotifications: true,
});
}
async updatePreference(
userId: string,
workspaceId: string,
update: UpdatableNotificationPreference
): Promise<NotificationPreference> {
return await this.db
.updateTable('notificationPreferences')
.set({
...update,
updatedAt: new Date(),
})
.where('userId', '=', userId)
.where('workspaceId', '=', workspaceId)
.returningAll()
.executeTakeFirstOrThrow();
}
async findUsersWithBatchingEnabled(
workspaceId: string,
notificationType: string
): Promise<NotificationPreference[]> {
return await this.db
.selectFrom('notificationPreferences')
.selectAll()
.where('workspaceId', '=', workspaceId)
.where('emailEnabled', '=', true)
.where('emailFrequency', '!=', 'instant')
.where(
sql<boolean>`notification_settings::jsonb->'${sql.raw(notificationType)}'->>'batch' = 'true'`
)
.execute();
}
async findUsersForDigest(
workspaceId: string,
currentTime: string,
timezone: string
): Promise<NotificationPreference[]> {
return await this.db
.selectFrom('notificationPreferences')
.selectAll()
.where('workspaceId', '=', workspaceId)
.where('emailFrequency', '=', 'daily')
.where('digestTime', '=', currentTime)
.where('timezone', '=', timezone)
.where('emailEnabled', '=', true)
.execute();
}
async deletePreference(userId: string, workspaceId: string): Promise<void> {
await this.db
.deleteFrom('notificationPreferences')
.where('userId', '=', userId)
.where('workspaceId', '=', workspaceId)
.execute();
}
}

View File

@ -0,0 +1,175 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { Notification, InsertableNotification, UpdatableNotification } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
@Injectable()
export class NotificationRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
async insertNotification(notification: InsertableNotification): Promise<Notification> {
return await this.db
.insertInto('notifications')
.values(notification)
.returningAll()
.executeTakeFirstOrThrow();
}
async findById(id: string): Promise<Notification | undefined> {
return await this.db
.selectFrom('notifications')
.selectAll()
.where('id', '=', id)
.executeTakeFirst();
}
async findByRecipient(
recipientId: string,
options: {
status?: string;
limit?: number;
offset?: number;
} = {}
): Promise<Notification[]> {
let query = this.db
.selectFrom('notifications')
.selectAll()
.where('recipientId', '=', recipientId)
.orderBy('createdAt', 'desc');
if (options.status) {
query = query.where('status', '=', options.status);
}
if (options.limit) {
query = query.limit(options.limit);
}
if (options.offset) {
query = query.offset(options.offset);
}
return await query.execute();
}
async findByBatchId(batchId: string): Promise<Notification[]> {
return await this.db
.selectFrom('notifications')
.selectAll()
.where('batchId', '=', batchId)
.orderBy('createdAt', 'desc')
.execute();
}
async findRecent(params: {
recipientId: string;
type: string;
entityId: string;
since: Date;
}): Promise<Notification[]> {
return await this.db
.selectFrom('notifications')
.selectAll()
.where('recipientId', '=', params.recipientId)
.where('type', '=', params.type)
.where('entityId', '=', params.entityId)
.where('createdAt', '>', params.since)
.orderBy('createdAt', 'desc')
.execute();
}
async existsByDeduplicationKey(key: string): Promise<boolean> {
const result = await this.db
.selectFrom('notifications')
.select(['id'])
.where('deduplicationKey', '=', key)
.executeTakeFirst();
return !!result;
}
async updateNotification(id: string, update: UpdatableNotification): Promise<Notification> {
return await this.db
.updateTable('notifications')
.set(update)
.where('id', '=', id)
.returningAll()
.executeTakeFirstOrThrow();
}
async markAsRead(id: string): Promise<void> {
await this.db
.updateTable('notifications')
.set({
status: 'read',
readAt: new Date(),
updatedAt: new Date(),
})
.where('id', '=', id)
.execute();
}
async markManyAsRead(ids: string[]): Promise<void> {
if (ids.length === 0) return;
await this.db
.updateTable('notifications')
.set({
status: 'read',
readAt: new Date(),
updatedAt: new Date(),
})
.where('id', 'in', ids)
.execute();
}
async getUnreadCount(recipientId: string): Promise<number> {
const result = await this.db
.selectFrom('notifications')
.select((eb) => eb.fn.countAll<number>().as('count'))
.where('recipientId', '=', recipientId)
.where('status', '=', 'unread')
.executeTakeFirst();
return result?.count || 0;
}
async deleteExpired(): Promise<number> {
const result = await this.db
.deleteFrom('notifications')
.where('expiresAt', '<', new Date())
.execute();
return Number(result[0].numDeletedRows);
}
async getNotificationsByGroupKey(
groupKey: string,
recipientId: string,
since: Date
): Promise<Notification[]> {
return await this.db
.selectFrom('notifications')
.selectAll()
.where('groupKey', '=', groupKey)
.where('recipientId', '=', recipientId)
.where('createdAt', '>', since)
.orderBy('createdAt', 'desc')
.execute();
}
async updateBatchId(notificationIds: string[], batchId: string): Promise<void> {
if (notificationIds.length === 0) return;
await this.db
.updateTable('notifications')
.set({
batchId: batchId,
isBatched: true,
updatedAt: new Date(),
})
.where('id', 'in', notificationIds)
.execute();
}
}

View File

@ -62,6 +62,7 @@ export interface AuthProviders {
deletedAt: Timestamp | null;
id: Generated<string>;
isEnabled: Generated<boolean>;
isGroupSyncEnabled: Generated<boolean>;
name: string;
oidcClientId: string | null;
oidcClientSecret: string | null;
@ -122,6 +123,7 @@ export interface Comments {
pageId: string;
parentCommentId: string | null;
resolvedAt: Timestamp | null;
resolvedById: string | null;
selection: string | null;
type: string | null;
workspaceId: string;
@ -165,6 +167,78 @@ export interface GroupUsers {
userId: string;
}
export interface NotificationAggregations {
actorIds: Generated<string[]>;
aggregationKey: string;
aggregationType: string;
createdAt: Generated<Timestamp>;
entityId: string;
entityType: string;
id: Generated<string>;
notificationIds: Generated<string[]>;
recipientId: string;
summaryData: Generated<Json>;
updatedAt: Generated<Timestamp>;
}
export interface NotificationBatches {
batchKey: string;
batchType: string;
createdAt: Generated<Timestamp>;
firstNotificationId: string | null;
id: Generated<string>;
notificationCount: Generated<number>;
recipientId: string;
scheduledFor: Timestamp;
sentAt: Timestamp | null;
workspaceId: string;
}
export interface NotificationPreferences {
batchTypes: Generated<string[] | null>;
batchWindowMinutes: Generated<number | null>;
createdAt: Generated<Timestamp>;
digestTime: Generated<string | null>;
emailEnabled: Generated<boolean>;
emailFrequency: Generated<string>;
id: Generated<string>;
inAppEnabled: Generated<boolean>;
maxBatchSize: Generated<number | null>;
notificationSettings: Generated<Json>;
quietHoursEnabled: Generated<boolean | null>;
quietHoursEnd: Generated<string | null>;
quietHoursStart: Generated<string | null>;
timezone: Generated<string | null>;
updatedAt: Generated<Timestamp>;
userId: string;
weekendNotifications: Generated<boolean | null>;
workspaceId: string;
}
export interface Notifications {
actorId: string | null;
batchId: string | null;
context: Generated<Json>;
createdAt: Generated<Timestamp>;
deduplicationKey: string | null;
emailSentAt: Timestamp | null;
entityId: string;
entityType: string;
expiresAt: Timestamp | null;
groupCount: Generated<number | null>;
groupKey: string | null;
id: Generated<string>;
inAppDeliveredAt: Timestamp | null;
isBatched: Generated<boolean | null>;
priority: Generated<string>;
readAt: Timestamp | null;
recipientId: string;
status: Generated<string>;
type: string;
updatedAt: Generated<Timestamp>;
workspaceId: string;
}
export interface PageHistory {
content: Json | null;
coverPhoto: string | null;
@ -324,6 +398,10 @@ export interface DB {
fileTasks: FileTasks;
groups: Groups;
groupUsers: GroupUsers;
notificationAggregations: NotificationAggregations;
notificationBatches: NotificationBatches;
notificationPreferences: NotificationPreferences;
notifications: Notifications;
pageHistory: PageHistory;
pages: Pages;
shares: Shares;

View File

@ -18,6 +18,10 @@ import {
AuthAccounts,
Shares,
FileTasks,
Notifications,
NotificationPreferences,
NotificationBatches,
NotificationAggregations,
} from './db';
// Workspace
@ -113,3 +117,23 @@ export type UpdatableShare = Updateable<Omit<Shares, 'id'>>;
export type FileTask = Selectable<FileTasks>;
export type InsertableFileTask = Insertable<FileTasks>;
export type UpdatableFileTask = Updateable<Omit<FileTasks, 'id'>>;
// Notification
export type Notification = Selectable<Notifications>;
export type InsertableNotification = Insertable<Notifications>;
export type UpdatableNotification = Updateable<Omit<Notifications, 'id'>>;
// NotificationPreference
export type NotificationPreference = Selectable<NotificationPreferences>;
export type InsertableNotificationPreference = Insertable<NotificationPreferences>;
export type UpdatableNotificationPreference = Updateable<Omit<NotificationPreferences, 'id'>>;
// NotificationBatch
export type NotificationBatch = Selectable<NotificationBatches>;
export type InsertableNotificationBatch = Insertable<NotificationBatches>;
export type UpdatableNotificationBatch = Updateable<Omit<NotificationBatches, 'id'>>;
// NotificationAggregation
export type NotificationAggregation = Selectable<NotificationAggregations>;
export type InsertableNotificationAggregation = Insertable<NotificationAggregations>;
export type UpdatableNotificationAggregation = Updateable<Omit<NotificationAggregations, 'id'>>;

View File

@ -205,4 +205,12 @@ export class EnvironmentService {
.toLowerCase();
return disable === 'true';
}
getPostHogHost(): string {
return this.configService.get<string>('POSTHOG_HOST');
}
getPostHogKey(): string {
return this.configService.get<string>('POSTHOG_KEY');
}
}

View File

@ -47,6 +47,8 @@ export class StaticModule implements OnModuleInit {
BILLING_TRIAL_DAYS: this.environmentService.isCloud()
? this.environmentService.getBillingTrialDays()
: undefined,
POSTHOG_HOST: this.environmentService.getPostHogHost(),
POSTHOG_KEY: this.environmentService.getPostHogKey(),
};
const windowScriptContent = `<script>window.CONFIG=${JSON.stringify(configString)};</script>`;

View File

@ -19,6 +19,10 @@ import * as cookie from 'cookie';
export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
@WebSocketServer()
server: Server;
// Map to track which sockets belong to which users
private userSocketMap = new Map<string, Set<string>>();
constructor(
private tokenService: TokenService,
private spaceMemberRepo: SpaceMemberRepo,
@ -26,7 +30,7 @@ export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
async handleConnection(client: Socket, ...args: any[]): Promise<void> {
try {
const cookies = cookie.parse(client.handshake.headers.cookie);
const cookies = cookie.parse(client.handshake.headers.cookie || '');
const token: JwtPayload = await this.tokenService.verifyJwt(
cookies['authToken'],
JwtType.ACCESS,
@ -35,18 +39,41 @@ export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
const userId = token.sub;
const workspaceId = token.workspaceId;
// Store user-socket mapping
if (!this.userSocketMap.has(userId)) {
this.userSocketMap.set(userId, new Set());
}
this.userSocketMap.get(userId)!.add(client.id);
// Store user info on socket for later use
(client as any).userId = userId;
(client as any).workspaceId = workspaceId;
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
const workspaceRoom = `workspace-${workspaceId}`;
const userRoom = `user-${userId}`;
const spaceRooms = userSpaceIds.map((id) => this.getSpaceRoomName(id));
client.join([workspaceRoom, ...spaceRooms]);
client.join([workspaceRoom, userRoom, ...spaceRooms]);
} catch (err) {
client.emit('Unauthorized');
client.disconnect();
}
}
handleDisconnect(client: Socket): void {
// Clean up user-socket mapping
const userId = (client as any).userId;
if (userId && this.userSocketMap.has(userId)) {
const userSockets = this.userSocketMap.get(userId)!;
userSockets.delete(client.id);
if (userSockets.size === 0) {
this.userSocketMap.delete(userId);
}
}
}
@SubscribeMessage('message')
handleMessage(client: Socket, data: any): void {
const spaceEvents = [
@ -85,4 +112,35 @@ export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
getSpaceRoomName(spaceId: string): string {
return `space-${spaceId}`;
}
}
/**
* Emit an event to a specific user
*/
emitToUser(userId: string, event: string, data: any): void {
const userRoom = `user-${userId}`;
this.server.to(userRoom).emit(event, data);
}
/**
* Emit an event to a workspace
*/
emitToWorkspace(workspaceId: string, event: string, data: any): void {
const workspaceRoom = `workspace-${workspaceId}`;
this.server.to(workspaceRoom).emit(event, data);
}
/**
* Emit an event to a space
*/
emitToSpace(spaceId: string, event: string, data: any): void {
const spaceRoom = this.getSpaceRoomName(spaceId);
this.server.to(spaceRoom).emit(event, data);
}
/**
* Check if a user is currently connected
*/
isUserConnected(userId: string): boolean {
return this.userSocketMap.has(userId) && this.userSocketMap.get(userId)!.size > 0;
}
}

View File

@ -5,5 +5,6 @@ import { TokenModule } from '../core/auth/token.module';
@Module({
imports: [TokenModule],
providers: [WsGateway],
exports: [WsGateway],
})
export class WsModule {}

View File

@ -17,4 +17,5 @@ export * from "./lib/excalidraw";
export * from "./lib/embed";
export * from "./lib/mention";
export * from "./lib/markdown";
export * from "./lib/search-and-replace";
export * from "./lib/embed-provider";

View File

@ -35,6 +35,42 @@ export const CustomCodeBlock = CodeBlockLowlight.extend<CustomCodeBlockOptions>(
return true;
}
},
"Mod-a": () => {
if (this.editor.isActive("codeBlock")) {
const { state } = this.editor;
const { $from } = state.selection;
let codeBlockNode = null;
let codeBlockPos = null;
let depth = 0;
for (depth = $from.depth; depth > 0; depth--) {
const node = $from.node(depth);
if (node.type.name === "codeBlock") {
codeBlockNode = node;
codeBlockPos = $from.start(depth) - 1;
break;
}
}
if (codeBlockNode && codeBlockPos !== null) {
const codeBlockStart = codeBlockPos;
const codeBlockEnd = codeBlockPos + codeBlockNode.nodeSize;
const contentStart = codeBlockStart + 1;
const contentEnd = codeBlockEnd - 1;
this.editor.commands.setTextSelection({
from: contentStart,
to: contentEnd,
});
return true;
}
}
return false;
},
};
},

View File

@ -0,0 +1,3 @@
import { SearchAndReplace } from './search-and-replace'
export * from './search-and-replace'
export default SearchAndReplace

View File

@ -0,0 +1,455 @@
/***
MIT License
Copyright (c) 2023 - 2024 Jeet Mandaliya (Github Username: sereneinserenade)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
***/
import { Extension, Range, type Dispatch } from "@tiptap/core";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import {
Plugin,
PluginKey,
type EditorState,
type Transaction,
} from "@tiptap/pm/state";
import { Node as PMNode, Mark } from "@tiptap/pm/model";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
search: {
/**
* @description Set search term in extension.
*/
setSearchTerm: (searchTerm: string) => ReturnType;
/**
* @description Set replace term in extension.
*/
setReplaceTerm: (replaceTerm: string) => ReturnType;
/**
* @description Set case sensitivity in extension.
*/
setCaseSensitive: (caseSensitive: boolean) => ReturnType;
/**
* @description Reset current search result to first instance.
*/
resetIndex: () => ReturnType;
/**
* @description Find next instance of search result.
*/
nextSearchResult: () => ReturnType;
/**
* @description Find previous instance of search result.
*/
previousSearchResult: () => ReturnType;
/**
* @description Replace first instance of search result with given replace term.
*/
replace: () => ReturnType;
/**
* @description Replace all instances of search result with given replace term.
*/
replaceAll: () => ReturnType;
/**
* @description Find selected instance of search result.
*/
selectCurrentItem: () => ReturnType;
};
}
}
interface TextNodesWithPosition {
text: string;
pos: number;
}
const getRegex = (
s: string,
disableRegex: boolean,
caseSensitive: boolean,
): RegExp => {
return RegExp(
disableRegex ? s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : s,
caseSensitive ? "gu" : "gui",
);
};
interface ProcessedSearches {
decorationsToReturn: DecorationSet;
results: Range[];
}
function processSearches(
doc: PMNode,
searchTerm: RegExp,
searchResultClass: string,
resultIndex: number,
): ProcessedSearches {
const decorations: Decoration[] = [];
const results: Range[] = [];
let textNodesWithPosition: TextNodesWithPosition[] = [];
let index = 0;
if (!searchTerm) {
return {
decorationsToReturn: DecorationSet.empty,
results: [],
};
}
doc?.descendants((node, pos) => {
if (node.isText) {
if (textNodesWithPosition[index]) {
textNodesWithPosition[index] = {
text: textNodesWithPosition[index].text + node.text,
pos: textNodesWithPosition[index].pos,
};
} else {
textNodesWithPosition[index] = {
text: `${node.text}`,
pos,
};
}
} else {
index += 1;
}
});
textNodesWithPosition = textNodesWithPosition.filter(Boolean);
for (const element of textNodesWithPosition) {
const { text, pos } = element;
const matches = Array.from(text.matchAll(searchTerm)).filter(
([matchText]) => matchText.trim(),
);
for (const m of matches) {
if (m[0] === "") break;
if (m.index !== undefined) {
results.push({
from: pos + m.index,
to: pos + m.index + m[0].length,
});
}
}
}
for (let i = 0; i < results.length; i += 1) {
const r = results[i];
const className =
i === resultIndex
? `${searchResultClass} ${searchResultClass}-current`
: searchResultClass;
const decoration: Decoration = Decoration.inline(r.from, r.to, {
class: className,
});
decorations.push(decoration);
}
return {
decorationsToReturn: DecorationSet.create(doc, decorations),
results,
};
}
const replace = (
replaceTerm: string,
results: Range[],
resultIndex: number,
{ state, dispatch }: { state: EditorState; dispatch: Dispatch },
) => {
const firstResult = results[resultIndex];
if (!firstResult) return;
const { from, to } = results[resultIndex];
if (dispatch) {
const tr = state.tr;
// Get all marks that span the text being replaced
const marksSet = new Set<Mark>();
state.doc.nodesBetween(from, to, (node) => {
if (node.isText && node.marks) {
node.marks.forEach(mark => marksSet.add(mark));
}
});
const marks = Array.from(marksSet);
// Delete the old text and insert new text with preserved marks
tr.delete(from, to);
tr.insert(from, state.schema.text(replaceTerm, marks));
dispatch(tr);
}
};
const replaceAll = (
replaceTerm: string,
results: Range[],
{ tr, dispatch }: { tr: Transaction; dispatch: Dispatch },
) => {
const resultsCopy = results.slice();
if (!resultsCopy.length) return;
// Process replacements in reverse order to avoid position shifting issues
for (let i = resultsCopy.length - 1; i >= 0; i -= 1) {
const { from, to } = resultsCopy[i];
// Get all marks that span the text being replaced
const marksSet = new Set<Mark>();
tr.doc.nodesBetween(from, to, (node) => {
if (node.isText && node.marks) {
node.marks.forEach(mark => marksSet.add(mark));
}
});
const marks = Array.from(marksSet);
// Delete and insert with preserved marks
tr.delete(from, to);
tr.insert(from, tr.doc.type.schema.text(replaceTerm, marks));
}
dispatch(tr);
};
export const searchAndReplacePluginKey = new PluginKey(
"searchAndReplacePlugin",
);
export interface SearchAndReplaceOptions {
searchResultClass: string;
disableRegex: boolean;
}
export interface SearchAndReplaceStorage {
searchTerm: string;
replaceTerm: string;
results: Range[];
lastSearchTerm: string;
caseSensitive: boolean;
lastCaseSensitive: boolean;
resultIndex: number;
lastResultIndex: number;
}
export const SearchAndReplace = Extension.create<
SearchAndReplaceOptions,
SearchAndReplaceStorage
>({
name: "searchAndReplace",
addOptions() {
return {
searchResultClass: "search-result",
disableRegex: true,
};
},
addStorage() {
return {
searchTerm: "",
replaceTerm: "",
results: [],
lastSearchTerm: "",
caseSensitive: false,
lastCaseSensitive: false,
resultIndex: 0,
lastResultIndex: 0,
};
},
addCommands() {
return {
setSearchTerm:
(searchTerm: string) =>
({ editor }) => {
editor.storage.searchAndReplace.searchTerm = searchTerm;
return false;
},
setReplaceTerm:
(replaceTerm: string) =>
({ editor }) => {
editor.storage.searchAndReplace.replaceTerm = replaceTerm;
return false;
},
setCaseSensitive:
(caseSensitive: boolean) =>
({ editor }) => {
editor.storage.searchAndReplace.caseSensitive = caseSensitive;
return false;
},
resetIndex:
() =>
({ editor }) => {
editor.storage.searchAndReplace.resultIndex = 0;
return false;
},
nextSearchResult:
() =>
({ editor }) => {
const { results, resultIndex } = editor.storage.searchAndReplace;
const nextIndex = resultIndex + 1;
if (results[nextIndex]) {
editor.storage.searchAndReplace.resultIndex = nextIndex;
} else {
editor.storage.searchAndReplace.resultIndex = 0;
}
return false;
},
previousSearchResult:
() =>
({ editor }) => {
const { results, resultIndex } = editor.storage.searchAndReplace;
const prevIndex = resultIndex - 1;
if (results[prevIndex]) {
editor.storage.searchAndReplace.resultIndex = prevIndex;
} else {
editor.storage.searchAndReplace.resultIndex = results.length - 1;
}
return false;
},
replace:
() =>
({ editor, state, dispatch }) => {
const { replaceTerm, results, resultIndex } =
editor.storage.searchAndReplace;
replace(replaceTerm, results, resultIndex, { state, dispatch });
// After replace, adjust index if needed
// The results will be recalculated by the plugin, but we need to ensure
// the index doesn't exceed the new bounds
setTimeout(() => {
const newResultsLength = editor.storage.searchAndReplace.results.length;
if (newResultsLength > 0 && editor.storage.searchAndReplace.resultIndex >= newResultsLength) {
// Keep the same position if possible, otherwise go to the last result
editor.storage.searchAndReplace.resultIndex = Math.min(resultIndex, newResultsLength - 1);
}
}, 0);
return false;
},
replaceAll:
() =>
({ editor, tr, dispatch }) => {
const { replaceTerm, results } = editor.storage.searchAndReplace;
replaceAll(replaceTerm, results, { tr, dispatch });
return false;
},
selectCurrentItem:
() =>
({ editor }) => {
const { results } = editor.storage.searchAndReplace;
for (let i = 0; i < results.length; i++) {
if (
results[i].from == editor.state.selection.from &&
results[i].to == editor.state.selection.to
) {
editor.storage.searchAndReplace.resultIndex = i;
}
}
return false;
},
};
},
addProseMirrorPlugins() {
const editor = this.editor;
const { searchResultClass, disableRegex } = this.options;
const setLastSearchTerm = (t: string) =>
(editor.storage.searchAndReplace.lastSearchTerm = t);
const setLastCaseSensitive = (t: boolean) =>
(editor.storage.searchAndReplace.lastCaseSensitive = t);
const setLastResultIndex = (t: number) =>
(editor.storage.searchAndReplace.lastResultIndex = t);
return [
new Plugin({
key: searchAndReplacePluginKey,
state: {
init: () => DecorationSet.empty,
apply({ doc, docChanged }, oldState) {
const {
searchTerm,
lastSearchTerm,
caseSensitive,
lastCaseSensitive,
resultIndex,
lastResultIndex,
} = editor.storage.searchAndReplace;
if (
!docChanged &&
lastSearchTerm === searchTerm &&
lastCaseSensitive === caseSensitive &&
lastResultIndex === resultIndex
)
return oldState;
setLastSearchTerm(searchTerm);
setLastCaseSensitive(caseSensitive);
setLastResultIndex(resultIndex);
if (!searchTerm) {
editor.storage.searchAndReplace.results = [];
return DecorationSet.empty;
}
const { decorationsToReturn, results } = processSearches(
doc,
getRegex(searchTerm, disableRegex, caseSensitive),
searchResultClass,
resultIndex,
);
editor.storage.searchAndReplace.results = results;
return decorationsToReturn;
},
},
props: {
decorations(state) {
return this.getState(state);
},
},
}),
];
},
});
export default SearchAndReplace;

41
pnpm-lock.yaml generated
View File

@ -296,6 +296,9 @@ importers:
mitt:
specifier: ^3.0.1
version: 3.0.1
posthog-js:
specifier: ^1.255.1
version: 1.255.1
react:
specifier: ^18.3.1
version: 18.3.1
@ -5213,6 +5216,9 @@ packages:
core-js-compat@3.35.0:
resolution: {integrity: sha512-5blwFAddknKeNgsjBzilkdQ0+YK8L1PfqPYq40NOYMYFSS38qj+hpTcLLWwpIwA2A5bje/x5jmVn2tzUMg9IVw==}
core-js@3.43.0:
resolution: {integrity: sha512-N6wEbTTZSYOY2rYAn85CuvWWkCK6QweMn7/4Nr3w+gDBeBhk/x4EJeY6FPo4QzDoJZxVTv8U7CMvgWk6pOHHqA==}
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
@ -5988,6 +5994,9 @@ packages:
picomatch:
optional: true
fflate@0.4.8:
resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==}
fflate@0.8.2:
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
@ -7955,9 +7964,23 @@ packages:
postgres-range@1.1.4:
resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==}
posthog-js@1.255.1:
resolution: {integrity: sha512-KMh0o9MhORhEZVjXpktXB5rJ8PfDk+poqBoTSoLzWgNjhJf6D8jcyB9jUMA6vVPfn4YeepVX5NuclDRqOwr5Mw==}
peerDependencies:
'@rrweb/types': 2.0.0-alpha.17
rrweb-snapshot: 2.0.0-alpha.17
peerDependenciesMeta:
'@rrweb/types':
optional: true
rrweb-snapshot:
optional: true
postmark@4.0.5:
resolution: {integrity: sha512-nerZdd3TwOH4CgGboZnlUM/q7oZk0EqpZgJL+Y3Nup8kHeaukxouQ6JcFF3EJEijc4QbuNv1TefGhboAKtf/SQ==}
preact@10.26.9:
resolution: {integrity: sha512-SSjF9vcnF27mJK1XyFMNJzFd5u3pQiATFqoaDy03XuN00u4ziveVVEGt5RKJrDR8MHE/wJo9Nnad56RLzS2RMA==}
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@ -9297,6 +9320,9 @@ packages:
wcwidth@1.0.1:
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
web-vitals@4.2.4:
resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==}
web-worker@1.5.0:
resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==}
@ -15194,6 +15220,8 @@ snapshots:
dependencies:
browserslist: 4.24.2
core-js@3.43.0: {}
core-util-is@1.0.3: {}
cors@2.8.5:
@ -16181,6 +16209,8 @@ snapshots:
optionalDependencies:
picomatch: 4.0.2
fflate@0.4.8: {}
fflate@0.8.2: {}
figures@3.2.0:
@ -18482,12 +18512,21 @@ snapshots:
postgres-range@1.1.4: {}
posthog-js@1.255.1:
dependencies:
core-js: 3.43.0
fflate: 0.4.8
preact: 10.26.9
web-vitals: 4.2.4
postmark@4.0.5:
dependencies:
axios: 1.9.0
transitivePeerDependencies:
- debug
preact@10.26.9: {}
prelude-ls@1.2.1: {}
prettier@3.4.1: {}
@ -19911,6 +19950,8 @@ snapshots:
dependencies:
defaults: 1.0.4
web-vitals@4.2.4: {}
web-worker@1.5.0: {}
webidl-conversions@3.0.1: {}