mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
feat: notifications (#1947)
* feat: notifications * feat: watchers * improvements * handle page move for watchers * make watchers non-blocking * more
This commit is contained in:
@@ -590,5 +590,21 @@
|
||||
"No answer available": "No answer available",
|
||||
"Background color": "Background color",
|
||||
"Highlight color": "Highlight color",
|
||||
"Remove color": "Remove color"
|
||||
"Remove color": "Remove color",
|
||||
"Notifications": "Notifications",
|
||||
"No notifications": "No notifications",
|
||||
"No unread notifications": "No unread notifications",
|
||||
"All notifications": "All notifications",
|
||||
"Unread only": "Unread only",
|
||||
"Mark all as read": "Mark all as read",
|
||||
"Mark as read": "Mark as read",
|
||||
"More options": "More options",
|
||||
"mentioned you in a comment": "mentioned you in a comment",
|
||||
"commented on a page": "commented on a page",
|
||||
"resolved a comment": "resolved a comment",
|
||||
"mentioned you on a page": "mentioned you on a page",
|
||||
"Today": "Today",
|
||||
"Yesterday": "Yesterday",
|
||||
"This week": "This week",
|
||||
"Older": "Older"
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
searchSpotlight,
|
||||
shareSearchSpotlight,
|
||||
} from "@/features/search/constants.ts";
|
||||
import { NotificationPopover } from "@/features/notification/components/notification-popover.tsx";
|
||||
|
||||
const links = [{ link: APP_ROUTE.HOME, label: "Home" }];
|
||||
|
||||
@@ -97,6 +98,7 @@ export function AppHeader() {
|
||||
</div>
|
||||
|
||||
<Group px={"xl"} wrap="nowrap">
|
||||
<NotificationPopover />
|
||||
{isCloud() && isTrial && trialDaysLeft !== 0 && (
|
||||
<Badge
|
||||
variant="light"
|
||||
|
||||
@@ -31,6 +31,7 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const [, setAsideState] = useAtom(asideStateAtom);
|
||||
const useClickOutsideRef = useClickOutside(() => {
|
||||
if (document.querySelector("#mention")) return;
|
||||
handleDialogClose();
|
||||
});
|
||||
const createCommentMutation = useCreateCommentMutation();
|
||||
@@ -105,6 +106,7 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
||||
position={{ bottom: 500, right: 50 }}
|
||||
withCloseButton
|
||||
withBorder
|
||||
data-comment-dialog
|
||||
>
|
||||
<Stack gap={2}>
|
||||
<Group>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { EditorContent, useEditor } from "@tiptap/react";
|
||||
import { EditorContent, ReactNodeViewRenderer, useEditor } from "@tiptap/react";
|
||||
import { Placeholder } from "@tiptap/extension-placeholder";
|
||||
import { Underline } from "@tiptap/extension-underline";
|
||||
import { Link } from "@tiptap/extension-link";
|
||||
import { StarterKit } from "@tiptap/starter-kit";
|
||||
import { Mention, LinkExtension } from "@docmost/editor-ext";
|
||||
import classes from "./comment.module.css";
|
||||
import { useFocusWithin } from "@mantine/hooks";
|
||||
import clsx from "clsx";
|
||||
import { forwardRef, useEffect, useImperativeHandle } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import EmojiCommand from "@/features/editor/extensions/emoji-command";
|
||||
import mentionRenderItems from "@/features/editor/components/mention/mention-suggestion";
|
||||
import MentionView from "@/features/editor/components/mention/mention-view";
|
||||
|
||||
interface CommentEditorProps {
|
||||
defaultContent?: any;
|
||||
@@ -39,13 +40,29 @@ const CommentEditor = forwardRef(
|
||||
StarterKit.configure({
|
||||
gapcursor: false,
|
||||
dropcursor: false,
|
||||
link: false,
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: placeholder || t("Reply..."),
|
||||
}),
|
||||
Underline,
|
||||
Link,
|
||||
LinkExtension,
|
||||
EmojiCommand,
|
||||
Mention.configure({
|
||||
suggestion: {
|
||||
allowSpaces: true,
|
||||
items: () => [],
|
||||
// @ts-ignore
|
||||
render: mentionRenderItems,
|
||||
},
|
||||
HTMLAttributes: {
|
||||
class: "mention",
|
||||
},
|
||||
}).extend({
|
||||
addNodeView() {
|
||||
this.editor.isInitialized = true;
|
||||
return ReactNodeViewRenderer(MentionView);
|
||||
},
|
||||
}),
|
||||
],
|
||||
editorProps: {
|
||||
handleDOMEvents: {
|
||||
@@ -60,7 +77,8 @@ const CommentEditor = forwardRef(
|
||||
].includes(event.key)
|
||||
) {
|
||||
const emojiCommand = document.querySelector("#emoji-command");
|
||||
if (emojiCommand) {
|
||||
const mentionPopup = document.querySelector("#mention");
|
||||
if (emojiCommand || mentionPopup) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -108,7 +126,11 @@ const CommentEditor = forwardRef(
|
||||
}));
|
||||
|
||||
return (
|
||||
<div ref={focusRef} className={classes.commentEditor}>
|
||||
<div
|
||||
ref={focusRef}
|
||||
className={classes.commentEditor}
|
||||
data-editable={editable || undefined}
|
||||
>
|
||||
<EditorContent
|
||||
editor={commentEditor}
|
||||
className={clsx(classes.ProseMirror, { [classes.focused]: focused })}
|
||||
|
||||
@@ -32,11 +32,14 @@
|
||||
max-width: 100%;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 20vh;
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
&[data-editable] .ProseMirror :global(.ProseMirror){
|
||||
max-height: 50vh;
|
||||
overflow: hidden auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import React, {
|
||||
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query.ts";
|
||||
import {
|
||||
ActionIcon,
|
||||
Divider,
|
||||
Group,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
@@ -51,6 +52,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
const tree = useMemo(() => new SimpleTree<SpaceTreeNode>(data), [data]);
|
||||
const createPageMutation = useCreatePageMutation();
|
||||
const emit = useQueryEmit();
|
||||
const isInCommentContext = props.isInCommentContext ?? false;
|
||||
|
||||
const { data: suggestion, isLoading } = useSearchSuggestionsQuery({
|
||||
query: props.query,
|
||||
@@ -58,6 +60,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
includePages: true,
|
||||
spaceId: space.id,
|
||||
limit: 10,
|
||||
preload: true,
|
||||
});
|
||||
|
||||
const createPageItem = (label: string) : MentionSuggestionItem => {
|
||||
@@ -102,7 +105,9 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
})),
|
||||
);
|
||||
}
|
||||
items.push(createPageItem(props.query));
|
||||
if (!isInCommentContext && props.query) {
|
||||
items.push(createPageItem(props.query));
|
||||
}
|
||||
|
||||
setRenderItems(items);
|
||||
// update editor storage
|
||||
@@ -250,35 +255,51 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
}
|
||||
}
|
||||
|
||||
// if no results and enter what to do?
|
||||
|
||||
useEffect(() => {
|
||||
viewportRef.current
|
||||
?.querySelector(`[data-item-index="${selectedIndex}"]`)
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
}, [selectedIndex]);
|
||||
|
||||
const popupWidth = isInCommentContext ? 280 : 320;
|
||||
|
||||
if (renderItems.length === 0) {
|
||||
return (
|
||||
<Paper shadow="md" p="xs" withBorder>
|
||||
{ t("No results") }
|
||||
<Paper id="mention" shadow="md" py="xs" withBorder radius="md">
|
||||
<Text c="dimmed" size="sm" px="sm">
|
||||
{ t("No results") }
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
const hasUsers = renderItems.some((item) => item.entityType === "user");
|
||||
const hasPages = renderItems.some((item) => item.entityType === "page" && item.id !== null);
|
||||
const createPageItemData = renderItems.find((item) => item.entityType === "page" && item.id === null);
|
||||
|
||||
return (
|
||||
<Paper id="mention" shadow="md" p="xs" withBorder>
|
||||
<Paper id="mention" shadow="md" withBorder radius="md" py={6}>
|
||||
<ScrollArea.Autosize
|
||||
viewportRef={viewportRef}
|
||||
mah={350}
|
||||
w={320}
|
||||
scrollbarSize={8}
|
||||
w={popupWidth}
|
||||
scrollbarSize={6}
|
||||
>
|
||||
{renderItems?.map((item, index) => {
|
||||
if (item.entityType === "header") {
|
||||
const isFirst = index === 0;
|
||||
return (
|
||||
<div key={`${item.label}-${index}`}>
|
||||
<Text c="dimmed" mb={4} tt="uppercase">
|
||||
{!isFirst && <Divider my={6} />}
|
||||
<Text
|
||||
c="dimmed"
|
||||
size="xs"
|
||||
fw={500}
|
||||
px="sm"
|
||||
pt={isFirst ? 2 : 4}
|
||||
pb={4}
|
||||
tt="uppercase"
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
</div>
|
||||
@@ -292,8 +313,9 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
className={clsx(classes.menuBtn, {
|
||||
[classes.selectedItem]: index === selectedIndex,
|
||||
})}
|
||||
px="sm"
|
||||
>
|
||||
<Group>
|
||||
<Group gap="sm">
|
||||
<CustomAvatar
|
||||
size={"sm"}
|
||||
avatarUrl={item.avatarUrl}
|
||||
@@ -308,7 +330,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
} else if (item.entityType === "page") {
|
||||
} else if (item.entityType === "page" && item.id !== null) {
|
||||
return (
|
||||
<UnstyledButton
|
||||
data-item-index={index}
|
||||
@@ -317,28 +339,24 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
className={clsx(classes.menuBtn, {
|
||||
[classes.selectedItem]: index === selectedIndex,
|
||||
})}
|
||||
px="sm"
|
||||
>
|
||||
<Group>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
variant="subtle"
|
||||
component="div"
|
||||
aria-label={item.label}
|
||||
color="gray"
|
||||
size="sm"
|
||||
>
|
||||
{item.icon || (
|
||||
<ActionIcon
|
||||
component="span"
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
size={18}
|
||||
>
|
||||
{ (item.id) ? <IconFileDescription size={18} /> : <IconPlus size={18} /> }
|
||||
</ActionIcon>
|
||||
<IconFileDescription size={18} stroke={1.5} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
{ (item.id) ? item.label : t("Create page") + ': ' + item.label }
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{item.label}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
@@ -348,6 +366,37 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
|
||||
{createPageItemData && !isInCommentContext && (
|
||||
<>
|
||||
{(hasUsers || hasPages) && <Divider my={6} />}
|
||||
<UnstyledButton
|
||||
data-item-index={renderItems.indexOf(createPageItemData)}
|
||||
onClick={() => selectItem(renderItems.indexOf(createPageItemData))}
|
||||
className={clsx(classes.menuBtn, {
|
||||
[classes.selectedItem]: renderItems.indexOf(createPageItemData) === selectedIndex,
|
||||
})}
|
||||
px="sm"
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
component="div"
|
||||
color="gray"
|
||||
size="sm"
|
||||
>
|
||||
<IconPlus size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{t("Create page")}: {createPageItemData.label}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</>
|
||||
)}
|
||||
</ScrollArea.Autosize>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
@@ -17,8 +17,13 @@ const mentionRenderItems = () => {
|
||||
let component: ReactRenderer | null = null;
|
||||
let activeClientRect: (() => DOMRect) | null = null;
|
||||
let updatePositionCleanup: (() => void) | null = null;
|
||||
let outsideClickHandler: ((e: MouseEvent) => void) | null = null;
|
||||
|
||||
const destroy = () => {
|
||||
if (outsideClickHandler) {
|
||||
document.removeEventListener("pointerdown", outsideClickHandler);
|
||||
outsideClickHandler = null;
|
||||
}
|
||||
updatePositionCleanup?.();
|
||||
updatePositionCleanup = null;
|
||||
component?.destroy();
|
||||
@@ -45,8 +50,14 @@ const mentionRenderItems = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const editorDom = props.editor?.view?.dom;
|
||||
const asideEl = editorDom?.closest(".mantine-AppShell-aside");
|
||||
const dialogEl = editorDom?.closest("[data-comment-dialog]");
|
||||
const isInCommentContext = !!(asideEl || dialogEl);
|
||||
// const isInCommentContext = !!asideEl;
|
||||
|
||||
component = new ReactRenderer(MentionList, {
|
||||
props,
|
||||
props: { ...props, isInCommentContext },
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
@@ -59,6 +70,18 @@ const mentionRenderItems = () => {
|
||||
const { element } = component;
|
||||
document.body.appendChild(element);
|
||||
|
||||
outsideClickHandler = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (element && !element.contains(target)) {
|
||||
destroy();
|
||||
}
|
||||
};
|
||||
document.addEventListener("pointerdown", outsideClickHandler);
|
||||
|
||||
const shiftMiddleware = asideEl
|
||||
? shift({ boundary: asideEl, crossAxis: true, padding: 8 })
|
||||
: shift();
|
||||
|
||||
updatePositionCleanup = autoUpdate(
|
||||
{
|
||||
getBoundingClientRect: () =>
|
||||
@@ -76,7 +99,7 @@ const mentionRenderItems = () => {
|
||||
element,
|
||||
{
|
||||
placement: "bottom-start",
|
||||
middleware: [offset(0), flip(), shift()],
|
||||
middleware: [offset(4), flip(), shiftMiddleware],
|
||||
},
|
||||
).then(({ x, y }) => {
|
||||
Object.assign(element.style, {
|
||||
|
||||
@@ -31,14 +31,14 @@
|
||||
|
||||
.menuBtn {
|
||||
width: 100%;
|
||||
padding: 4px;
|
||||
margin-bottom: 2px;
|
||||
padding: 6px 4px;
|
||||
margin-bottom: 1px;
|
||||
color: var(--mantine-color-text);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
|
||||
&:hover {
|
||||
@mixin light {
|
||||
background: var(--mantine-color-gray-2);
|
||||
background: var(--mantine-color-gray-1);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
@@ -49,7 +49,7 @@
|
||||
|
||||
.selectedItem {
|
||||
@mixin light {
|
||||
background: var(--mantine-color-gray-2);
|
||||
background: var(--mantine-color-gray-1);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface MentionListProps {
|
||||
range: Range;
|
||||
text: string;
|
||||
editor: Editor;
|
||||
isInCommentContext?: boolean;
|
||||
}
|
||||
|
||||
export type MentionSuggestionItem =
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Group,
|
||||
Text,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconCheck,
|
||||
IconFileDescription,
|
||||
IconPointFilled,
|
||||
} from "@tabler/icons-react";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||
import { INotification } from "../types/notification.types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import { useMarkReadMutation } from "../queries/notification-query";
|
||||
import { buildPageUrl } from "@/features/page/page.utils";
|
||||
import { formatRelativeTime } from "../notification.utils";
|
||||
import classes from "../notification.module.css";
|
||||
|
||||
type NotificationItemProps = {
|
||||
notification: INotification;
|
||||
onNavigate: () => void;
|
||||
};
|
||||
|
||||
export function NotificationItem({
|
||||
notification,
|
||||
onNavigate,
|
||||
}: NotificationItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const markRead = useMarkReadMutation();
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
const isUnread = !notification.readAt;
|
||||
|
||||
const getNotificationMessage = (): string => {
|
||||
switch (notification.type) {
|
||||
case "comment.user_mention":
|
||||
return t("mentioned you in a comment");
|
||||
case "comment.created":
|
||||
return t("commented on a page");
|
||||
case "comment.resolved":
|
||||
return t("resolved a comment");
|
||||
case "page.user_mention":
|
||||
return t("mentioned you on a page");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (notification.page && notification.space) {
|
||||
if (isUnread) {
|
||||
markRead.mutate([notification.id]);
|
||||
}
|
||||
navigate(
|
||||
buildPageUrl(
|
||||
notification.space.slug,
|
||||
notification.page.slugId,
|
||||
notification.page.title,
|
||||
),
|
||||
);
|
||||
onNavigate();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkRead = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (isUnread) {
|
||||
markRead.mutate([notification.id]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
onClick={handleClick}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
w="100%"
|
||||
className={classes.notificationItem}
|
||||
>
|
||||
<Group wrap="nowrap" align="flex-start" gap="sm">
|
||||
<CustomAvatar
|
||||
avatarUrl={notification.actor?.avatarUrl}
|
||||
name={notification.actor?.name || "?"}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" lineClamp={2}>
|
||||
<Text span fw={600}>
|
||||
{notification.actor?.name}
|
||||
</Text>{" "}
|
||||
{getNotificationMessage()}
|
||||
</Text>
|
||||
|
||||
{notification.page && (
|
||||
<Group gap={4} mt={2} wrap="nowrap">
|
||||
{notification.page.icon ? (
|
||||
<Text size="xs" style={{ flexShrink: 0 }}>
|
||||
{notification.page.icon}
|
||||
</Text>
|
||||
) : (
|
||||
<IconFileDescription
|
||||
size={14}
|
||||
stroke={1.5}
|
||||
style={{ flexShrink: 0, color: "var(--mantine-color-dimmed)" }}
|
||||
/>
|
||||
)}
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{notification.page.title || t("Untitled")}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Group gap={4} wrap="nowrap" align="center" style={{ flexShrink: 0 }}>
|
||||
{hovered && isUnread ? (
|
||||
<Tooltip label={t("Mark as read")} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={handleMarkRead}
|
||||
>
|
||||
<IconCheck size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
{formatRelativeTime(notification.createdAt)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{isUnread && (
|
||||
<IconPointFilled
|
||||
size={12}
|
||||
color="var(--mantine-color-blue-filled)"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Center, Divider, Loader, Stack, Text } from "@mantine/core";
|
||||
import { IconBellOff } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { NotificationItem } from "./notification-item";
|
||||
import { INotification, NotificationFilter } from "../types/notification.types";
|
||||
import { groupNotificationsByTime } from "../notification.utils";
|
||||
import { useNotificationsQuery } from "../queries/notification-query";
|
||||
import classes from "../notification.module.css";
|
||||
|
||||
type NotificationListProps = {
|
||||
filter: NotificationFilter;
|
||||
onNavigate: () => void;
|
||||
};
|
||||
|
||||
export function NotificationList({
|
||||
filter,
|
||||
onNavigate,
|
||||
}: NotificationListProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useNotificationsQuery();
|
||||
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
if (!sentinel) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const allNotifications =
|
||||
data?.pages.flatMap((page) => page.items) ?? [];
|
||||
|
||||
const filtered =
|
||||
filter === "unread"
|
||||
? allNotifications.filter((n) => !n.readAt)
|
||||
: allNotifications;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Stack align="center" gap="xs">
|
||||
<IconBellOff size={32} stroke={1.5} color="var(--mantine-color-dimmed)" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{filter === "unread"
|
||||
? t("No unread notifications")
|
||||
: t("No notifications")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const timeGroupLabels = {
|
||||
today: t("Today"),
|
||||
yesterday: t("Yesterday"),
|
||||
this_week: t("This week"),
|
||||
older: t("Older"),
|
||||
};
|
||||
|
||||
const groups = groupNotificationsByTime(filtered, timeGroupLabels);
|
||||
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
{groups.map((group, groupIndex) => (
|
||||
<div key={group.key}>
|
||||
{groupIndex > 0 && <Divider className={classes.divider} />}
|
||||
<Text size="xs" fw={600} c="dimmed" px="md" pt="sm" pb={4}>
|
||||
{group.label}
|
||||
</Text>
|
||||
{group.notifications.map((notification: INotification) => (
|
||||
<NotificationItem
|
||||
key={notification.id}
|
||||
notification={notification}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div ref={sentinelRef} style={{ height: 1 }} />
|
||||
|
||||
{isFetchingNextPage && (
|
||||
<Center py="xs">
|
||||
<Loader size="xs" />
|
||||
</Center>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Group,
|
||||
Indicator,
|
||||
Menu,
|
||||
Popover,
|
||||
ScrollArea,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconBell,
|
||||
IconCheck,
|
||||
IconChecks,
|
||||
IconDots,
|
||||
IconFilter,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { NotificationList } from "./notification-list";
|
||||
import { NotificationFilter } from "../types/notification.types";
|
||||
import {
|
||||
useMarkAllReadMutation,
|
||||
useUnreadCountQuery,
|
||||
} from "../queries/notification-query";
|
||||
|
||||
export function NotificationPopover() {
|
||||
const { t } = useTranslation();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [filter, setFilter] = useState<NotificationFilter>("all");
|
||||
|
||||
const { data: unreadData } = useUnreadCountQuery();
|
||||
const markAllRead = useMarkAllReadMutation();
|
||||
|
||||
const unreadCount = unreadData?.count ?? 0;
|
||||
|
||||
const handleMarkAllRead = () => {
|
||||
markAllRead.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover
|
||||
position="bottom-end"
|
||||
shadow="lg"
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
withArrow
|
||||
>
|
||||
<Popover.Target>
|
||||
<Tooltip label={t("Notifications")} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
size="sm"
|
||||
onClick={() => setOpened((o) => !o)}
|
||||
>
|
||||
<Indicator
|
||||
offset={5}
|
||||
color="red"
|
||||
withBorder
|
||||
disabled={unreadCount === 0}
|
||||
>
|
||||
<IconBell size={20} />
|
||||
</Indicator>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Popover.Target>
|
||||
|
||||
<Popover.Dropdown
|
||||
p={0}
|
||||
style={{ width: "min(420px, calc(100vw - 24px))" }}
|
||||
>
|
||||
<Group justify="space-between" px="md" py="sm">
|
||||
<Text fw={600} size="sm">
|
||||
{t("Notifications")}
|
||||
</Text>
|
||||
<Group gap={4}>
|
||||
<Menu position="bottom-end" withArrow withinPortal={false}>
|
||||
<Menu.Target>
|
||||
<Tooltip label={t("Filter")} withArrow>
|
||||
<ActionIcon variant="subtle" color="dark" size="sm">
|
||||
<IconFilter size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>{t("Filter")}</Menu.Label>
|
||||
<Menu.Item
|
||||
onClick={() => setFilter("all")}
|
||||
rightSection={
|
||||
filter === "all" ? <IconCheck size={14} /> : null
|
||||
}
|
||||
>
|
||||
{t("All notifications")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() => setFilter("unread")}
|
||||
rightSection={
|
||||
filter === "unread" ? <IconCheck size={14} /> : null
|
||||
}
|
||||
>
|
||||
{t("Unread only")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
<Menu position="bottom-end" withArrow withinPortal={false}>
|
||||
<Menu.Target>
|
||||
<Tooltip label={t("More options")} withArrow>
|
||||
<ActionIcon variant="subtle" color="dark" size="sm">
|
||||
<IconDots size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconChecks size={16} />}
|
||||
onClick={handleMarkAllRead}
|
||||
disabled={unreadCount === 0}
|
||||
>
|
||||
{t("Mark all as read")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<ScrollArea.Autosize
|
||||
mah={500}
|
||||
type="auto"
|
||||
offsetScrollbars
|
||||
scrollbarSize={6}
|
||||
>
|
||||
<NotificationList
|
||||
filter={filter}
|
||||
onNavigate={() => setOpened(false)}
|
||||
/>
|
||||
</ScrollArea.Autosize>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useEffect } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { socketAtom } from "@/features/websocket/atoms/socket-atom";
|
||||
import { NOTIFICATION_KEY } from "../queries/notification-query";
|
||||
|
||||
export function useNotificationSocket() {
|
||||
const queryClient = useQueryClient();
|
||||
const [socket] = useAtom(socketAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handler = () => {
|
||||
queryClient.invalidateQueries({ queryKey: NOTIFICATION_KEY });
|
||||
};
|
||||
|
||||
socket.on("notification", handler);
|
||||
return () => {
|
||||
socket.off("notification", handler);
|
||||
};
|
||||
}, [socket, queryClient]);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
.notificationItem {
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.notificationItem:hover {
|
||||
background-color: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
.divider {
|
||||
border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { INotification } from "./types/notification.types";
|
||||
|
||||
export function formatRelativeTime(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60_000);
|
||||
const diffHours = Math.floor(diffMs / 3_600_000);
|
||||
const diffDays = Math.floor(diffMs / 86_400_000);
|
||||
|
||||
if (diffMin < 1) return "now";
|
||||
if (diffMin < 60) return `${diffMin}m`;
|
||||
if (diffHours < 24) return `${diffHours}h`;
|
||||
if (diffDays < 7) return `${diffDays}d`;
|
||||
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
type TimeGroup = "today" | "yesterday" | "this_week" | "older";
|
||||
|
||||
export function getTimeGroup(dateStr: string): TimeGroup {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
|
||||
const startOfToday = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
);
|
||||
const startOfYesterday = new Date(startOfToday);
|
||||
startOfYesterday.setDate(startOfYesterday.getDate() - 1);
|
||||
const startOfWeek = new Date(startOfToday);
|
||||
startOfWeek.setDate(startOfWeek.getDate() - 7);
|
||||
|
||||
if (date >= startOfToday) return "today";
|
||||
if (date >= startOfYesterday) return "yesterday";
|
||||
if (date >= startOfWeek) return "this_week";
|
||||
return "older";
|
||||
}
|
||||
|
||||
export type GroupedNotifications = {
|
||||
key: TimeGroup;
|
||||
label: string;
|
||||
notifications: INotification[];
|
||||
};
|
||||
|
||||
export function groupNotificationsByTime(
|
||||
notifications: INotification[],
|
||||
labels: Record<TimeGroup, string>,
|
||||
): GroupedNotifications[] {
|
||||
const groups: Record<TimeGroup, INotification[]> = {
|
||||
today: [],
|
||||
yesterday: [],
|
||||
this_week: [],
|
||||
older: [],
|
||||
};
|
||||
|
||||
for (const notification of notifications) {
|
||||
const group = getTimeGroup(notification.createdAt);
|
||||
groups[group].push(notification);
|
||||
}
|
||||
|
||||
const order: TimeGroup[] = ["today", "yesterday", "this_week", "older"];
|
||||
|
||||
return order
|
||||
.filter((key) => groups[key].length > 0)
|
||||
.map((key) => ({
|
||||
key,
|
||||
label: labels[key],
|
||||
notifications: groups[key],
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
keepPreviousData,
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
getNotifications,
|
||||
getUnreadCount,
|
||||
markNotificationsRead,
|
||||
markAllNotificationsRead,
|
||||
} from "../services/notification-service";
|
||||
|
||||
export const NOTIFICATION_KEY = ["notifications"];
|
||||
export const UNREAD_COUNT_KEY = ["notifications", "unread-count"];
|
||||
|
||||
export function useNotificationsQuery() {
|
||||
return useInfiniteQuery({
|
||||
queryKey: NOTIFICATION_KEY,
|
||||
queryFn: ({ pageParam }) => getNotifications({ cursor: pageParam }),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUnreadCountQuery() {
|
||||
return useQuery({
|
||||
queryKey: UNREAD_COUNT_KEY,
|
||||
queryFn: getUnreadCount,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkReadMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (notificationIds: string[]) =>
|
||||
markNotificationsRead(notificationIds),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: NOTIFICATION_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkAllReadMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: markAllNotificationsRead,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: NOTIFICATION_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import api from "@/lib/api-client";
|
||||
import { INotification } from "../types/notification.types";
|
||||
import { IPagination } from "@/lib/types";
|
||||
|
||||
export async function getNotifications(params: {
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
}): Promise<IPagination<INotification>> {
|
||||
const req = await api.post<IPagination<INotification>>(
|
||||
"/notifications",
|
||||
params,
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getUnreadCount(): Promise<{ count: number }> {
|
||||
const req = await api.post<{ count: number }>(
|
||||
"/notifications/unread-count",
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function markNotificationsRead(
|
||||
notificationIds: string[],
|
||||
): Promise<void> {
|
||||
await api.post("/notifications/mark-read", { notificationIds });
|
||||
}
|
||||
|
||||
export async function markAllNotificationsRead(): Promise<void> {
|
||||
await api.post("/notifications/mark-all-read");
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export type NotificationType =
|
||||
| "comment.user_mention"
|
||||
| "comment.created"
|
||||
| "comment.resolved"
|
||||
| "page.user_mention";
|
||||
|
||||
export type INotification = {
|
||||
id: string;
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
type: NotificationType;
|
||||
actorId: string | null;
|
||||
pageId: string | null;
|
||||
spaceId: string | null;
|
||||
commentId: string | null;
|
||||
data: Record<string, unknown> | null;
|
||||
readAt: string | null;
|
||||
emailedAt: string | null;
|
||||
archivedAt: string | null;
|
||||
createdAt: string;
|
||||
actor: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
} | null;
|
||||
page: {
|
||||
id: string;
|
||||
title: string;
|
||||
slugId: string;
|
||||
icon: string | null;
|
||||
} | null;
|
||||
space: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type NotificationFilter = "all" | "unread";
|
||||
@@ -106,10 +106,16 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
||||
}
|
||||
}, sizeRef);
|
||||
const [isDataLoaded, setIsDataLoaded] = useState(false);
|
||||
const spaceIdRef = useRef(spaceId);
|
||||
spaceIdRef.current = spaceId;
|
||||
const { data: currentPage } = usePageQuery({
|
||||
pageId: extractPageSlugId(pageSlug),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setIsDataLoaded(false);
|
||||
}, [spaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasNextPage && !isFetching) {
|
||||
fetchNextPage();
|
||||
@@ -130,12 +136,15 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
||||
}
|
||||
|
||||
// same space; append only missing roots
|
||||
setIsDataLoaded(true);
|
||||
return mergeRootTrees(prev, treeData);
|
||||
});
|
||||
}
|
||||
}, [pagesData, hasNextPage]);
|
||||
}, [pagesData, hasNextPage, spaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
const effectSpaceId = spaceId;
|
||||
|
||||
const fetchData = async () => {
|
||||
if (isDataLoaded && currentPage) {
|
||||
// check if pageId node is present in the tree
|
||||
@@ -149,6 +158,8 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
||||
if (!currentPage.id) return;
|
||||
const ancestors = await getPageBreadcrumbs(currentPage.id);
|
||||
|
||||
if (spaceIdRef.current !== effectSpaceId) return;
|
||||
|
||||
if (ancestors && ancestors?.length > 1) {
|
||||
let flatTreeItems = [...buildTree(ancestors)];
|
||||
|
||||
@@ -176,22 +187,22 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
|
||||
|
||||
// Wait for all fetch operations to complete
|
||||
Promise.all(fetchPromises).then(() => {
|
||||
if (spaceIdRef.current !== effectSpaceId) return;
|
||||
|
||||
// build tree with children
|
||||
const ancestorsTree = buildTreeWithChildren(flatTreeItems);
|
||||
// child of root page we're attaching the built ancestors to
|
||||
const rootChild = ancestorsTree[0];
|
||||
|
||||
// attach built ancestors to tree
|
||||
const updatedTree = appendNodeChildren(
|
||||
data,
|
||||
rootChild.id,
|
||||
rootChild.children,
|
||||
// attach built ancestors to tree using functional updater
|
||||
// to avoid stale closure overwriting the current tree data
|
||||
setData((currentData) =>
|
||||
appendNodeChildren(currentData, rootChild.id, rootChild.children),
|
||||
);
|
||||
setData(updatedTree);
|
||||
|
||||
setTimeout(() => {
|
||||
// focus on node and open all parents
|
||||
treeApiRef.current.select(currentPage.id);
|
||||
treeApiRef.current?.select(currentPage.id);
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -44,8 +44,8 @@ export function SearchMobileControl({ onSearch }: SearchMobileControlProps) {
|
||||
return (
|
||||
<Tooltip label={t("Search")} withArrow>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
style={{ border: "none" }}
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
onClick={onSearch}
|
||||
size="sm"
|
||||
>
|
||||
|
||||
@@ -24,13 +24,14 @@ export function usePageSearchQuery(
|
||||
}
|
||||
|
||||
export function useSearchSuggestionsQuery(
|
||||
params: SearchSuggestionParams,
|
||||
params: SearchSuggestionParams & { preload?: boolean },
|
||||
): UseQueryResult<ISuggestionResult, Error> {
|
||||
const { preload, ...queryParams } = params;
|
||||
return useQuery({
|
||||
queryKey: ["search-suggestion", params.query],
|
||||
staleTime: 60 * 1000, // 1min
|
||||
queryFn: () => searchSuggestions(params),
|
||||
enabled: !!params.query,
|
||||
queryFn: () => searchSuggestions(queryParams),
|
||||
enabled: preload || !!params.query,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { io } from "socket.io-client";
|
||||
import { SOCKET_URL } from "@/features/websocket/types";
|
||||
import { useQuerySubscription } from "@/features/websocket/use-query-subscription.ts";
|
||||
import { useTreeSocket } from "@/features/websocket/use-tree-socket.ts";
|
||||
import { useNotificationSocket } from "@/features/notification/hooks/use-notification-socket.ts";
|
||||
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||
import { Error404 } from "@/components/ui/error-404.tsx";
|
||||
|
||||
@@ -44,6 +45,7 @@ export function UserProvider({ children }: React.PropsWithChildren) {
|
||||
|
||||
useQuerySubscription();
|
||||
useTreeSocket();
|
||||
useNotificationSocket();
|
||||
|
||||
useEffect(() => {
|
||||
if (data && data.user && data.workspace) {
|
||||
|
||||
@@ -59,7 +59,7 @@ export const mantineCssResolver: CSSVariablesResolver = (theme) => ({
|
||||
"--input-error-size": theme.fontSizes.sm,
|
||||
},
|
||||
light: {
|
||||
"--mantine-color-dark-light-color": "var(--mantine-color-default-color)",
|
||||
"--mantine-color-dark-light-color": "#4e5359",
|
||||
"--mantine-color-dark-light-hover": "var(--mantine-color-gray-light-hover)",
|
||||
},
|
||||
dark: {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { HistoryProcessor } from './processors/history.processor';
|
||||
import { LoggerExtension } from './extensions/logger.extension';
|
||||
import { CollaborationHandler } from './collaboration.handler';
|
||||
import { CollabHistoryService } from './services/collab-history.service';
|
||||
import { WatcherModule } from '../core/watcher/watcher.module';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
@@ -29,7 +30,7 @@ import { CollabHistoryService } from './services/collab-history.service';
|
||||
CollaborationHandler,
|
||||
],
|
||||
exports: [CollaborationGateway],
|
||||
imports: [TokenModule],
|
||||
imports: [TokenModule, WatcherModule],
|
||||
})
|
||||
export class CollaborationModule implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(CollaborationModule.name);
|
||||
|
||||
@@ -19,11 +19,13 @@ import { Queue } from 'bullmq';
|
||||
import {
|
||||
extractMentions,
|
||||
extractPageMentions,
|
||||
extractUserMentions,
|
||||
} from '../../common/helpers/prosemirror/utils';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
import {
|
||||
IPageBacklinkJob,
|
||||
IPageHistoryJob,
|
||||
IPageMentionNotificationJob,
|
||||
} from '../../integrations/queue/constants/queue.interface';
|
||||
import { Page } from '@docmost/db/types/entity.types';
|
||||
import { CollabHistoryService } from '../services/collab-history.service';
|
||||
@@ -44,6 +46,7 @@ export class PersistenceExtension implements Extension {
|
||||
@InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
|
||||
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
||||
@InjectQueue(QueueName.HISTORY_QUEUE) private historyQueue: Queue,
|
||||
@InjectQueue(QueueName.NOTIFICATION_QUEUE) private notificationQueue: Queue,
|
||||
private readonly collabHistory: CollabHistoryService,
|
||||
) {}
|
||||
|
||||
@@ -170,6 +173,24 @@ export class PersistenceExtension implements Extension {
|
||||
mentions: pageMentions,
|
||||
} as IPageBacklinkJob);
|
||||
|
||||
const userMentions = extractUserMentions(mentions);
|
||||
const oldMentions = page.content ? extractMentions(page.content) : [];
|
||||
const oldMentionedUserIds = extractUserMentions(oldMentions).map((m) => m.entityId);
|
||||
|
||||
if (userMentions.length > 0) {
|
||||
await this.notificationQueue.add(QueueJob.PAGE_MENTION_NOTIFICATION, {
|
||||
userMentions: userMentions.map((m) => ({
|
||||
userId: m.entityId,
|
||||
mentionId: m.id,
|
||||
creatorId: m.creatorId,
|
||||
})),
|
||||
oldMentionedUserIds,
|
||||
pageId,
|
||||
spaceId: page.spaceId,
|
||||
workspaceId: page.workspaceId,
|
||||
} as IPageMentionNotificationJob);
|
||||
}
|
||||
|
||||
await this.aiQueue.add(QueueJob.PAGE_CONTENT_UPDATED, {
|
||||
pageIds: [pageId],
|
||||
workspaceId: page.workspaceId,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
import { CollabHistoryService } from '../services/collab-history.service';
|
||||
import { WatcherService } from '../../core/watcher/watcher.service';
|
||||
|
||||
@Processor(QueueName.HISTORY_QUEUE)
|
||||
export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
@@ -16,6 +17,7 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
private readonly pageHistoryRepo: PageHistoryRepo,
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly collabHistory: CollabHistoryService,
|
||||
private readonly watcherService: WatcherService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -49,6 +51,13 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
await this.collabHistory.popContributors(pageId);
|
||||
|
||||
try {
|
||||
await this.watcherService.addPageWatchers(
|
||||
contributorIds,
|
||||
pageId,
|
||||
page.spaceId,
|
||||
page.workspaceId,
|
||||
);
|
||||
|
||||
await this.pageHistoryRepo.saveHistory(page, { contributorIds });
|
||||
this.logger.debug(`History created for page: ${pageId}`);
|
||||
} catch (err) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { TransformHttpResponseInterceptor } from '../../common/interceptors/http-response.interceptor';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Logger as PinoLogger } from 'nestjs-pino';
|
||||
import { InternalLogFilter } from '../../common/logger/internal-log-filter';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestFastifyApplication>(
|
||||
@@ -19,7 +20,7 @@ async function bootstrap() {
|
||||
},
|
||||
}),
|
||||
{
|
||||
logger: false,
|
||||
logger: new InternalLogFilter(),
|
||||
bufferLogs: false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -9,3 +9,7 @@ export const LOCAL_STORAGE_PATH = path.resolve(
|
||||
'..',
|
||||
LOCAL_STORAGE_DIR,
|
||||
);
|
||||
|
||||
export function getPageTitle(title: string | null | undefined): string {
|
||||
return title || 'untitled';
|
||||
}
|
||||
|
||||
@@ -64,6 +64,30 @@ export function extractPageMentions(mentionList: MentionNode[]): MentionNode[] {
|
||||
return pageMentionList as MentionNode[];
|
||||
}
|
||||
|
||||
export function extractUserMentionIdsFromJson(json: any): string[] {
|
||||
const userIds: string[] = [];
|
||||
|
||||
function walk(node: any) {
|
||||
if (!node) return;
|
||||
if (
|
||||
node.type === 'mention' &&
|
||||
node.attrs?.entityType === 'user' &&
|
||||
node.attrs?.entityId &&
|
||||
!userIds.includes(node.attrs.entityId)
|
||||
) {
|
||||
userIds.push(node.attrs.entityId);
|
||||
}
|
||||
if (Array.isArray(node.content)) {
|
||||
for (const child of node.content) {
|
||||
walk(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(json);
|
||||
return userIds;
|
||||
}
|
||||
|
||||
export function getProsemirrorContent(content: any) {
|
||||
return (
|
||||
content ?? {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ConsoleLogger } from '@nestjs/common';
|
||||
import { ConsoleLogger, LogLevel } from '@nestjs/common';
|
||||
|
||||
export class InternalLogFilter extends ConsoleLogger {
|
||||
static contextsToIgnore = [
|
||||
'NestFactory',
|
||||
'InstanceLoader',
|
||||
'RoutesResolver',
|
||||
'RouterExplorer',
|
||||
@@ -11,14 +12,23 @@ export class InternalLogFilter extends ConsoleLogger {
|
||||
private allowedLogLevels: string[];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
super({
|
||||
json: isProduction,
|
||||
});
|
||||
const isDebugMode = process.env.DEBUG_MODE === 'true';
|
||||
|
||||
|
||||
if (isProduction && !isDebugMode) {
|
||||
this.allowedLogLevels = ['log', 'error', 'fatal'];
|
||||
this.allowedLogLevels = ['info', 'error', 'fatal'];
|
||||
} else {
|
||||
this.allowedLogLevels = ['log', 'debug', 'verbose', 'warn', 'error', 'fatal'];
|
||||
this.allowedLogLevels = [
|
||||
'info',
|
||||
'debug',
|
||||
'verbose',
|
||||
'warn',
|
||||
'error',
|
||||
'fatal',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,9 +38,8 @@ export class InternalLogFilter extends ConsoleLogger {
|
||||
|
||||
log(_: any, context?: string): void {
|
||||
if (
|
||||
this.isLogLevelAllowed('log') &&
|
||||
(process.env.NODE_ENV !== 'production' ||
|
||||
!InternalLogFilter.contextsToIgnore.includes(context))
|
||||
this.isLogLevelAllowed('info') &&
|
||||
!InternalLogFilter.contextsToIgnore.includes(context)
|
||||
) {
|
||||
super.log.apply(this, arguments);
|
||||
}
|
||||
@@ -59,4 +68,15 @@ export class InternalLogFilter extends ConsoleLogger {
|
||||
super.verbose.apply(this, arguments);
|
||||
}
|
||||
}
|
||||
|
||||
protected printMessages(
|
||||
messages: unknown[],
|
||||
context?: string,
|
||||
logLevel?: LogLevel,
|
||||
writeStreamType?: 'stdout' | 'stderr',
|
||||
errorStack?: unknown,
|
||||
): void {
|
||||
const level = logLevel === 'log' ? ('info' as LogLevel) : logLevel;
|
||||
super.printMessages(messages, context, level, writeStreamType, errorStack);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { CommentService } from './comment.service';
|
||||
import { CommentController } from './comment.controller';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
controllers: [CommentController],
|
||||
providers: [CommentService],
|
||||
exports: [CommentService],
|
||||
|
||||
@@ -2,8 +2,11 @@ import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import { CreateCommentDto } from './dto/create-comment.dto';
|
||||
import { UpdateCommentDto } from './dto/update-comment.dto';
|
||||
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
|
||||
@@ -11,12 +14,21 @@ import { Comment, Page, User } from '@docmost/db/types/entity.types';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
||||
import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
||||
import { extractUserMentionIdsFromJson } from '../../common/helpers/prosemirror/utils';
|
||||
import { ICommentNotificationJob } from '../../integrations/queue/constants/queue.interface';
|
||||
|
||||
@Injectable()
|
||||
export class CommentService {
|
||||
private readonly logger = new Logger(CommentService.name);
|
||||
|
||||
constructor(
|
||||
private commentRepo: CommentRepo,
|
||||
private pageRepo: PageRepo,
|
||||
@InjectQueue(QueueName.GENERAL_QUEUE)
|
||||
private generalQueue: Queue,
|
||||
@InjectQueue(QueueName.NOTIFICATION_QUEUE)
|
||||
private notificationQueue: Queue,
|
||||
) {}
|
||||
|
||||
async findById(commentId: string) {
|
||||
@@ -51,7 +63,7 @@ export class CommentService {
|
||||
}
|
||||
}
|
||||
|
||||
return await this.commentRepo.insertComment({
|
||||
const comment = await this.commentRepo.insertComment({
|
||||
pageId: page.id,
|
||||
content: commentContent,
|
||||
selection: createCommentDto?.selection?.substring(0, 250),
|
||||
@@ -61,6 +73,33 @@ export class CommentService {
|
||||
workspaceId: workspaceId,
|
||||
spaceId: page.spaceId,
|
||||
});
|
||||
|
||||
this.generalQueue
|
||||
.add(QueueJob.ADD_PAGE_WATCHERS, {
|
||||
userIds: [userId],
|
||||
pageId: page.id,
|
||||
spaceId: page.spaceId,
|
||||
workspaceId,
|
||||
})
|
||||
.catch((err) =>
|
||||
this.logger.warn(`Failed to queue add-page-watchers: ${err.message}`),
|
||||
);
|
||||
|
||||
const isReply = !!createCommentDto.parentCommentId;
|
||||
|
||||
await this.queueCommentNotification(
|
||||
commentContent,
|
||||
[],
|
||||
comment.id,
|
||||
page.id,
|
||||
page.spaceId,
|
||||
workspaceId,
|
||||
userId,
|
||||
!isReply,
|
||||
createCommentDto.parentCommentId,
|
||||
);
|
||||
|
||||
return comment;
|
||||
}
|
||||
|
||||
async findByPageId(
|
||||
@@ -87,6 +126,8 @@ export class CommentService {
|
||||
throw new ForbiddenException('You can only edit your own comments');
|
||||
}
|
||||
|
||||
const oldMentionIds = extractUserMentionIdsFromJson(comment.content);
|
||||
|
||||
const editedAt = new Date();
|
||||
|
||||
await this.commentRepo.updateComment(
|
||||
@@ -97,10 +138,57 @@ export class CommentService {
|
||||
},
|
||||
comment.id,
|
||||
);
|
||||
|
||||
await this.queueCommentNotification(
|
||||
commentContent,
|
||||
oldMentionIds,
|
||||
comment.id,
|
||||
comment.pageId,
|
||||
comment.spaceId,
|
||||
comment.workspaceId,
|
||||
authUser.id,
|
||||
false,
|
||||
);
|
||||
|
||||
comment.content = commentContent;
|
||||
comment.editedAt = editedAt;
|
||||
comment.updatedAt = editedAt;
|
||||
|
||||
return comment;
|
||||
}
|
||||
|
||||
private async queueCommentNotification(
|
||||
content: any,
|
||||
oldMentionIds: string[],
|
||||
commentId: string,
|
||||
pageId: string,
|
||||
spaceId: string,
|
||||
workspaceId: string,
|
||||
actorId: string,
|
||||
notifyWatchers: boolean,
|
||||
parentCommentId?: string,
|
||||
) {
|
||||
const mentionedUserIds = extractUserMentionIdsFromJson(content);
|
||||
const newMentionIds = mentionedUserIds.filter(
|
||||
(id) => id !== actorId && !oldMentionIds.includes(id),
|
||||
);
|
||||
|
||||
if (newMentionIds.length === 0 && !notifyWatchers && !parentCommentId) return;
|
||||
|
||||
const jobData: ICommentNotificationJob = {
|
||||
commentId,
|
||||
parentCommentId,
|
||||
pageId,
|
||||
spaceId,
|
||||
workspaceId,
|
||||
actorId,
|
||||
mentionedUserIds: newMentionIds,
|
||||
notifyWatchers,
|
||||
};
|
||||
|
||||
await this.notificationQueue.add(
|
||||
QueueJob.COMMENT_NOTIFICATION,
|
||||
jobData,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ 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';
|
||||
import { WatcherModule } from './watcher/watcher.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -30,6 +32,8 @@ import { ShareModule } from './share/share.module';
|
||||
GroupModule,
|
||||
CaslModule,
|
||||
ShareModule,
|
||||
NotificationModule,
|
||||
WatcherModule,
|
||||
],
|
||||
})
|
||||
export class CoreModule implements NestModule {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { GroupController } from './group.controller';
|
||||
import { GroupUserService } from './services/group-user.service';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
controllers: [GroupController],
|
||||
providers: [GroupService, GroupUserService],
|
||||
exports: [GroupService, GroupUserService],
|
||||
|
||||
@@ -10,15 +10,20 @@ import { GroupService } from './group.service';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||
|
||||
@Injectable()
|
||||
export class GroupUserService {
|
||||
constructor(
|
||||
private groupUserRepo: GroupUserRepo,
|
||||
private spaceMemberRepo: SpaceMemberRepo,
|
||||
private userRepo: UserRepo,
|
||||
@Inject(forwardRef(() => GroupService))
|
||||
private groupService: GroupService,
|
||||
private readonly watcherRepo: WatcherRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
) {}
|
||||
|
||||
@@ -100,6 +105,18 @@ export class GroupUserService {
|
||||
throw new BadRequestException('Group member not found');
|
||||
}
|
||||
|
||||
await this.groupUserRepo.delete(userId, groupId);
|
||||
const spaceIds = await this.spaceMemberRepo.getSpaceIdsByGroupId(groupId);
|
||||
|
||||
// TODO: use queue instead
|
||||
await executeTx(this.db, async (trx) => {
|
||||
await this.groupUserRepo.delete(userId, groupId, { trx });
|
||||
|
||||
for (const spaceId of spaceIds) {
|
||||
await this.watcherRepo.deleteByUsersWithoutSpaceAccess(
|
||||
[userId],
|
||||
spaceId,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,18 +8,27 @@ import {
|
||||
import { CreateGroupDto, DefaultGroup } from '../dto/create-group.dto';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { UpdateGroupDto } from '../dto/update-group.dto';
|
||||
import { KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
||||
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { Group, InsertableGroup, User } from '@docmost/db/types/entity.types';
|
||||
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
||||
import { GroupUserService } from './group-user.service';
|
||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
|
||||
@Injectable()
|
||||
export class GroupService {
|
||||
constructor(
|
||||
private groupRepo: GroupRepo,
|
||||
private groupUserRepo: GroupUserRepo,
|
||||
private spaceMemberRepo: SpaceMemberRepo,
|
||||
@Inject(forwardRef(() => GroupUserService))
|
||||
private groupUserService: GroupUserService,
|
||||
private readonly watcherRepo: WatcherRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
) {}
|
||||
|
||||
async getGroupInfo(groupId: string, workspaceId: string): Promise<Group> {
|
||||
@@ -68,20 +77,6 @@ export class GroupService {
|
||||
return createdGroup;
|
||||
}
|
||||
|
||||
async createDefaultGroup(
|
||||
workspaceId: string,
|
||||
userId?: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<Group> {
|
||||
const insertableGroup: InsertableGroup = {
|
||||
name: DefaultGroup.EVERYONE,
|
||||
isDefault: true,
|
||||
creatorId: userId ?? null,
|
||||
workspaceId: workspaceId,
|
||||
};
|
||||
return await this.groupRepo.insertGroup(insertableGroup, trx);
|
||||
}
|
||||
|
||||
async updateGroup(
|
||||
workspaceId: string,
|
||||
updateGroupDto: UpdateGroupDto,
|
||||
@@ -141,7 +136,24 @@ export class GroupService {
|
||||
if (group.isDefault) {
|
||||
throw new BadRequestException('You cannot delete a default group');
|
||||
}
|
||||
await this.groupRepo.delete(groupId, workspaceId);
|
||||
|
||||
const [userIds, spaceIds] = await Promise.all([
|
||||
this.groupUserRepo.getUserIdsByGroupId(groupId),
|
||||
this.spaceMemberRepo.getSpaceIdsByGroupId(groupId),
|
||||
]);
|
||||
|
||||
// TODO: use queue instead
|
||||
await executeTx(this.db, async (trx) => {
|
||||
await this.groupRepo.delete(groupId, workspaceId, { trx });
|
||||
|
||||
for (const spaceId of spaceIds) {
|
||||
await this.watcherRepo.deleteByUsersWithoutSpaceAccess(
|
||||
userIds,
|
||||
spaceId,
|
||||
{ trx },
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async findAndValidateGroup(
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { IsArray, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
export class NotificationIdDto {
|
||||
@IsUUID()
|
||||
notificationId: string;
|
||||
}
|
||||
|
||||
export class MarkNotificationsReadDto {
|
||||
@IsArray()
|
||||
@IsUUID(undefined, { each: true })
|
||||
@IsOptional()
|
||||
notificationIds?: string[];
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export const NotificationType = {
|
||||
COMMENT_USER_MENTION: 'comment.user_mention',
|
||||
COMMENT_CREATED: 'comment.created',
|
||||
COMMENT_RESOLVED: 'comment.resolved',
|
||||
PAGE_USER_MENTION: 'page.user_mention',
|
||||
} as const;
|
||||
|
||||
export type NotificationType =
|
||||
(typeof NotificationType)[keyof typeof NotificationType];
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { NotificationService } from './notification.service';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
import { MarkNotificationsReadDto } from './dto/notification.dto';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('notifications')
|
||||
export class NotificationController {
|
||||
constructor(private readonly notificationService: NotificationService) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('/')
|
||||
async getNotifications(
|
||||
@Body() pagination: PaginationOptions,
|
||||
@AuthUser() user: User,
|
||||
) {
|
||||
return this.notificationService.findByUserId(user.id, pagination);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('unread-count')
|
||||
async getUnreadCount(@AuthUser() user: User) {
|
||||
const count = await this.notificationService.getUnreadCount(user.id);
|
||||
return { count };
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('mark-read')
|
||||
async markAsRead(
|
||||
@Body() dto: MarkNotificationsReadDto,
|
||||
@AuthUser() user: User,
|
||||
) {
|
||||
if (dto.notificationIds?.length) {
|
||||
await this.notificationService.markMultipleAsRead(
|
||||
dto.notificationIds,
|
||||
user.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('mark-all-read')
|
||||
async markAllAsRead(@AuthUser() user: User) {
|
||||
await this.notificationService.markAllAsRead(user.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { NotificationService } from './notification.service';
|
||||
import { NotificationController } from './notification.controller';
|
||||
import { NotificationProcessor } from './notification.processor';
|
||||
import { CommentNotificationService } from './services/comment.notification';
|
||||
import { PageNotificationService } from './services/page.notification';
|
||||
import { WsModule } from '../../ws/ws.module';
|
||||
|
||||
@Module({
|
||||
imports: [WsModule],
|
||||
controllers: [NotificationController],
|
||||
providers: [
|
||||
NotificationService,
|
||||
NotificationProcessor,
|
||||
CommentNotificationService,
|
||||
PageNotificationService,
|
||||
],
|
||||
exports: [NotificationService],
|
||||
})
|
||||
export class NotificationModule {}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Logger, OnModuleDestroy } from '@nestjs/common';
|
||||
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
|
||||
import { Job } from 'bullmq';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
||||
import {
|
||||
ICommentNotificationJob,
|
||||
ICommentResolvedNotificationJob,
|
||||
IPageMentionNotificationJob,
|
||||
} from '../../integrations/queue/constants/queue.interface';
|
||||
import { CommentNotificationService } from './services/comment.notification';
|
||||
import { PageNotificationService } from './services/page.notification';
|
||||
import { DomainService } from '../../integrations/environment/domain.service';
|
||||
|
||||
@Processor(QueueName.NOTIFICATION_QUEUE)
|
||||
export class NotificationProcessor
|
||||
extends WorkerHost
|
||||
implements OnModuleDestroy
|
||||
{
|
||||
private readonly logger = new Logger(NotificationProcessor.name);
|
||||
|
||||
constructor(
|
||||
private readonly commentNotificationService: CommentNotificationService,
|
||||
private readonly pageNotificationService: PageNotificationService,
|
||||
private readonly domainService: DomainService,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async process(
|
||||
job: Job<
|
||||
| ICommentNotificationJob
|
||||
| ICommentResolvedNotificationJob
|
||||
| IPageMentionNotificationJob,
|
||||
void
|
||||
>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const workspaceId = (job.data as { workspaceId: string }).workspaceId;
|
||||
const appUrl = await this.getWorkspaceUrl(workspaceId);
|
||||
|
||||
switch (job.name) {
|
||||
case QueueJob.COMMENT_NOTIFICATION: {
|
||||
await this.commentNotificationService.processComment(
|
||||
job.data as ICommentNotificationJob,
|
||||
appUrl,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case QueueJob.COMMENT_RESOLVED_NOTIFICATION: {
|
||||
await this.commentNotificationService.processResolved(
|
||||
job.data as ICommentResolvedNotificationJob,
|
||||
appUrl,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case QueueJob.PAGE_MENTION_NOTIFICATION: {
|
||||
await this.pageNotificationService.processPageMention(
|
||||
job.data as IPageMentionNotificationJob,
|
||||
appUrl,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
this.logger.warn(`Unknown notification job: ${job.name}`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
this.logger.error(`Failed to process ${job.name}: ${message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async getWorkspaceUrl(workspaceId: string): Promise<string> {
|
||||
const workspace = await this.db
|
||||
.selectFrom('workspaces')
|
||||
.select('hostname')
|
||||
.where('id', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
|
||||
return this.domainService.getUrl(workspace?.hostname);
|
||||
}
|
||||
|
||||
@OnWorkerEvent('failed')
|
||||
onError(job: Job) {
|
||||
this.logger.error(
|
||||
`Error processing ${job.name} job. Reason: ${job.failedReason}`,
|
||||
);
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
if (this.worker) {
|
||||
await this.worker.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
|
||||
import { InsertableNotification } from '@docmost/db/types/entity.types';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { WsGateway } from '../../ws/ws.gateway';
|
||||
import { MailService } from '../../integrations/mail/mail.service';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationService {
|
||||
private readonly logger = new Logger(NotificationService.name);
|
||||
|
||||
constructor(
|
||||
private readonly notificationRepo: NotificationRepo,
|
||||
private readonly wsGateway: WsGateway,
|
||||
private readonly mailService: MailService,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
) {}
|
||||
|
||||
async create(data: InsertableNotification) {
|
||||
const notification = await this.notificationRepo.insert(data);
|
||||
|
||||
this.wsGateway.server
|
||||
.to(`user-${data.userId}`)
|
||||
.emit('notification', { id: notification.id, type: notification.type });
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
async findByUserId(userId: string, pagination: PaginationOptions) {
|
||||
return this.notificationRepo.findByUserId(userId, pagination);
|
||||
}
|
||||
|
||||
async getUnreadCount(userId: string) {
|
||||
return this.notificationRepo.getUnreadCount(userId);
|
||||
}
|
||||
|
||||
async markAsRead(notificationId: string, userId: string) {
|
||||
return this.notificationRepo.markAsRead(notificationId, userId);
|
||||
}
|
||||
|
||||
async markMultipleAsRead(notificationIds: string[], userId: string) {
|
||||
return this.notificationRepo.markMultipleAsRead(notificationIds, userId);
|
||||
}
|
||||
|
||||
async markAllAsRead(userId: string) {
|
||||
return this.notificationRepo.markAllAsRead(userId);
|
||||
}
|
||||
|
||||
async queueEmail(
|
||||
userId: string,
|
||||
notificationId: string,
|
||||
subject: string,
|
||||
template: any,
|
||||
) {
|
||||
try {
|
||||
const user = await this.db
|
||||
.selectFrom('users')
|
||||
.select(['email'])
|
||||
.where('id', '=', userId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!user?.email) return;
|
||||
|
||||
await this.mailService.sendToQueue({
|
||||
to: user.email,
|
||||
subject,
|
||||
template,
|
||||
notificationId,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
this.logger.error(
|
||||
`Failed to queue email for notification ${notificationId}: ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import {
|
||||
ICommentNotificationJob,
|
||||
ICommentResolvedNotificationJob,
|
||||
} from '../../../integrations/queue/constants/queue.interface';
|
||||
import { NotificationService } from '../notification.service';
|
||||
import { NotificationType } from '../notification.constants';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||
import { CommentMentionEmail } from '@docmost/transactional/emails/comment-mention-email';
|
||||
import { CommentCreateEmail } from '@docmost/transactional/emails/comment-created-email';
|
||||
import { CommentResolvedEmail } from '@docmost/transactional/emails/comment-resolved-email';
|
||||
import { getPageTitle } from '../../../common/helpers';
|
||||
|
||||
@Injectable()
|
||||
export class CommentNotificationService {
|
||||
private readonly logger = new Logger(CommentNotificationService.name);
|
||||
|
||||
constructor(
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly notificationService: NotificationService,
|
||||
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||
private readonly watcherRepo: WatcherRepo,
|
||||
) {}
|
||||
|
||||
async processComment(data: ICommentNotificationJob, appUrl: string) {
|
||||
const {
|
||||
commentId,
|
||||
parentCommentId,
|
||||
pageId,
|
||||
spaceId,
|
||||
workspaceId,
|
||||
actorId,
|
||||
mentionedUserIds,
|
||||
notifyWatchers,
|
||||
} = data;
|
||||
|
||||
const context = await this.getCommentContext(
|
||||
actorId,
|
||||
pageId,
|
||||
spaceId,
|
||||
commentId,
|
||||
appUrl,
|
||||
);
|
||||
if (!context) return;
|
||||
|
||||
const { actor, pageTitle, pageUrl } = context;
|
||||
const notifiedUserIds = new Set<string>();
|
||||
notifiedUserIds.add(actorId);
|
||||
|
||||
const recipientIds = parentCommentId
|
||||
? await this.getThreadParticipantIds(parentCommentId)
|
||||
: notifyWatchers
|
||||
? await this.watcherRepo.getPageWatcherIds(pageId)
|
||||
: [];
|
||||
|
||||
const allCandidateIds = [
|
||||
...new Set([...mentionedUserIds, ...recipientIds]),
|
||||
];
|
||||
const usersWithAccess =
|
||||
await this.spaceMemberRepo.getUserIdsWithSpaceAccess(
|
||||
allCandidateIds,
|
||||
spaceId,
|
||||
);
|
||||
|
||||
for (const userId of mentionedUserIds) {
|
||||
if (!usersWithAccess.has(userId)) continue;
|
||||
|
||||
const notification = await this.notificationService.create({
|
||||
userId,
|
||||
workspaceId,
|
||||
type: NotificationType.COMMENT_USER_MENTION,
|
||||
actorId,
|
||||
pageId,
|
||||
spaceId,
|
||||
commentId,
|
||||
});
|
||||
|
||||
await this.notificationService.queueEmail(
|
||||
userId,
|
||||
notification.id,
|
||||
`${actor.name} mentioned you in a comment`,
|
||||
CommentMentionEmail({ actorName: actor.name, pageTitle, pageUrl }),
|
||||
);
|
||||
|
||||
notifiedUserIds.add(userId);
|
||||
}
|
||||
|
||||
for (const recipientId of recipientIds) {
|
||||
if (notifiedUserIds.has(recipientId)) continue;
|
||||
if (!usersWithAccess.has(recipientId)) continue;
|
||||
|
||||
const notification = await this.notificationService.create({
|
||||
userId: recipientId,
|
||||
workspaceId,
|
||||
type: NotificationType.COMMENT_CREATED,
|
||||
actorId,
|
||||
pageId,
|
||||
spaceId,
|
||||
commentId,
|
||||
});
|
||||
|
||||
await this.notificationService.queueEmail(
|
||||
recipientId,
|
||||
notification.id,
|
||||
`${actor.name} commented on ${pageTitle}`,
|
||||
CommentCreateEmail({ actorName: actor.name, pageTitle, pageUrl }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async processResolved(data: ICommentResolvedNotificationJob, appUrl: string) {
|
||||
const {
|
||||
commentId,
|
||||
commentCreatorId,
|
||||
pageId,
|
||||
spaceId,
|
||||
workspaceId,
|
||||
actorId,
|
||||
} = data;
|
||||
|
||||
if (commentCreatorId === actorId) return;
|
||||
|
||||
const context = await this.getCommentContext(
|
||||
actorId,
|
||||
pageId,
|
||||
spaceId,
|
||||
commentId,
|
||||
appUrl,
|
||||
);
|
||||
if (!context) return;
|
||||
|
||||
const { actor, pageTitle, pageUrl } = context;
|
||||
|
||||
const roles = await this.spaceMemberRepo.getUserSpaceRoles(
|
||||
commentCreatorId,
|
||||
spaceId,
|
||||
);
|
||||
|
||||
if (!roles) {
|
||||
this.logger.debug(
|
||||
`Skipping resolved notification for user ${commentCreatorId}: no access to space ${spaceId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const notification = await this.notificationService.create({
|
||||
userId: commentCreatorId,
|
||||
workspaceId,
|
||||
type: NotificationType.COMMENT_RESOLVED,
|
||||
actorId,
|
||||
pageId,
|
||||
spaceId,
|
||||
commentId,
|
||||
});
|
||||
|
||||
const subject = `${actor.name} resolved a comment on ${pageTitle}`;
|
||||
|
||||
await this.notificationService.queueEmail(
|
||||
commentCreatorId,
|
||||
notification.id,
|
||||
subject,
|
||||
CommentResolvedEmail({ actorName: actor.name, pageTitle, pageUrl }),
|
||||
);
|
||||
}
|
||||
|
||||
private async getThreadParticipantIds(
|
||||
parentCommentId: string,
|
||||
): Promise<string[]> {
|
||||
const participants = await this.db
|
||||
.selectFrom('comments')
|
||||
.select('creatorId')
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb('id', '=', parentCommentId),
|
||||
eb('parentCommentId', '=', parentCommentId),
|
||||
]),
|
||||
)
|
||||
.execute();
|
||||
|
||||
return [...new Set(participants.map((p) => p.creatorId))];
|
||||
}
|
||||
|
||||
private async getCommentContext(
|
||||
actorId: string,
|
||||
pageId: string,
|
||||
spaceId: string,
|
||||
commentId: string,
|
||||
appUrl: string,
|
||||
) {
|
||||
const [actor, page, space] = await Promise.all([
|
||||
this.db
|
||||
.selectFrom('users')
|
||||
.select(['id', 'name'])
|
||||
.where('id', '=', actorId)
|
||||
.executeTakeFirst(),
|
||||
this.db
|
||||
.selectFrom('pages')
|
||||
.select(['id', 'title', 'slugId'])
|
||||
.where('id', '=', pageId)
|
||||
.executeTakeFirst(),
|
||||
this.db
|
||||
.selectFrom('spaces')
|
||||
.select(['id', 'slug'])
|
||||
.where('id', '=', spaceId)
|
||||
.executeTakeFirst(),
|
||||
]);
|
||||
|
||||
if (!actor || !page || !space) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pageUrl = `${appUrl}/s/${space.slug}/p/${page.slugId}`;
|
||||
|
||||
return { actor, pageTitle: getPageTitle(page.title), pageUrl };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { IPageMentionNotificationJob } from '../../../integrations/queue/constants/queue.interface';
|
||||
import { NotificationService } from '../notification.service';
|
||||
import { NotificationType } from '../notification.constants';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { PageMentionEmail } from '@docmost/transactional/emails/page-mention-email';
|
||||
import { getPageTitle } from '../../../common/helpers';
|
||||
|
||||
@Injectable()
|
||||
export class PageNotificationService {
|
||||
constructor(
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly notificationService: NotificationService,
|
||||
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||
) {}
|
||||
|
||||
async processPageMention(data: IPageMentionNotificationJob, appUrl: string) {
|
||||
const { userMentions, oldMentionedUserIds, pageId, spaceId, workspaceId } =
|
||||
data;
|
||||
|
||||
const oldIds = new Set(oldMentionedUserIds);
|
||||
const newMentions = userMentions.filter(
|
||||
(m) => !oldIds.has(m.userId) && m.creatorId !== m.userId,
|
||||
);
|
||||
|
||||
if (newMentions.length === 0) return;
|
||||
|
||||
const candidateUserIds = newMentions.map((m) => m.userId);
|
||||
const usersWithAccess =
|
||||
await this.spaceMemberRepo.getUserIdsWithSpaceAccess(
|
||||
candidateUserIds,
|
||||
spaceId,
|
||||
);
|
||||
|
||||
const accessibleMentions = newMentions.filter((m) =>
|
||||
usersWithAccess.has(m.userId),
|
||||
);
|
||||
if (accessibleMentions.length === 0) return;
|
||||
|
||||
const mentionsByCreator = new Map<
|
||||
string,
|
||||
{ userId: string; mentionId: string }[]
|
||||
>();
|
||||
for (const m of accessibleMentions) {
|
||||
const list = mentionsByCreator.get(m.creatorId) || [];
|
||||
list.push({ userId: m.userId, mentionId: m.mentionId });
|
||||
mentionsByCreator.set(m.creatorId, list);
|
||||
}
|
||||
|
||||
for (const [actorId, mentions] of mentionsByCreator) {
|
||||
await this.notifyMentionedUsers(
|
||||
mentions,
|
||||
actorId,
|
||||
pageId,
|
||||
spaceId,
|
||||
workspaceId,
|
||||
appUrl,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async notifyMentionedUsers(
|
||||
mentions: { userId: string; mentionId: string }[],
|
||||
actorId: string,
|
||||
pageId: string,
|
||||
spaceId: string,
|
||||
workspaceId: string,
|
||||
appUrl: string,
|
||||
) {
|
||||
const context = await this.getPageContext(actorId, pageId, spaceId, appUrl);
|
||||
if (!context) return;
|
||||
|
||||
const { actor, pageTitle, basePageUrl } = context;
|
||||
|
||||
for (const { userId, mentionId } of mentions) {
|
||||
const notification = await this.notificationService.create({
|
||||
userId,
|
||||
workspaceId,
|
||||
type: NotificationType.PAGE_USER_MENTION,
|
||||
actorId,
|
||||
pageId,
|
||||
spaceId,
|
||||
data: { mentionId },
|
||||
});
|
||||
|
||||
const pageUrl = `${basePageUrl}`;
|
||||
const subject = `${actor.name} mentioned you in ${pageTitle}`;
|
||||
|
||||
await this.notificationService.queueEmail(
|
||||
userId,
|
||||
notification.id,
|
||||
subject,
|
||||
PageMentionEmail({ actorName: actor.name, pageTitle, pageUrl }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async getPageContext(
|
||||
actorId: string,
|
||||
pageId: string,
|
||||
spaceId: string,
|
||||
appUrl: string,
|
||||
) {
|
||||
const [actor, page, space] = await Promise.all([
|
||||
this.db
|
||||
.selectFrom('users')
|
||||
.select(['id', 'name'])
|
||||
.where('id', '=', actorId)
|
||||
.executeTakeFirst(),
|
||||
this.db
|
||||
.selectFrom('pages')
|
||||
.select(['id', 'title', 'slugId'])
|
||||
.where('id', '=', pageId)
|
||||
.executeTakeFirst(),
|
||||
this.db
|
||||
.selectFrom('spaces')
|
||||
.select(['id', 'slug'])
|
||||
.where('id', '=', spaceId)
|
||||
.executeTakeFirst(),
|
||||
]);
|
||||
|
||||
if (!actor || !page || !space) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const basePageUrl = `${appUrl}/s/${space.slug}/p/${page.slugId}`;
|
||||
|
||||
return { actor, pageTitle: getPageTitle(page.title), basePageUrl };
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,12 @@ import { PageHistoryService } from './services/page-history.service';
|
||||
import { TrashCleanupService } from './services/trash-cleanup.service';
|
||||
import { StorageModule } from '../../integrations/storage/storage.module';
|
||||
import { CollaborationModule } from '../../collaboration/collaboration.module';
|
||||
import { WatcherModule } from '../watcher/watcher.module';
|
||||
|
||||
@Module({
|
||||
controllers: [PageController],
|
||||
providers: [PageService, PageHistoryService, TrashCleanupService],
|
||||
exports: [PageService, PageHistoryService],
|
||||
imports: [StorageModule, CollaborationModule],
|
||||
imports: [StorageModule, CollaborationModule, WatcherModule],
|
||||
})
|
||||
export class PageModule {}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
||||
import { MovePageDto } from '../dto/move-page.dto';
|
||||
import { generateSlugId } from '../../../common/helpers';
|
||||
import { getPageTitle } from '../../../common/helpers';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
|
||||
import { v7 as uuid7 } from 'uuid';
|
||||
@@ -46,6 +47,7 @@ import { EventName } from '../../../common/events/event.contants';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { CollaborationGateway } from '../../../collaboration/collaboration.gateway';
|
||||
import { markdownToHtml } from '@docmost/editor-ext';
|
||||
import { WatcherService } from '../../watcher/watcher.service';
|
||||
|
||||
@Injectable()
|
||||
export class PageService {
|
||||
@@ -58,8 +60,10 @@ export class PageService {
|
||||
private readonly storageService: StorageService,
|
||||
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
||||
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
||||
@InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
|
||||
private eventEmitter: EventEmitter2,
|
||||
private collaborationGateway: CollaborationGateway,
|
||||
private readonly watcherService: WatcherService,
|
||||
) {}
|
||||
|
||||
async findById(
|
||||
@@ -110,7 +114,7 @@ export class PageService {
|
||||
ydoc = createYdocFromJson(prosemirrorJson);
|
||||
}
|
||||
|
||||
return this.pageRepo.insertPage({
|
||||
const page = await this.pageRepo.insertPage({
|
||||
slugId: generateSlugId(),
|
||||
title: createPageDto.title,
|
||||
position: await this.nextPagePosition(
|
||||
@@ -127,6 +131,19 @@ export class PageService {
|
||||
textContent,
|
||||
ydoc,
|
||||
});
|
||||
|
||||
this.generalQueue
|
||||
.add(QueueJob.ADD_PAGE_WATCHERS, {
|
||||
userIds: [userId],
|
||||
pageId: page.id,
|
||||
spaceId: createPageDto.spaceId,
|
||||
workspaceId,
|
||||
})
|
||||
.catch((err) =>
|
||||
this.logger.warn(`Failed to queue add-page-watchers: ${err.message}`),
|
||||
);
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
async nextPagePosition(spaceId: string, parentPageId?: string) {
|
||||
@@ -190,6 +207,17 @@ export class PageService {
|
||||
page.id,
|
||||
);
|
||||
|
||||
this.generalQueue
|
||||
.add(QueueJob.ADD_PAGE_WATCHERS, {
|
||||
userIds: [user.id],
|
||||
pageId: page.id,
|
||||
spaceId: page.spaceId,
|
||||
workspaceId: page.workspaceId,
|
||||
})
|
||||
.catch((err) =>
|
||||
this.logger.warn(`Failed to queue add-page-watchers: ${err.message}`),
|
||||
);
|
||||
|
||||
if (
|
||||
updatePageDto.content &&
|
||||
updatePageDto.operation &&
|
||||
@@ -321,6 +349,11 @@ export class PageService {
|
||||
trx,
|
||||
);
|
||||
|
||||
// Update watchers and remove those without access to new space
|
||||
await this.watcherService.movePageWatchersToSpace(pageIds, spaceId, {
|
||||
trx,
|
||||
});
|
||||
|
||||
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
|
||||
pageId: pageIds,
|
||||
workspaceId: rootPage.workspaceId,
|
||||
@@ -434,7 +467,7 @@ export class PageService {
|
||||
// Add "Copy of " prefix to the root page title only for duplicates in same space
|
||||
let title = page.title;
|
||||
if (isDuplicateInSameSpace && page.id === rootPage.id) {
|
||||
const originalTitle = page.title || 'Untitled';
|
||||
const originalTitle = getPageTitle(page.title);
|
||||
title = `Copy of ${originalTitle}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
|
||||
import { AddSpaceMembersDto } from '../dto/add-space-members.dto';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { Space, SpaceMember, User } from '@docmost/db/types/entity.types';
|
||||
@@ -14,12 +15,16 @@ import { RemoveSpaceMemberDto } from '../dto/remove-space-member.dto';
|
||||
import { UpdateSpaceMemberRoleDto } from '../dto/update-space-member-role.dto';
|
||||
import { SpaceRole } from '../../../common/helpers/types/permission';
|
||||
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
|
||||
@Injectable()
|
||||
export class SpaceMemberService {
|
||||
constructor(
|
||||
private spaceMemberRepo: SpaceMemberRepo,
|
||||
private groupUserRepo: GroupUserRepo,
|
||||
private spaceRepo: SpaceRepo,
|
||||
private watcherRepo: WatcherRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
) {}
|
||||
|
||||
@@ -203,10 +208,28 @@ export class SpaceMemberService {
|
||||
await this.validateLastAdmin(dto.spaceId);
|
||||
}
|
||||
|
||||
await this.spaceMemberRepo.removeSpaceMemberById(
|
||||
spaceMember.id,
|
||||
dto.spaceId,
|
||||
);
|
||||
let affectedUserIds: string[] = [];
|
||||
if (dto.userId) {
|
||||
affectedUserIds = [dto.userId];
|
||||
} else if (dto.groupId) {
|
||||
affectedUserIds = await this.groupUserRepo.getUserIdsByGroupId(
|
||||
dto.groupId,
|
||||
);
|
||||
}
|
||||
|
||||
await executeTx(this.db, async (trx) => {
|
||||
await this.spaceMemberRepo.removeSpaceMemberById(
|
||||
spaceMember.id,
|
||||
dto.spaceId,
|
||||
{ trx },
|
||||
);
|
||||
|
||||
await this.watcherRepo.deleteByUsersWithoutSpaceAccess(
|
||||
affectedUserIds,
|
||||
dto.spaceId,
|
||||
{ trx },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async updateSpaceMemberRole(
|
||||
|
||||
@@ -4,6 +4,7 @@ import { SpaceController } from './space.controller';
|
||||
import { SpaceMemberService } from './services/space-member.service';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
controllers: [SpaceController],
|
||||
providers: [SpaceService, SpaceMemberService],
|
||||
exports: [SpaceService, SpaceMemberService],
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsString, IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class WatcherPageDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
pageId: string;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/***
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { WatcherService } from './watcher.service';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { WatcherPageDto } from './dto/watcher.dto';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from '../casl/interfaces/space-ability.type';
|
||||
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('pages')
|
||||
export class WatcherController {
|
||||
constructor(
|
||||
private readonly watcherService: WatcherService,
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('watch')
|
||||
async watchPage(
|
||||
@Body() dto: WatcherPageDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
if (!page) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
await this.watcherService.watchPage(
|
||||
user.id,
|
||||
page.id,
|
||||
page.spaceId,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
return { watching: true };
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('unwatch')
|
||||
async unwatchPage(@Body() dto: WatcherPageDto, @AuthUser() user: User) {
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
if (!page) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
await this.watcherService.unwatchPage(user.id, page.id);
|
||||
|
||||
return { watching: false };
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('watch-status')
|
||||
async getWatchStatus(@Body() dto: WatcherPageDto, @AuthUser() user: User) {
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
if (!page) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
const watching = await this.watcherService.isWatchingPage(user.id, page.id);
|
||||
|
||||
return { watching };
|
||||
}
|
||||
|
||||
}
|
||||
***/
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WatcherService } from './watcher.service';
|
||||
import { CaslModule } from '../casl/casl.module';
|
||||
|
||||
@Module({
|
||||
imports: [CaslModule],
|
||||
controllers: [],
|
||||
providers: [WatcherService],
|
||||
exports: [WatcherService],
|
||||
})
|
||||
export class WatcherModule {}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
WatcherRepo,
|
||||
WatcherType,
|
||||
} from '@docmost/db/repos/watcher/watcher.repo';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { InsertableWatcher } from '@docmost/db/types/entity.types';
|
||||
|
||||
@Injectable()
|
||||
export class WatcherService {
|
||||
constructor(private readonly watcherRepo: WatcherRepo) {}
|
||||
|
||||
async watchPage(
|
||||
userId: string,
|
||||
pageId: string,
|
||||
spaceId: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
) {
|
||||
const watcher: InsertableWatcher = {
|
||||
userId,
|
||||
pageId,
|
||||
spaceId,
|
||||
workspaceId,
|
||||
type: WatcherType.PAGE,
|
||||
addedById: userId,
|
||||
};
|
||||
return this.watcherRepo.upsert(watcher, trx);
|
||||
}
|
||||
|
||||
async addPageWatchers(
|
||||
userIds: string[],
|
||||
pageId: string,
|
||||
spaceId: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
) {
|
||||
if (userIds.length === 0) return;
|
||||
|
||||
const watchers: InsertableWatcher[] = userIds.map((userId) => ({
|
||||
userId,
|
||||
pageId,
|
||||
spaceId,
|
||||
workspaceId,
|
||||
type: WatcherType.PAGE,
|
||||
addedById: userId,
|
||||
}));
|
||||
|
||||
return this.watcherRepo.insertMany(watchers, trx);
|
||||
}
|
||||
|
||||
async unwatchPage(userId: string, pageId: string) {
|
||||
return this.watcherRepo.mute(userId, pageId);
|
||||
}
|
||||
|
||||
async isWatchingPage(userId: string, pageId: string): Promise<boolean> {
|
||||
return this.watcherRepo.isWatching(userId, pageId);
|
||||
}
|
||||
|
||||
async getPageWatchers(pageId: string, pagination: PaginationOptions) {
|
||||
return this.watcherRepo.findPageWatchers(pageId, pagination);
|
||||
}
|
||||
|
||||
async getPageWatcherIds(
|
||||
pageId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<string[]> {
|
||||
return this.watcherRepo.getPageWatcherIds(pageId, trx);
|
||||
}
|
||||
|
||||
async countPageWatchers(pageId: string): Promise<number> {
|
||||
return this.watcherRepo.countPageWatchers(pageId);
|
||||
}
|
||||
|
||||
async cleanupOnSpaceAccessChange(
|
||||
userIds: string[],
|
||||
spaceId: string,
|
||||
opts?: { trx?: KyselyTransaction },
|
||||
): Promise<void> {
|
||||
const { trx } = opts;
|
||||
await this.watcherRepo.deleteByUsersWithoutSpaceAccess(userIds, spaceId, {
|
||||
trx,
|
||||
});
|
||||
}
|
||||
|
||||
async movePageWatchersToSpace(
|
||||
pageIds: string[],
|
||||
spaceId: string,
|
||||
opts?: { trx?: KyselyTransaction },
|
||||
): Promise<void> {
|
||||
await this.watcherRepo.updateSpaceIdByPageIds(spaceId, pageIds, opts);
|
||||
await this.watcherRepo.deleteByPageIdsWithoutSpaceAccess(
|
||||
pageIds,
|
||||
spaceId,
|
||||
opts,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import { generateRandomSuffixNumbers } from '../../../common/helpers';
|
||||
import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers';
|
||||
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceService {
|
||||
@@ -51,6 +52,7 @@ export class WorkspaceService {
|
||||
private domainService: DomainService,
|
||||
private licenseCheckService: LicenseCheckService,
|
||||
private shareRepo: ShareRepo,
|
||||
private watcherRepo: WatcherRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
||||
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
|
||||
@@ -553,6 +555,10 @@ export class WorkspaceService {
|
||||
.deleteFrom('authAccounts')
|
||||
.where('userId', '=', userId)
|
||||
.execute();
|
||||
|
||||
await this.watcherRepo.deleteByUserAndWorkspace(userId, workspaceId, {
|
||||
trx,
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -24,6 +24,8 @@ 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 '@docmost/db/repos/notification/notification.repo';
|
||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||
import { PageListener } from '@docmost/db/listeners/page.listener';
|
||||
import { PostgresJSDialect } from 'kysely-postgres-js';
|
||||
import * as postgres from 'postgres';
|
||||
@@ -80,6 +82,8 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
UserTokenRepo,
|
||||
BacklinkRepo,
|
||||
ShareRepo,
|
||||
NotificationRepo,
|
||||
WatcherRepo,
|
||||
PageListener,
|
||||
],
|
||||
exports: [
|
||||
@@ -96,6 +100,8 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
UserTokenRepo,
|
||||
BacklinkRepo,
|
||||
ShareRepo,
|
||||
NotificationRepo,
|
||||
WatcherRepo,
|
||||
],
|
||||
})
|
||||
export class DatabaseModule
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { type Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('notifications')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('user_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('type', 'text', (col) => col.notNull())
|
||||
.addColumn('actor_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('set null'),
|
||||
)
|
||||
.addColumn('page_id', 'uuid', (col) =>
|
||||
col.references('pages.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('space_id', 'uuid', (col) =>
|
||||
col.references('spaces.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('comment_id', 'uuid', (col) =>
|
||||
col.references('comments.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('data', 'jsonb')
|
||||
.addColumn('read_at', 'timestamptz')
|
||||
.addColumn('emailed_at', 'timestamptz')
|
||||
.addColumn('archived_at', 'timestamptz')
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_notifications_user_id')
|
||||
.on('notifications')
|
||||
.columns(['user_id', 'id desc'])
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_notifications_user_unread')
|
||||
.on('notifications')
|
||||
.column('user_id')
|
||||
.where(sql.ref('read_at'), 'is', null)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable('notifications').execute();
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { type Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('watchers')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('user_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('page_id', 'uuid', (col) =>
|
||||
col.references('pages.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('space_id', 'uuid', (col) =>
|
||||
col.references('spaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('type', 'text', (col) => col.notNull())
|
||||
.addColumn('added_by_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('set null'),
|
||||
)
|
||||
.addColumn('muted_at', 'timestamptz')
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_watchers_user_page')
|
||||
.on('watchers')
|
||||
.columns(['user_id', 'page_id'])
|
||||
.unique()
|
||||
.where('page_id', 'is not', null)
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_watchers_user_space')
|
||||
.on('watchers')
|
||||
.columns(['user_id', 'space_id'])
|
||||
.unique()
|
||||
.where(sql.ref('page_id'), 'is', null)
|
||||
.execute();
|
||||
|
||||
// Query index for fetching watchers by page
|
||||
await db.schema
|
||||
.createIndex('idx_watchers_page_id')
|
||||
.on('watchers')
|
||||
.column('page_id')
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable('watchers').execute();
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { type Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
// Backfill watchers from pages.contributorIds and pages.creatorId
|
||||
// This inserts unique user-page combinations from both sources
|
||||
await sql`
|
||||
INSERT INTO watchers (user_id, page_id, space_id, workspace_id, type, added_by_id)
|
||||
SELECT DISTINCT
|
||||
u.user_id,
|
||||
p.id as page_id,
|
||||
p.space_id,
|
||||
p.workspace_id,
|
||||
'page' as type,
|
||||
u.user_id as added_by_id
|
||||
FROM pages p
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT unnest(p.contributor_ids) as user_id
|
||||
UNION
|
||||
SELECT p.creator_id as user_id WHERE p.creator_id IS NOT NULL
|
||||
) u
|
||||
WHERE p.deleted_at IS NULL
|
||||
AND u.user_id IS NOT NULL
|
||||
ON CONFLICT DO NOTHING
|
||||
`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`DELETE FROM watchers WHERE type = 'page'`.execute(db);
|
||||
}
|
||||
@@ -56,7 +56,11 @@ export class GroupUserRepo {
|
||||
|
||||
if (pagination.query) {
|
||||
query = query.where((eb) =>
|
||||
eb(sql`f_unaccent(users.name)`, 'ilike', sql`f_unaccent(${'%' + pagination.query + '%'})`),
|
||||
eb(
|
||||
sql`f_unaccent(users.name)`,
|
||||
'ilike',
|
||||
sql`f_unaccent(${'%' + pagination.query + '%'})`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -147,8 +151,25 @@ export class GroupUserRepo {
|
||||
);
|
||||
}
|
||||
|
||||
async delete(userId: string, groupId: string): Promise<void> {
|
||||
await this.db
|
||||
async getUserIdsByGroupId(groupId: string): Promise<string[]> {
|
||||
const rows = await this.db
|
||||
.selectFrom('groupUsers')
|
||||
.select('userId')
|
||||
.where('groupId', '=', groupId)
|
||||
.execute();
|
||||
|
||||
return rows.map((r) => r.userId);
|
||||
}
|
||||
|
||||
async delete(
|
||||
userId: string,
|
||||
groupId: string,
|
||||
opts?: { trx?: KyselyTransaction },
|
||||
): Promise<void> {
|
||||
const { trx } = opts;
|
||||
const db = dbOrTx(this.db, trx);
|
||||
|
||||
await db
|
||||
.deleteFrom('groupUsers')
|
||||
.where('userId', '=', userId)
|
||||
.where('groupId', '=', groupId)
|
||||
|
||||
@@ -152,8 +152,15 @@ export class GroupRepo {
|
||||
.as('memberCount');
|
||||
}
|
||||
|
||||
async delete(groupId: string, workspaceId: string): Promise<void> {
|
||||
await this.db
|
||||
async delete(
|
||||
groupId: string,
|
||||
workspaceId: string,
|
||||
opts?: { trx?: KyselyTransaction },
|
||||
): Promise<void> {
|
||||
const { trx } = opts;
|
||||
const db = dbOrTx(this.db, trx);
|
||||
|
||||
await db
|
||||
.deleteFrom('groups')
|
||||
.where('id', '=', groupId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '../../types/kysely.types';
|
||||
import {
|
||||
InsertableNotification,
|
||||
Notification,
|
||||
} from '@docmost/db/types/entity.types';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||
import { ExpressionBuilder } from 'kysely';
|
||||
import { DB } from '@docmost/db/types/db';
|
||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationRepo {
|
||||
constructor(
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||
) {}
|
||||
|
||||
async findById(notificationId: string): Promise<Notification | undefined> {
|
||||
return this.db
|
||||
.selectFrom('notifications')
|
||||
.selectAll('notifications')
|
||||
.where('id', '=', notificationId)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async findByUserId(userId: string, pagination: PaginationOptions) {
|
||||
const query = this.db
|
||||
.selectFrom('notifications')
|
||||
.selectAll('notifications')
|
||||
.select((eb) => this.withActor(eb))
|
||||
.select((eb) => this.withPage(eb))
|
||||
.select((eb) => this.withSpace(eb))
|
||||
.where('userId', '=', userId)
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb('spaceId', 'is', null),
|
||||
eb('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)),
|
||||
]),
|
||||
);
|
||||
|
||||
return executeWithCursorPagination(query, {
|
||||
perPage: pagination.limit,
|
||||
cursor: pagination.cursor,
|
||||
beforeCursor: pagination.beforeCursor,
|
||||
fields: [{ expression: 'id', direction: 'desc' }],
|
||||
parseCursor: (cursor) => ({ id: cursor.id }),
|
||||
});
|
||||
}
|
||||
|
||||
async getUnreadCount(userId: string): Promise<number> {
|
||||
const result = await this.db
|
||||
.selectFrom('notifications')
|
||||
.select((eb) => eb.fn.count('id').as('count'))
|
||||
.where('userId', '=', userId)
|
||||
.where('readAt', 'is', null)
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb('spaceId', 'is', null),
|
||||
eb('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)),
|
||||
]),
|
||||
)
|
||||
.executeTakeFirst();
|
||||
|
||||
return Number(result?.count ?? 0);
|
||||
}
|
||||
|
||||
async insert(notification: InsertableNotification): Promise<Notification> {
|
||||
return this.db
|
||||
.insertInto('notifications')
|
||||
.values(notification)
|
||||
.returningAll()
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async markAsRead(notificationId: string, userId: string): Promise<void> {
|
||||
await this.db
|
||||
.updateTable('notifications')
|
||||
.set({ readAt: new Date() })
|
||||
.where('id', '=', notificationId)
|
||||
.where('userId', '=', userId)
|
||||
.where('readAt', 'is', null)
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb('spaceId', 'is', null),
|
||||
eb('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)),
|
||||
]),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async markMultipleAsRead(
|
||||
notificationIds: string[],
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
if (notificationIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
await this.db
|
||||
.updateTable('notifications')
|
||||
.set({ readAt: new Date() })
|
||||
.where('id', 'in', notificationIds)
|
||||
.where('userId', '=', userId)
|
||||
.where('readAt', 'is', null)
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb('spaceId', 'is', null),
|
||||
eb('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)),
|
||||
]),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async markAsEmailed(notificationId: string): Promise<void> {
|
||||
await this.db
|
||||
.updateTable('notifications')
|
||||
.set({ emailedAt: new Date() })
|
||||
.where('id', '=', notificationId)
|
||||
.where('emailedAt', 'is', null)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async markAllAsRead(userId: string): Promise<void> {
|
||||
await this.db
|
||||
.updateTable('notifications')
|
||||
.set({ readAt: new Date() })
|
||||
.where('userId', '=', userId)
|
||||
.where('readAt', 'is', null)
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb('spaceId', 'is', null),
|
||||
eb('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)),
|
||||
]),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
withActor(eb: ExpressionBuilder<DB, 'notifications'>) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('users')
|
||||
.select(['users.id', 'users.name', 'users.avatarUrl'])
|
||||
.whereRef('users.id', '=', 'notifications.actorId'),
|
||||
).as('actor');
|
||||
}
|
||||
|
||||
withPage(eb: ExpressionBuilder<DB, 'notifications'>) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('pages')
|
||||
.select(['pages.id', 'pages.title', 'pages.slugId', 'pages.icon'])
|
||||
.whereRef('pages.id', '=', 'notifications.pageId'),
|
||||
).as('page');
|
||||
}
|
||||
|
||||
withSpace(eb: ExpressionBuilder<DB, 'notifications'>) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('spaces')
|
||||
.select(['spaces.id', 'spaces.name', 'spaces.slug'])
|
||||
.whereRef('spaces.id', '=', 'notifications.spaceId'),
|
||||
).as('space');
|
||||
}
|
||||
}
|
||||
@@ -73,8 +73,9 @@ export class SpaceMemberRepo {
|
||||
async removeSpaceMemberById(
|
||||
memberId: string,
|
||||
spaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
opts?: { trx?: KyselyTransaction },
|
||||
): Promise<void> {
|
||||
const { trx } = opts;
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.deleteFrom('spaceMembers')
|
||||
@@ -114,7 +115,11 @@ export class SpaceMemberRepo {
|
||||
'spaceMembers.createdAt',
|
||||
])
|
||||
.select((eb) => this.groupRepo.withMemberCount(eb))
|
||||
.select(sql<number>`case when groups.id is not null then 1 else 0 end`.as('isGroup'))
|
||||
.select(
|
||||
sql<number>`case when groups.id is not null then 1 else 0 end`.as(
|
||||
'isGroup',
|
||||
),
|
||||
)
|
||||
.where('spaceId', '=', spaceId);
|
||||
|
||||
if (pagination.query) {
|
||||
@@ -219,6 +224,40 @@ export class SpaceMemberRepo {
|
||||
return roles;
|
||||
}
|
||||
|
||||
async getUserIdsWithSpaceAccess(
|
||||
userIds: string[],
|
||||
spaceId: string,
|
||||
): Promise<Set<string>> {
|
||||
if (userIds.length === 0) return new Set();
|
||||
|
||||
const rows = await this.db
|
||||
.selectFrom('spaceMembers')
|
||||
.select('userId')
|
||||
.where('userId', 'in', userIds)
|
||||
.where('spaceId', '=', spaceId)
|
||||
.unionAll(
|
||||
this.db
|
||||
.selectFrom('spaceMembers')
|
||||
.innerJoin('groupUsers', 'groupUsers.groupId', 'spaceMembers.groupId')
|
||||
.select('groupUsers.userId')
|
||||
.where('groupUsers.userId', 'in', userIds)
|
||||
.where('spaceMembers.spaceId', '=', spaceId),
|
||||
)
|
||||
.execute();
|
||||
|
||||
return new Set(rows.map((r) => r.userId));
|
||||
}
|
||||
|
||||
async getSpaceIdsByGroupId(groupId: string): Promise<string[]> {
|
||||
const rows = await this.db
|
||||
.selectFrom('spaceMembers')
|
||||
.select('spaceId')
|
||||
.where('groupId', '=', groupId)
|
||||
.execute();
|
||||
|
||||
return rows.map((r) => r.spaceId);
|
||||
}
|
||||
|
||||
getUserSpaceIdsQuery(userId: string) {
|
||||
return this.db
|
||||
.selectFrom('spaceMembers')
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
|
||||
import { InsertableWatcher, Watcher } from '@docmost/db/types/entity.types';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||
import { ExpressionBuilder } from 'kysely';
|
||||
import { DB } from '@docmost/db/types/db';
|
||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { dbOrTx } from '@docmost/db/utils';
|
||||
|
||||
export const WatcherType = {
|
||||
PAGE: 'page',
|
||||
SPACE: 'space',
|
||||
} as const;
|
||||
|
||||
export type WatcherType = (typeof WatcherType)[keyof typeof WatcherType];
|
||||
|
||||
@Injectable()
|
||||
export class WatcherRepo {
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
async findByUserAndPage(
|
||||
userId: string,
|
||||
pageId: string,
|
||||
): Promise<Watcher | undefined> {
|
||||
return this.db
|
||||
.selectFrom('watchers')
|
||||
.selectAll()
|
||||
.where('userId', '=', userId)
|
||||
.where('pageId', '=', pageId)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async findPageWatchers(pageId: string, pagination: PaginationOptions) {
|
||||
const query = this.db
|
||||
.selectFrom('watchers')
|
||||
.selectAll('watchers')
|
||||
.select((eb) => this.withUser(eb))
|
||||
.where('pageId', '=', pageId)
|
||||
.where('type', '=', WatcherType.PAGE)
|
||||
.where('mutedAt', 'is', null);
|
||||
|
||||
return executeWithCursorPagination(query, {
|
||||
perPage: pagination.limit,
|
||||
cursor: pagination.cursor,
|
||||
beforeCursor: pagination.beforeCursor,
|
||||
fields: [{ expression: 'id', direction: 'asc' }],
|
||||
parseCursor: (cursor) => ({ id: cursor.id }),
|
||||
});
|
||||
}
|
||||
|
||||
async getPageWatcherIds(
|
||||
pageId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<string[]> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
const watchers = await db
|
||||
.selectFrom('watchers')
|
||||
.select('userId')
|
||||
.where('pageId', '=', pageId)
|
||||
.where('type', '=', WatcherType.PAGE)
|
||||
.where('mutedAt', 'is', null)
|
||||
.execute();
|
||||
|
||||
return watchers.map((w) => w.userId);
|
||||
}
|
||||
|
||||
async insert(
|
||||
watcher: InsertableWatcher,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<Watcher | undefined> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.insertInto('watchers')
|
||||
.values(watcher)
|
||||
.onConflict((oc) => oc.doNothing())
|
||||
.returningAll()
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async insertMany(
|
||||
watchers: InsertableWatcher[],
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
if (watchers.length === 0) return;
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.insertInto('watchers')
|
||||
.values(watchers)
|
||||
.onConflict((oc) => oc.doNothing())
|
||||
.execute();
|
||||
}
|
||||
|
||||
async upsert(
|
||||
watcher: InsertableWatcher,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<Watcher | undefined> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.insertInto('watchers')
|
||||
.values(watcher)
|
||||
.onConflict((oc) =>
|
||||
oc
|
||||
.columns(['userId', 'pageId'])
|
||||
.where('pageId', 'is not', null)
|
||||
.doUpdateSet({ mutedAt: null }),
|
||||
)
|
||||
.returningAll()
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async mute(
|
||||
userId: string,
|
||||
pageId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.updateTable('watchers')
|
||||
.set({ mutedAt: new Date() })
|
||||
.where('userId', '=', userId)
|
||||
.where('pageId', '=', pageId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async isWatching(userId: string, pageId: string): Promise<boolean> {
|
||||
const watcher = await this.db
|
||||
.selectFrom('watchers')
|
||||
.select('id')
|
||||
.where('userId', '=', userId)
|
||||
.where('pageId', '=', pageId)
|
||||
.where('mutedAt', 'is', null)
|
||||
.executeTakeFirst();
|
||||
|
||||
return !!watcher;
|
||||
}
|
||||
|
||||
async countPageWatchers(pageId: string): Promise<number> {
|
||||
const result = await this.db
|
||||
.selectFrom('watchers')
|
||||
.select((eb) => eb.fn.count('id').as('count'))
|
||||
.where('pageId', '=', pageId)
|
||||
.where('type', '=', WatcherType.PAGE)
|
||||
.where('mutedAt', 'is', null)
|
||||
.executeTakeFirst();
|
||||
|
||||
return Number(result?.count ?? 0);
|
||||
}
|
||||
|
||||
async deleteByUsersWithoutSpaceAccess(
|
||||
userIds: string[],
|
||||
spaceId: string,
|
||||
opts?: { trx?: KyselyTransaction },
|
||||
): Promise<void> {
|
||||
if (userIds.length === 0) return;
|
||||
|
||||
const { trx } = opts;
|
||||
const db = dbOrTx(this.db, trx);
|
||||
|
||||
const usersWithAccess = db
|
||||
.selectFrom('spaceMembers')
|
||||
.select('userId')
|
||||
.where('spaceId', '=', spaceId)
|
||||
.where('userId', 'is not', null)
|
||||
.union(
|
||||
this.db
|
||||
.selectFrom('spaceMembers')
|
||||
.innerJoin('groupUsers', 'groupUsers.groupId', 'spaceMembers.groupId')
|
||||
.select('groupUsers.userId')
|
||||
.where('spaceMembers.spaceId', '=', spaceId),
|
||||
);
|
||||
|
||||
await this.db
|
||||
.deleteFrom('watchers')
|
||||
.where('userId', 'in', userIds)
|
||||
.where('spaceId', '=', spaceId)
|
||||
.where('userId', 'not in', usersWithAccess)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async updateSpaceIdByPageIds(
|
||||
spaceId: string,
|
||||
pageIds: string[],
|
||||
opts?: { trx?: KyselyTransaction },
|
||||
): Promise<void> {
|
||||
if (pageIds.length === 0) return;
|
||||
const { trx } = opts;
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.updateTable('watchers')
|
||||
.set({ spaceId })
|
||||
.where('pageId', 'in', pageIds)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deleteByPageIdsWithoutSpaceAccess(
|
||||
pageIds: string[],
|
||||
spaceId: string,
|
||||
opts?: { trx?: KyselyTransaction },
|
||||
): Promise<void> {
|
||||
if (pageIds.length === 0) return;
|
||||
const { trx } = opts;
|
||||
const db = dbOrTx(this.db, trx);
|
||||
|
||||
const usersWithAccess = db
|
||||
.selectFrom('spaceMembers')
|
||||
.select('userId')
|
||||
.where('spaceId', '=', spaceId)
|
||||
.where('userId', 'is not', null)
|
||||
.union(
|
||||
db
|
||||
.selectFrom('spaceMembers')
|
||||
.innerJoin('groupUsers', 'groupUsers.groupId', 'spaceMembers.groupId')
|
||||
.select('groupUsers.userId')
|
||||
.where('spaceMembers.spaceId', '=', spaceId),
|
||||
);
|
||||
|
||||
await db
|
||||
.deleteFrom('watchers')
|
||||
.where('pageId', 'in', pageIds)
|
||||
.where('userId', 'not in', usersWithAccess)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deleteByUserAndWorkspace(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
opts?: { trx?: KyselyTransaction },
|
||||
): Promise<void> {
|
||||
const { trx } = opts;
|
||||
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.deleteFrom('watchers')
|
||||
.where('userId', '=', userId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
withUser(eb: ExpressionBuilder<DB, 'watchers'>) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('users')
|
||||
.select(['users.id', 'users.name', 'users.avatarUrl', 'users.email'])
|
||||
.whereRef('users.id', '=', 'watchers.userId'),
|
||||
).as('user');
|
||||
}
|
||||
}
|
||||
+30
@@ -362,6 +362,34 @@ export interface Workspaces {
|
||||
updatedAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
export interface Notifications {
|
||||
id: Generated<string>;
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
type: string;
|
||||
actorId: string | null;
|
||||
pageId: string | null;
|
||||
spaceId: string | null;
|
||||
commentId: string | null;
|
||||
data: Json | null;
|
||||
readAt: Timestamp | null;
|
||||
emailedAt: Timestamp | null;
|
||||
archivedAt: Timestamp | null;
|
||||
createdAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
export interface Watchers {
|
||||
id: Generated<string>;
|
||||
userId: string;
|
||||
pageId: string | null;
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
type: string;
|
||||
addedById: string | null;
|
||||
mutedAt: Timestamp | null;
|
||||
createdAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
export interface DB {
|
||||
apiKeys: ApiKeys;
|
||||
attachments: Attachments;
|
||||
@@ -373,6 +401,7 @@ export interface DB {
|
||||
fileTasks: FileTasks;
|
||||
groups: Groups;
|
||||
groupUsers: GroupUsers;
|
||||
notifications: Notifications;
|
||||
pageHistory: PageHistory;
|
||||
pages: Pages;
|
||||
shares: Shares;
|
||||
@@ -381,6 +410,7 @@ export interface DB {
|
||||
userMfa: UserMfa;
|
||||
users: Users;
|
||||
userTokens: UserTokens;
|
||||
watchers: Watchers;
|
||||
workspaceInvitations: WorkspaceInvitations;
|
||||
workspaces: Workspaces;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
FileTasks,
|
||||
Groups,
|
||||
GroupUsers,
|
||||
Notifications,
|
||||
PageHistory,
|
||||
Pages,
|
||||
Shares,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
UserMfa,
|
||||
Users,
|
||||
UserTokens,
|
||||
Watchers,
|
||||
WorkspaceInvitations,
|
||||
Workspaces,
|
||||
} from '@docmost/db/types/db';
|
||||
@@ -32,6 +34,7 @@ export interface DbInterface {
|
||||
fileTasks: FileTasks;
|
||||
groups: Groups;
|
||||
groupUsers: GroupUsers;
|
||||
notifications: Notifications;
|
||||
pageEmbeddings: PageEmbeddings;
|
||||
pageHistory: PageHistory;
|
||||
pages: Pages;
|
||||
@@ -41,6 +44,7 @@ export interface DbInterface {
|
||||
userMfa: UserMfa;
|
||||
users: Users;
|
||||
userTokens: UserTokens;
|
||||
watchers: Watchers;
|
||||
workspaceInvitations: WorkspaceInvitations;
|
||||
workspaces: Workspaces;
|
||||
apiKeys: ApiKeys;
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Attachments,
|
||||
Comments,
|
||||
Groups,
|
||||
Notifications,
|
||||
Pages,
|
||||
Spaces,
|
||||
Users,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
FileTasks,
|
||||
UserMfa as _UserMFA,
|
||||
ApiKeys,
|
||||
Watchers,
|
||||
} from './db';
|
||||
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
|
||||
|
||||
@@ -131,3 +133,13 @@ export type UpdatableApiKey = Updateable<Omit<ApiKeys, 'id'>>;
|
||||
export type PageEmbedding = Selectable<PageEmbeddings>;
|
||||
export type InsertablePageEmbedding = Insertable<PageEmbeddings>;
|
||||
export type UpdatablePageEmbedding = Updateable<Omit<PageEmbeddings, 'id'>>;
|
||||
|
||||
// Notification
|
||||
export type Notification = Selectable<Notifications>;
|
||||
export type InsertableNotification = Insertable<Notifications>;
|
||||
export type UpdatableNotification = Updateable<Omit<Notifications, 'id'>>;
|
||||
|
||||
// Watcher
|
||||
export type Watcher = Selectable<Watchers>;
|
||||
export type InsertableWatcher = Insertable<Watchers>;
|
||||
export type UpdatableWatcher = Updateable<Omit<Watchers, 'id'>>;
|
||||
|
||||
@@ -5,4 +5,5 @@ export interface MailMessage {
|
||||
text?: string;
|
||||
html?: string;
|
||||
template?: any;
|
||||
notificationId?: string;
|
||||
}
|
||||
|
||||
@@ -4,11 +4,15 @@ import { QueueName } from '../../queue/constants';
|
||||
import { Job } from 'bullmq';
|
||||
import { MailService } from '../mail.service';
|
||||
import { MailMessage } from '../interfaces/mail.message';
|
||||
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
|
||||
|
||||
@Processor(QueueName.EMAIL_QUEUE)
|
||||
export class EmailProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(EmailProcessor.name);
|
||||
constructor(private readonly mailService: MailService) {
|
||||
constructor(
|
||||
private readonly mailService: MailService,
|
||||
private readonly notificationRepo: NotificationRepo,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -18,6 +22,14 @@ export class EmailProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (job.data.notificationId) {
|
||||
try {
|
||||
await this.notificationRepo.markAsEmailed(job.data.notificationId);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to mark notification ${job.data.notificationId} as emailed`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OnWorkerEvent('active')
|
||||
|
||||
@@ -7,6 +7,7 @@ export enum QueueName {
|
||||
SEARCH_QUEUE = '{search-queue}',
|
||||
AI_QUEUE = '{ai-queue}',
|
||||
HISTORY_QUEUE = '{history-queue}',
|
||||
NOTIFICATION_QUEUE = '{notification-queue}',
|
||||
}
|
||||
|
||||
export enum QueueJob {
|
||||
@@ -19,6 +20,7 @@ export enum QueueJob {
|
||||
DELETE_USER_AVATARS = 'delete-user-avatars',
|
||||
|
||||
PAGE_BACKLINKS = 'page-backlinks',
|
||||
ADD_PAGE_WATCHERS = 'add-page-watchers',
|
||||
|
||||
STRIPE_SEATS_SYNC = 'sync-stripe-seats',
|
||||
TRIAL_ENDED = 'trial-ended',
|
||||
@@ -61,4 +63,8 @@ export enum QueueJob {
|
||||
DELETE_PAGE_EMBEDDINGS = 'delete-page-embeddings',
|
||||
|
||||
PAGE_HISTORY = 'page-history',
|
||||
|
||||
COMMENT_NOTIFICATION = 'comment-notification',
|
||||
COMMENT_RESOLVED_NOTIFICATION = 'comment-resolved-notification',
|
||||
PAGE_MENTION_NOTIFICATION = 'page-mention-notification',
|
||||
}
|
||||
|
||||
@@ -7,10 +7,56 @@ export interface IPageBacklinkJob {
|
||||
mentions: MentionNode[];
|
||||
}
|
||||
|
||||
export interface IAddPageWatchersJob {
|
||||
userIds: string[];
|
||||
pageId: string;
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface IStripeSeatsSyncJob {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface IPageHistoryJob {
|
||||
pageId: string;
|
||||
}
|
||||
}
|
||||
|
||||
export interface INotificationCreateJob {
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
type: string;
|
||||
actorId?: string;
|
||||
pageId?: string;
|
||||
spaceId?: string;
|
||||
commentId?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ICommentNotificationJob {
|
||||
commentId: string;
|
||||
parentCommentId?: string;
|
||||
pageId: string;
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
actorId: string;
|
||||
mentionedUserIds: string[];
|
||||
notifyWatchers: boolean;
|
||||
}
|
||||
|
||||
export interface ICommentResolvedNotificationJob {
|
||||
commentId: string;
|
||||
commentCreatorId: string;
|
||||
pageId: string;
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
actorId: string;
|
||||
}
|
||||
|
||||
export interface IPageMentionNotificationJob {
|
||||
userMentions: { userId: string; mentionId: string; creatorId: string }[];
|
||||
oldMentionedUserIds: string[];
|
||||
pageId: string;
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
import { Logger, OnModuleDestroy } from '@nestjs/common';
|
||||
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
|
||||
import { Job } from 'bullmq';
|
||||
import { QueueJob, QueueName } from '../constants';
|
||||
import { IPageBacklinkJob } from '../constants/queue.interface';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
|
||||
@Processor(QueueName.GENERAL_QUEUE)
|
||||
export class BacklinksProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(BacklinksProcessor.name);
|
||||
constructor(
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly backlinkRepo: BacklinkRepo,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async process(job: Job<IPageBacklinkJob, void>): Promise<void> {
|
||||
try {
|
||||
const { pageId, mentions, workspaceId } = job.data;
|
||||
|
||||
switch (job.name) {
|
||||
case QueueJob.PAGE_BACKLINKS:
|
||||
{
|
||||
await executeTx(this.db, async (trx) => {
|
||||
const existingBacklinks = await trx
|
||||
.selectFrom('backlinks')
|
||||
.select('targetPageId')
|
||||
.where('sourcePageId', '=', pageId)
|
||||
.execute();
|
||||
|
||||
if (existingBacklinks.length === 0 && mentions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingTargetPageIds = existingBacklinks.map(
|
||||
(backlink) => backlink.targetPageId,
|
||||
);
|
||||
|
||||
const targetPageIds = mentions
|
||||
.filter((mention) => mention.entityId !== pageId)
|
||||
.map((mention) => mention.entityId);
|
||||
|
||||
// make sure target pages belong to the same workspace
|
||||
let validTargetPages = [];
|
||||
if (targetPageIds.length > 0) {
|
||||
validTargetPages = await trx
|
||||
.selectFrom('pages')
|
||||
.select('id')
|
||||
.where('id', 'in', targetPageIds)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
const validTargetPageIds = validTargetPages.map(
|
||||
(page) => page.id,
|
||||
);
|
||||
|
||||
// new backlinks
|
||||
const backlinksToAdd = validTargetPageIds.filter(
|
||||
(id) => !existingTargetPageIds.includes(id),
|
||||
);
|
||||
|
||||
// stale backlinks
|
||||
const backlinksToRemove = existingTargetPageIds.filter(
|
||||
(existingId) => !validTargetPageIds.includes(existingId),
|
||||
);
|
||||
|
||||
// add new backlinks
|
||||
if (backlinksToAdd.length > 0) {
|
||||
const newBacklinks = backlinksToAdd.map((targetPageId) => ({
|
||||
sourcePageId: pageId,
|
||||
targetPageId: targetPageId,
|
||||
workspaceId: workspaceId,
|
||||
}));
|
||||
|
||||
await this.backlinkRepo.insertBacklink(newBacklinks, trx);
|
||||
this.logger.debug(
|
||||
`Added ${newBacklinks.length} new backlinks to ${pageId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// remove stale backlinks
|
||||
if (backlinksToRemove.length > 0) {
|
||||
await this.db
|
||||
.deleteFrom('backlinks')
|
||||
.where('sourcePageId', '=', pageId)
|
||||
.where('targetPageId', 'in', backlinksToRemove)
|
||||
.execute();
|
||||
|
||||
this.logger.debug(
|
||||
`Removed ${backlinksToRemove.length} outdated backlinks from ${pageId}.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@OnWorkerEvent('active')
|
||||
onActive(job: Job) {
|
||||
if (job.name === QueueJob.PAGE_BACKLINKS) {
|
||||
this.logger.debug(`Processing ${job.name} job`);
|
||||
}
|
||||
}
|
||||
|
||||
@OnWorkerEvent('failed')
|
||||
onError(job: Job) {
|
||||
if (job.name === QueueJob.PAGE_BACKLINKS) {
|
||||
this.logger.error(
|
||||
`Error processing ${job.name} job. Reason: ${job.failedReason}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@OnWorkerEvent('completed')
|
||||
onCompleted(job: Job) {
|
||||
if (job.name === QueueJob.PAGE_BACKLINKS) {
|
||||
this.logger.debug(`Completed ${job.name} job`);
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
if (this.worker) {
|
||||
await this.worker.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Logger, OnModuleDestroy } from '@nestjs/common';
|
||||
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
|
||||
import { Job } from 'bullmq';
|
||||
import { QueueJob, QueueName } from '../constants';
|
||||
import {
|
||||
IAddPageWatchersJob,
|
||||
IPageBacklinkJob,
|
||||
} from '../constants/queue.interface';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
|
||||
import {
|
||||
WatcherRepo,
|
||||
WatcherType,
|
||||
} from '@docmost/db/repos/watcher/watcher.repo';
|
||||
import { InsertableWatcher } from '@docmost/db/types/entity.types';
|
||||
import { processBacklinks } from '../tasks/backlinks.task';
|
||||
|
||||
@Processor(QueueName.GENERAL_QUEUE)
|
||||
export class GeneralQueueProcessor
|
||||
extends WorkerHost
|
||||
implements OnModuleDestroy
|
||||
{
|
||||
private readonly logger = new Logger(GeneralQueueProcessor.name);
|
||||
constructor(
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly backlinkRepo: BacklinkRepo,
|
||||
private readonly watcherRepo: WatcherRepo,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async process(job: Job): Promise<void> {
|
||||
try {
|
||||
switch (job.name) {
|
||||
case QueueJob.ADD_PAGE_WATCHERS: {
|
||||
const { userIds, pageId, spaceId, workspaceId } =
|
||||
job.data as IAddPageWatchersJob;
|
||||
const watchers: InsertableWatcher[] = userIds.map((userId) => ({
|
||||
userId,
|
||||
pageId,
|
||||
spaceId,
|
||||
workspaceId,
|
||||
type: WatcherType.PAGE,
|
||||
addedById: userId,
|
||||
}));
|
||||
await this.watcherRepo.insertMany(watchers);
|
||||
break;
|
||||
}
|
||||
|
||||
case QueueJob.PAGE_BACKLINKS: {
|
||||
await processBacklinks(
|
||||
this.db,
|
||||
this.backlinkRepo,
|
||||
job.data as IPageBacklinkJob,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@OnWorkerEvent('active')
|
||||
onActive(job: Job) {
|
||||
this.logger.debug(`Processing ${job.name} job`);
|
||||
}
|
||||
|
||||
@OnWorkerEvent('failed')
|
||||
onError(job: Job) {
|
||||
this.logger.error(
|
||||
`Error processing ${job.name} job. Reason: ${job.failedReason}`,
|
||||
);
|
||||
}
|
||||
|
||||
@OnWorkerEvent('completed')
|
||||
onCompleted(job: Job) {
|
||||
this.logger.debug(`Completed ${job.name} job`);
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
if (this.worker) {
|
||||
await this.worker.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { BullModule } from '@nestjs/bullmq';
|
||||
import { EnvironmentService } from '../environment/environment.service';
|
||||
import { createRetryStrategy, parseRedisUrl } from '../../common/helpers';
|
||||
import { QueueName } from './constants';
|
||||
import { BacklinksProcessor } from './processors/backlinks.processor';
|
||||
import { GeneralQueueProcessor } from './processors/general-queue.processor';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
@@ -81,8 +81,11 @@ import { BacklinksProcessor } from './processors/backlinks.processor';
|
||||
attempts: 2,
|
||||
},
|
||||
}),
|
||||
BullModule.registerQueue({
|
||||
name: QueueName.NOTIFICATION_QUEUE,
|
||||
}),
|
||||
],
|
||||
exports: [BullModule],
|
||||
providers: [BacklinksProcessor],
|
||||
providers: [GeneralQueueProcessor],
|
||||
})
|
||||
export class QueueModule {}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
|
||||
import { IPageBacklinkJob } from '../constants/queue.interface';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
|
||||
const logger = new Logger('BacklinksTask');
|
||||
|
||||
export async function processBacklinks(
|
||||
db: KyselyDB,
|
||||
backlinkRepo: BacklinkRepo,
|
||||
data: IPageBacklinkJob,
|
||||
): Promise<void> {
|
||||
const { pageId, mentions, workspaceId } = data;
|
||||
|
||||
await executeTx(db, async (trx) => {
|
||||
const existingBacklinks = await trx
|
||||
.selectFrom('backlinks')
|
||||
.select('targetPageId')
|
||||
.where('sourcePageId', '=', pageId)
|
||||
.execute();
|
||||
|
||||
if (existingBacklinks.length === 0 && mentions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingTargetPageIds = existingBacklinks.map(
|
||||
(backlink) => backlink.targetPageId,
|
||||
);
|
||||
|
||||
const targetPageIds = mentions
|
||||
.filter((mention) => mention.entityId !== pageId)
|
||||
.map((mention) => mention.entityId);
|
||||
|
||||
let validTargetPages = [];
|
||||
if (targetPageIds.length > 0) {
|
||||
validTargetPages = await trx
|
||||
.selectFrom('pages')
|
||||
.select('id')
|
||||
.where('id', 'in', targetPageIds)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
const validTargetPageIds = validTargetPages.map((page) => page.id);
|
||||
|
||||
const backlinksToAdd = validTargetPageIds.filter(
|
||||
(id) => !existingTargetPageIds.includes(id),
|
||||
);
|
||||
|
||||
const backlinksToRemove = existingTargetPageIds.filter(
|
||||
(existingId) => !validTargetPageIds.includes(existingId),
|
||||
);
|
||||
|
||||
if (backlinksToAdd.length > 0) {
|
||||
const newBacklinks = backlinksToAdd.map((targetPageId) => ({
|
||||
sourcePageId: pageId,
|
||||
targetPageId: targetPageId,
|
||||
workspaceId: workspaceId,
|
||||
}));
|
||||
|
||||
await backlinkRepo.insertBacklink(newBacklinks, trx);
|
||||
logger.debug(
|
||||
`Added ${newBacklinks.length} new backlinks to ${pageId}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (backlinksToRemove.length > 0) {
|
||||
await db
|
||||
.deleteFrom('backlinks')
|
||||
.where('sourcePageId', '=', pageId)
|
||||
.where('targetPageId', 'in', backlinksToRemove)
|
||||
.execute();
|
||||
|
||||
logger.debug(
|
||||
`Removed ${backlinksToRemove.length} outdated backlinks from ${pageId}.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Section, Text, Button } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
import { button, content, paragraph } from '../css/styles';
|
||||
import { MailBody } from '../partials/partials';
|
||||
|
||||
interface Props {
|
||||
actorName: string;
|
||||
pageTitle: string;
|
||||
pageUrl: string;
|
||||
}
|
||||
|
||||
export const CommentCreateEmail = ({
|
||||
actorName,
|
||||
pageTitle,
|
||||
pageUrl,
|
||||
}: Props) => {
|
||||
return (
|
||||
<MailBody>
|
||||
<Section style={content}>
|
||||
<Text style={paragraph}>Hi there,</Text>
|
||||
<Text style={paragraph}>
|
||||
<strong>{actorName}</strong> commented on{' '}
|
||||
<strong>{pageTitle}</strong>.
|
||||
</Text>
|
||||
</Section>
|
||||
<Section
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingLeft: '15px',
|
||||
paddingBottom: '15px',
|
||||
}}
|
||||
>
|
||||
<Button href={pageUrl} style={button}>
|
||||
View
|
||||
</Button>
|
||||
</Section>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentCreateEmail;
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Section, Text, Button } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
import { button, content, paragraph } from '../css/styles';
|
||||
import { MailBody } from '../partials/partials';
|
||||
|
||||
interface Props {
|
||||
actorName: string;
|
||||
pageTitle: string;
|
||||
pageUrl: string;
|
||||
}
|
||||
|
||||
export const CommentMentionEmail = ({
|
||||
actorName,
|
||||
pageTitle,
|
||||
pageUrl,
|
||||
}: Props) => {
|
||||
return (
|
||||
<MailBody>
|
||||
<Section style={content}>
|
||||
<Text style={paragraph}>Hi there,</Text>
|
||||
<Text style={paragraph}>
|
||||
<strong>{actorName}</strong> mentioned you in a comment on{' '}
|
||||
<strong>{pageTitle}</strong>.
|
||||
</Text>
|
||||
</Section>
|
||||
<Section
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingLeft: '15px',
|
||||
paddingBottom: '15px',
|
||||
}}
|
||||
>
|
||||
<Button href={pageUrl} style={button}>
|
||||
View
|
||||
</Button>
|
||||
</Section>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentMentionEmail;
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Section, Text, Button } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
import { button, content, paragraph } from '../css/styles';
|
||||
import { MailBody } from '../partials/partials';
|
||||
|
||||
interface Props {
|
||||
actorName: string;
|
||||
pageTitle: string;
|
||||
pageUrl: string;
|
||||
}
|
||||
|
||||
export const CommentResolvedEmail = ({
|
||||
actorName,
|
||||
pageTitle,
|
||||
pageUrl,
|
||||
}: Props) => {
|
||||
return (
|
||||
<MailBody>
|
||||
<Section style={content}>
|
||||
<Text style={paragraph}>Hi there,</Text>
|
||||
<Text style={paragraph}>
|
||||
<strong>{actorName}</strong> resolved a comment on{' '}
|
||||
<strong>{pageTitle}</strong>.
|
||||
</Text>
|
||||
</Section>
|
||||
<Section
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingLeft: '15px',
|
||||
paddingBottom: '15px',
|
||||
}}
|
||||
>
|
||||
<Button href={pageUrl} style={button}>
|
||||
View
|
||||
</Button>
|
||||
</Section>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentResolvedEmail;
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Section, Text, Button } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
import { button, content, paragraph } from '../css/styles';
|
||||
import { MailBody } from '../partials/partials';
|
||||
|
||||
interface Props {
|
||||
actorName: string;
|
||||
pageTitle: string;
|
||||
pageUrl: string;
|
||||
}
|
||||
|
||||
export const PageMentionEmail = ({ actorName, pageTitle, pageUrl }: Props) => {
|
||||
return (
|
||||
<MailBody>
|
||||
<Section style={content}>
|
||||
<Text style={paragraph}>Hi there,</Text>
|
||||
<Text style={paragraph}>
|
||||
<strong>{actorName}</strong> mentioned you in{' '}
|
||||
<strong>{pageTitle}</strong>.
|
||||
</Text>
|
||||
</Section>
|
||||
<Section
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingLeft: '15px',
|
||||
paddingBottom: '15px',
|
||||
}}
|
||||
>
|
||||
<Button href={pageUrl} style={button}>
|
||||
View
|
||||
</Button>
|
||||
</Section>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageMentionEmail;
|
||||
@@ -10,6 +10,7 @@ import { TransformHttpResponseInterceptor } from './common/interceptors/http-res
|
||||
import { WsRedisIoAdapter } from './ws/adapter/ws-redis.adapter';
|
||||
import fastifyMultipart from '@fastify/multipart';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { InternalLogFilter } from './common/logger/internal-log-filter';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestFastifyApplication>(
|
||||
@@ -24,10 +25,10 @@ async function bootstrap() {
|
||||
}),
|
||||
{
|
||||
rawBody: true,
|
||||
// disable Nest logger so pino handles all logs
|
||||
// captures NestJS internal errors
|
||||
logger: new InternalLogFilter(),
|
||||
// bufferLogs must be false else pino will fail
|
||||
// to log OnApplicationBootstrap logs
|
||||
logger: false,
|
||||
bufferLogs: false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -37,10 +37,11 @@ export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
|
||||
|
||||
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
|
||||
|
||||
const userRoom = `user-${userId}`;
|
||||
const workspaceRoom = `workspace-${workspaceId}`;
|
||||
const spaceRooms = userSpaceIds.map((id) => this.getSpaceRoomName(id));
|
||||
|
||||
client.join([workspaceRoom, ...spaceRooms]);
|
||||
client.join([userRoom, workspaceRoom, ...spaceRooms]);
|
||||
} catch (err) {
|
||||
client.emit('Unauthorized');
|
||||
client.disconnect();
|
||||
|
||||
@@ -5,5 +5,6 @@ import { TokenModule } from '../core/auth/token.module';
|
||||
@Module({
|
||||
imports: [TokenModule],
|
||||
providers: [WsGateway],
|
||||
exports: [WsGateway],
|
||||
})
|
||||
export class WsModule {}
|
||||
|
||||
Reference in New Issue
Block a user