mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
feat: watchers notification and email preferences
This commit is contained in:
@@ -674,6 +674,22 @@
|
|||||||
"<bold>{{name}}</bold> mentioned you on a page": "<bold>{{name}}</bold> mentioned you on a page.",
|
"<bold>{{name}}</bold> mentioned you on a page": "<bold>{{name}}</bold> mentioned you on a page.",
|
||||||
"<bold>{{name}}</bold> gave you edit access to a page": "<bold>{{name}}</bold> gave you edit access to a page.",
|
"<bold>{{name}}</bold> gave you edit access to a page": "<bold>{{name}}</bold> gave you edit access to a page.",
|
||||||
"<bold>{{name}}</bold> gave you view access to a page": "<bold>{{name}}</bold> gave you view access to a page.",
|
"<bold>{{name}}</bold> gave you view access to a page": "<bold>{{name}}</bold> gave you view access to a page.",
|
||||||
|
"<bold>{{name}}</bold> updated a page": "<bold>{{name}}</bold> updated a page.",
|
||||||
|
"Watch page": "Watch page",
|
||||||
|
"Stop watching": "Stop watching",
|
||||||
|
"Email notifications": "Email notifications",
|
||||||
|
"Page updates": "Page updates",
|
||||||
|
"Get notified when pages you watch are updated.": "Get notified when pages you watch are updated.",
|
||||||
|
"Page mentions": "Page mentions",
|
||||||
|
"Get notified when someone mentions you on a page.": "Get notified when someone mentions you on a page.",
|
||||||
|
"Comment mentions": "Comment mentions",
|
||||||
|
"Get notified when someone mentions you in a comment.": "Get notified when someone mentions you in a comment.",
|
||||||
|
"New comments": "New comments",
|
||||||
|
"Get notified about new comments on threads you participate in.": "Get notified about new comments on threads you participate in.",
|
||||||
|
"Resolved comments": "Resolved comments",
|
||||||
|
"Get notified when your comment is resolved.": "Get notified when your comment is resolved.",
|
||||||
|
"You are now watching this page": "You are now watching this page",
|
||||||
|
"You are no longer watching this page": "You are no longer watching this page",
|
||||||
"Today": "Today",
|
"Today": "Today",
|
||||||
"Yesterday": "Yesterday",
|
"Yesterday": "Yesterday",
|
||||||
"This week": "This week",
|
"This week": "This week",
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ export function NotificationItem({
|
|||||||
return notification.data?.role === "writer"
|
return notification.data?.role === "writer"
|
||||||
? "<bold>{{name}}</bold> gave you edit access to a page"
|
? "<bold>{{name}}</bold> gave you edit access to a page"
|
||||||
: "<bold>{{name}}</bold> gave you view access to a page";
|
: "<bold>{{name}}</bold> gave you view access to a page";
|
||||||
|
case "page.updated":
|
||||||
|
return "<bold>{{name}}</bold> updated a page";
|
||||||
default:
|
default:
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ export type NotificationType =
|
|||||||
| "comment.created"
|
| "comment.created"
|
||||||
| "comment.resolved"
|
| "comment.resolved"
|
||||||
| "page.user_mention"
|
| "page.user_mention"
|
||||||
| "page.permission_granted";
|
| "page.permission_granted"
|
||||||
|
| "page.updated";
|
||||||
|
|
||||||
export type INotification = {
|
export type INotification = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import {
|
|||||||
IconArrowRight,
|
IconArrowRight,
|
||||||
IconArrowsHorizontal,
|
IconArrowsHorizontal,
|
||||||
IconDots,
|
IconDots,
|
||||||
|
IconEye,
|
||||||
|
IconEyeOff,
|
||||||
IconFileExport,
|
IconFileExport,
|
||||||
IconHistory,
|
IconHistory,
|
||||||
IconLink,
|
IconLink,
|
||||||
@@ -40,6 +42,11 @@ import { PageStateSegmentedControl } from "@/features/user/components/page-state
|
|||||||
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
||||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||||
import { PageShareModal } from "@/ee/page-permission";
|
import { PageShareModal } from "@/ee/page-permission";
|
||||||
|
import {
|
||||||
|
useWatchStatusQuery,
|
||||||
|
useWatchPageMutation,
|
||||||
|
useUnwatchPageMutation,
|
||||||
|
} from "@/features/page/queries/watcher-query";
|
||||||
|
|
||||||
interface PageHeaderMenuProps {
|
interface PageHeaderMenuProps {
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
@@ -123,6 +130,9 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
] = useDisclosure(false);
|
] = useDisclosure(false);
|
||||||
const [pageEditor] = useAtom(pageEditorAtom);
|
const [pageEditor] = useAtom(pageEditorAtom);
|
||||||
const pageUpdatedAt = useTimeAgo(page?.updatedAt);
|
const pageUpdatedAt = useTimeAgo(page?.updatedAt);
|
||||||
|
const { data: watchStatus } = useWatchStatusQuery(page?.id);
|
||||||
|
const watchPage = useWatchPageMutation();
|
||||||
|
const unwatchPage = useUnwatchPageMutation();
|
||||||
|
|
||||||
const handleCopyLink = () => {
|
const handleCopyLink = () => {
|
||||||
const pageUrl =
|
const pageUrl =
|
||||||
@@ -185,6 +195,23 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
>
|
>
|
||||||
{t("Copy as Markdown")}
|
{t("Copy as Markdown")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
|
{watchStatus?.watching ? (
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconEyeOff size={16} />}
|
||||||
|
onClick={() => unwatchPage.mutate(page.id)}
|
||||||
|
>
|
||||||
|
{t("Stop watching")}
|
||||||
|
</Menu.Item>
|
||||||
|
) : (
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconEye size={16} />}
|
||||||
|
onClick={() => watchPage.mutate(page.id)}
|
||||||
|
>
|
||||||
|
{t("Watch page")}
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|
||||||
<Menu.Item leftSection={<IconArrowsHorizontal size={16} />}>
|
<Menu.Item leftSection={<IconArrowsHorizontal size={16} />}>
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
watchPage,
|
||||||
|
unwatchPage,
|
||||||
|
getWatchStatus,
|
||||||
|
} from "@/features/page/services/watcher-service";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
const WATCHER_KEY = "watcher";
|
||||||
|
|
||||||
|
export function useWatchStatusQuery(pageId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [WATCHER_KEY, pageId],
|
||||||
|
queryFn: () => getWatchStatus(pageId),
|
||||||
|
enabled: !!pageId,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWatchPageMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (pageId: string) => watchPage(pageId),
|
||||||
|
onSuccess: (_data, pageId) => {
|
||||||
|
queryClient.setQueryData([WATCHER_KEY, pageId], { watching: true });
|
||||||
|
notifications.show({ message: t("You are now watching this page") });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUnwatchPageMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (pageId: string) => unwatchPage(pageId),
|
||||||
|
onSuccess: (_data, pageId) => {
|
||||||
|
queryClient.setQueryData([WATCHER_KEY, pageId], { watching: false });
|
||||||
|
notifications.show({ message: t("You are no longer watching this page") });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import api from "@/lib/api-client";
|
||||||
|
|
||||||
|
export async function watchPage(pageId: string): Promise<{ watching: boolean }> {
|
||||||
|
const req = await api.post<{ watching: boolean }>("/pages/watch", { pageId });
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unwatchPage(pageId: string): Promise<{ watching: boolean }> {
|
||||||
|
const req = await api.post<{ watching: boolean }>("/pages/unwatch", { pageId });
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWatchStatus(pageId: string): Promise<{ watching: boolean }> {
|
||||||
|
const req = await api.post<{ watching: boolean }>("/pages/watch-status", { pageId });
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
|
import { updateUser } from "@/features/user/services/user-service.ts";
|
||||||
|
import { IUser, IUserSettings } from "@/features/user/types/user.types.ts";
|
||||||
|
import { Switch, Text, Title, Stack } from "@mantine/core";
|
||||||
|
import { useAtom } from "jotai/index";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ResponsiveSettingsRow,
|
||||||
|
ResponsiveSettingsContent,
|
||||||
|
ResponsiveSettingsControl,
|
||||||
|
} from "@/components/ui/responsive-settings-row";
|
||||||
|
|
||||||
|
type NotificationKey = keyof NonNullable<IUserSettings["notifications"]>;
|
||||||
|
|
||||||
|
const notificationItems: {
|
||||||
|
key: NotificationKey;
|
||||||
|
dtoField: keyof IUser;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
key: "page.updated",
|
||||||
|
dtoField: "notificationPageUpdates",
|
||||||
|
label: "Page updates",
|
||||||
|
description: "Get notified when pages you watch are updated.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "page.user_mention",
|
||||||
|
dtoField: "notificationPageUserMention",
|
||||||
|
label: "Page mentions",
|
||||||
|
description: "Get notified when someone mentions you on a page.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "comment.user_mention",
|
||||||
|
dtoField: "notificationCommentUserMention",
|
||||||
|
label: "Comment mentions",
|
||||||
|
description: "Get notified when someone mentions you in a comment.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "comment.created",
|
||||||
|
dtoField: "notificationCommentCreated",
|
||||||
|
label: "New comments",
|
||||||
|
description: "Get notified about new comments on threads you participate in.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "comment.resolved",
|
||||||
|
dtoField: "notificationCommentResolved",
|
||||||
|
label: "Resolved comments",
|
||||||
|
description: "Get notified when your comment is resolved.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function NotificationToggle({
|
||||||
|
settingKey,
|
||||||
|
dtoField,
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
}: {
|
||||||
|
settingKey: NotificationKey;
|
||||||
|
dtoField: keyof IUser;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [user, setUser] = useAtom(userAtom);
|
||||||
|
const [checked, setChecked] = useState(
|
||||||
|
user.settings?.notifications?.[settingKey] !== false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = event.currentTarget.checked;
|
||||||
|
setChecked(value);
|
||||||
|
try {
|
||||||
|
const updatedUser = await updateUser({ [dtoField]: value } as any);
|
||||||
|
setUser(updatedUser);
|
||||||
|
} catch {
|
||||||
|
setChecked(!value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveSettingsRow>
|
||||||
|
<ResponsiveSettingsContent>
|
||||||
|
<Text size="md">{t(label)}</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t(description)}
|
||||||
|
</Text>
|
||||||
|
</ResponsiveSettingsContent>
|
||||||
|
|
||||||
|
<ResponsiveSettingsControl>
|
||||||
|
<Switch checked={checked} onChange={handleChange} />
|
||||||
|
</ResponsiveSettingsControl>
|
||||||
|
</ResponsiveSettingsRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NotificationPref() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Title order={5}>{t("Email notifications")}</Title>
|
||||||
|
|
||||||
|
{notificationItems.map((item) => (
|
||||||
|
<NotificationToggle
|
||||||
|
key={item.key}
|
||||||
|
settingKey={item.key}
|
||||||
|
dtoField={item.dtoField}
|
||||||
|
label={item.label}
|
||||||
|
description={item.description}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,6 +20,11 @@ export interface IUser {
|
|||||||
deletedAt: Date;
|
deletedAt: Date;
|
||||||
fullPageWidth: boolean; // used for update
|
fullPageWidth: boolean; // used for update
|
||||||
pageEditMode: string; // used for update
|
pageEditMode: string; // used for update
|
||||||
|
notificationPageUpdates: boolean; // used for update
|
||||||
|
notificationPageUserMention: boolean; // used for update
|
||||||
|
notificationCommentUserMention: boolean; // used for update
|
||||||
|
notificationCommentCreated: boolean; // used for update
|
||||||
|
notificationCommentResolved: boolean; // used for update
|
||||||
hasGeneratedPassword?: boolean;
|
hasGeneratedPassword?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +38,13 @@ export interface IUserSettings {
|
|||||||
fullPageWidth: boolean;
|
fullPageWidth: boolean;
|
||||||
pageEditMode: string;
|
pageEditMode: string;
|
||||||
};
|
};
|
||||||
|
notifications?: {
|
||||||
|
'page.updated'?: boolean;
|
||||||
|
'page.user_mention'?: boolean;
|
||||||
|
'comment.user_mention'?: boolean;
|
||||||
|
'comment.created'?: boolean;
|
||||||
|
'comment.resolved'?: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum PageEditMode {
|
export enum PageEditMode {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import AccountLanguage from "@/features/user/components/account-language.tsx";
|
|||||||
import AccountTheme from "@/features/user/components/account-theme.tsx";
|
import AccountTheme from "@/features/user/components/account-theme.tsx";
|
||||||
import PageWidthPref from "@/features/user/components/page-width-pref.tsx";
|
import PageWidthPref from "@/features/user/components/page-width-pref.tsx";
|
||||||
import PageEditPref from "@/features/user/components/page-state-pref";
|
import PageEditPref from "@/features/user/components/page-state-pref";
|
||||||
|
import NotificationPref from "@/features/user/components/notification-pref";
|
||||||
import { getAppName } from "@/lib/config.ts";
|
import { getAppName } from "@/lib/config.ts";
|
||||||
import { Divider } from "@mantine/core";
|
import { Divider } from "@mantine/core";
|
||||||
import { Helmet } from "react-helmet-async";
|
import { Helmet } from "react-helmet-async";
|
||||||
@@ -33,6 +34,10 @@ export default function AccountPreferences() {
|
|||||||
<Divider my={"md"} />
|
<Divider my={"md"} />
|
||||||
|
|
||||||
<PageEditPref />
|
<PageEditPref />
|
||||||
|
|
||||||
|
<Divider my={"md"} />
|
||||||
|
|
||||||
|
<NotificationPref />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import { Logger, OnModuleDestroy } from '@nestjs/common';
|
import { Logger, OnModuleDestroy } from '@nestjs/common';
|
||||||
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
|
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
|
||||||
import { Job } from 'bullmq';
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
|
import { Job, Queue } from 'bullmq';
|
||||||
import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
||||||
import { IPageHistoryJob } from '../../integrations/queue/constants/queue.interface';
|
import {
|
||||||
|
IPageHistoryJob,
|
||||||
|
IPageUpdateNotificationJob,
|
||||||
|
} from '../../integrations/queue/constants/queue.interface';
|
||||||
import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
|
import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
import { isDeepStrictEqual } from 'node:util';
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
import { CollabHistoryService } from '../services/collab-history.service';
|
import { CollabHistoryService } from '../services/collab-history.service';
|
||||||
import { WatcherService } from '../../core/watcher/watcher.service';
|
import { WatcherService } from '../../core/watcher/watcher.service';
|
||||||
|
import { NotificationType } from '../../core/notification/notification.constants';
|
||||||
|
|
||||||
@Processor(QueueName.HISTORY_QUEUE)
|
@Processor(QueueName.HISTORY_QUEUE)
|
||||||
export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
||||||
@@ -18,6 +23,7 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
|||||||
private readonly pageRepo: PageRepo,
|
private readonly pageRepo: PageRepo,
|
||||||
private readonly collabHistory: CollabHistoryService,
|
private readonly collabHistory: CollabHistoryService,
|
||||||
private readonly watcherService: WatcherService,
|
private readonly watcherService: WatcherService,
|
||||||
|
@InjectQueue(QueueName.NOTIFICATION_QUEUE) private notificationQueue: Queue,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
@@ -47,8 +53,7 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
|||||||
!lastHistory ||
|
!lastHistory ||
|
||||||
!isDeepStrictEqual(lastHistory.content, page.content)
|
!isDeepStrictEqual(lastHistory.content, page.content)
|
||||||
) {
|
) {
|
||||||
const contributorIds =
|
const contributorIds = await this.collabHistory.popContributors(pageId);
|
||||||
await this.collabHistory.popContributors(pageId);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.watcherService.addPageWatchers(
|
await this.watcherService.addPageWatchers(
|
||||||
@@ -61,12 +66,24 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
|||||||
await this.pageHistoryRepo.saveHistory(page, { contributorIds });
|
await this.pageHistoryRepo.saveHistory(page, { contributorIds });
|
||||||
this.logger.debug(`History created for page: ${pageId}`);
|
this.logger.debug(`History created for page: ${pageId}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await this.collabHistory.addContributors(
|
await this.collabHistory.addContributors(pageId, contributorIds);
|
||||||
pageId,
|
|
||||||
contributorIds,
|
|
||||||
);
|
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (contributorIds.length > 0 && lastHistory?.content) {
|
||||||
|
await this.notificationQueue
|
||||||
|
.add(NotificationType.PAGE_UPDATED, {
|
||||||
|
pageId,
|
||||||
|
spaceId: page.spaceId,
|
||||||
|
workspaceId: page.workspaceId,
|
||||||
|
actorIds: contributorIds,
|
||||||
|
} as IPageUpdateNotificationJob)
|
||||||
|
.catch((err) => {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to queue page update notification for ${pageId}: ${err.message}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export const NotificationType = {
|
|||||||
COMMENT_RESOLVED: 'comment.resolved',
|
COMMENT_RESOLVED: 'comment.resolved',
|
||||||
PAGE_USER_MENTION: 'page.user_mention',
|
PAGE_USER_MENTION: 'page.user_mention',
|
||||||
PAGE_PERMISSION_GRANTED: 'page.permission_granted',
|
PAGE_PERMISSION_GRANTED: 'page.permission_granted',
|
||||||
|
PAGE_UPDATED: 'page.updated',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type NotificationType =
|
export type NotificationType =
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import {
|
|||||||
ICommentNotificationJob,
|
ICommentNotificationJob,
|
||||||
ICommentResolvedNotificationJob,
|
ICommentResolvedNotificationJob,
|
||||||
IPageMentionNotificationJob,
|
IPageMentionNotificationJob,
|
||||||
|
IPageUpdateNotificationJob,
|
||||||
IPermissionGrantedNotificationJob,
|
IPermissionGrantedNotificationJob,
|
||||||
} from '../../integrations/queue/constants/queue.interface';
|
} from '../../integrations/queue/constants/queue.interface';
|
||||||
import { CommentNotificationService } from './services/comment.notification';
|
import { CommentNotificationService } from './services/comment.notification';
|
||||||
import { PageNotificationService } from './services/page.notification';
|
import { PageNotificationService } from './services/page.notification';
|
||||||
import { DomainService } from '../../integrations/environment/domain.service';
|
import { DomainService } from '../../integrations/environment/domain.service';
|
||||||
|
import { NotificationType } from './notification.constants';
|
||||||
|
|
||||||
@Processor(QueueName.NOTIFICATION_QUEUE)
|
@Processor(QueueName.NOTIFICATION_QUEUE)
|
||||||
export class NotificationProcessor
|
export class NotificationProcessor
|
||||||
@@ -35,6 +37,7 @@ export class NotificationProcessor
|
|||||||
| ICommentNotificationJob
|
| ICommentNotificationJob
|
||||||
| ICommentResolvedNotificationJob
|
| ICommentResolvedNotificationJob
|
||||||
| IPageMentionNotificationJob
|
| IPageMentionNotificationJob
|
||||||
|
| IPageUpdateNotificationJob
|
||||||
| IPermissionGrantedNotificationJob,
|
| IPermissionGrantedNotificationJob,
|
||||||
void
|
void
|
||||||
>,
|
>,
|
||||||
@@ -76,6 +79,14 @@ export class NotificationProcessor
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case NotificationType.PAGE_UPDATED: {
|
||||||
|
await this.pageNotificationService.processPageUpdate(
|
||||||
|
job.data as IPageUpdateNotificationJob,
|
||||||
|
appUrl,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
this.logger.warn(`Unknown notification job: ${job.name}`);
|
this.logger.warn(`Unknown notification job: ${job.name}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { InsertableNotification } from '@docmost/db/types/entity.types';
|
|||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
import { WsGateway } from '../../ws/ws.gateway';
|
import { WsGateway } from '../../ws/ws.gateway';
|
||||||
import { MailService } from '../../integrations/mail/mail.service';
|
import { MailService } from '../../integrations/mail/mail.service';
|
||||||
|
import { NotificationType } from './notification.constants';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NotificationService {
|
export class NotificationService {
|
||||||
@@ -19,6 +20,16 @@ export class NotificationService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(data: InsertableNotification) {
|
async create(data: InsertableNotification) {
|
||||||
|
const user = await this.db
|
||||||
|
.selectFrom('users')
|
||||||
|
.select(['id'])
|
||||||
|
.where('id', '=', data.userId)
|
||||||
|
.where('deletedAt', 'is', null)
|
||||||
|
.where('deactivatedAt', 'is', null)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
const notification = await this.notificationRepo.insert(data);
|
const notification = await this.notificationRepo.insert(data);
|
||||||
|
|
||||||
this.wsGateway.server
|
this.wsGateway.server
|
||||||
@@ -53,17 +64,24 @@ export class NotificationService {
|
|||||||
notificationId: string,
|
notificationId: string,
|
||||||
subject: string,
|
subject: string,
|
||||||
template: any,
|
template: any,
|
||||||
|
type?: NotificationType,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const user = await this.db
|
const user = await this.db
|
||||||
.selectFrom('users')
|
.selectFrom('users')
|
||||||
.select(['email'])
|
.select(['email', 'settings'])
|
||||||
.where('id', '=', userId)
|
.where('id', '=', userId)
|
||||||
.where('deletedAt', 'is', null)
|
.where('deletedAt', 'is', null)
|
||||||
|
.where('deactivatedAt', 'is', null)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
if (!user?.email) return;
|
if (!user?.email) return;
|
||||||
|
|
||||||
|
if (type) {
|
||||||
|
const settings = user.settings as any;
|
||||||
|
if (settings?.notifications?.[type] === false) return;
|
||||||
|
}
|
||||||
|
|
||||||
await this.mailService.sendToQueue({
|
await this.mailService.sendToQueue({
|
||||||
to: user.email,
|
to: user.email,
|
||||||
subject,
|
subject,
|
||||||
|
|||||||
@@ -86,12 +86,14 @@ export class CommentNotificationService {
|
|||||||
spaceId,
|
spaceId,
|
||||||
commentId,
|
commentId,
|
||||||
});
|
});
|
||||||
|
if (!notification) continue;
|
||||||
|
|
||||||
await this.notificationService.queueEmail(
|
await this.notificationService.queueEmail(
|
||||||
userId,
|
userId,
|
||||||
notification.id,
|
notification.id,
|
||||||
`${actor.name} mentioned you in a comment`,
|
`${actor.name} mentioned you in a comment`,
|
||||||
CommentMentionEmail({ actorName: actor.name, pageTitle, pageUrl }),
|
CommentMentionEmail({ actorName: actor.name, pageTitle, pageUrl }),
|
||||||
|
NotificationType.COMMENT_USER_MENTION,
|
||||||
);
|
);
|
||||||
|
|
||||||
notifiedUserIds.add(userId);
|
notifiedUserIds.add(userId);
|
||||||
@@ -110,12 +112,14 @@ export class CommentNotificationService {
|
|||||||
spaceId,
|
spaceId,
|
||||||
commentId,
|
commentId,
|
||||||
});
|
});
|
||||||
|
if (!notification) continue;
|
||||||
|
|
||||||
await this.notificationService.queueEmail(
|
await this.notificationService.queueEmail(
|
||||||
recipientId,
|
recipientId,
|
||||||
notification.id,
|
notification.id,
|
||||||
`${actor.name} commented on ${pageTitle}`,
|
`${actor.name} commented on ${pageTitle}`,
|
||||||
CommentCreateEmail({ actorName: actor.name, pageTitle, pageUrl }),
|
CommentCreateEmail({ actorName: actor.name, pageTitle, pageUrl }),
|
||||||
|
NotificationType.COMMENT_CREATED,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,6 +175,7 @@ export class CommentNotificationService {
|
|||||||
spaceId,
|
spaceId,
|
||||||
commentId,
|
commentId,
|
||||||
});
|
});
|
||||||
|
if (!notification) return;
|
||||||
|
|
||||||
const subject = `${actor.name} resolved a comment on ${pageTitle}`;
|
const subject = `${actor.name} resolved a comment on ${pageTitle}`;
|
||||||
|
|
||||||
@@ -179,6 +184,7 @@ export class CommentNotificationService {
|
|||||||
notification.id,
|
notification.id,
|
||||||
subject,
|
subject,
|
||||||
CommentResolvedEmail({ actorName: actor.name, pageTitle, pageUrl }),
|
CommentResolvedEmail({ actorName: actor.name, pageTitle, pageUrl }),
|
||||||
|
NotificationType.COMMENT_RESOLVED,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,23 +3,31 @@ import { InjectKysely } from 'nestjs-kysely';
|
|||||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
import {
|
import {
|
||||||
IPageMentionNotificationJob,
|
IPageMentionNotificationJob,
|
||||||
|
IPageUpdateNotificationJob,
|
||||||
IPermissionGrantedNotificationJob,
|
IPermissionGrantedNotificationJob,
|
||||||
} from '../../../integrations/queue/constants/queue.interface';
|
} from '../../../integrations/queue/constants/queue.interface';
|
||||||
import { NotificationService } from '../notification.service';
|
import { NotificationService } from '../notification.service';
|
||||||
import { NotificationType } from '../notification.constants';
|
import { NotificationType } from '../notification.constants';
|
||||||
|
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
|
||||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||||
|
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||||
import { PageMentionEmail } from '@docmost/transactional/emails/page-mention-email';
|
import { PageMentionEmail } from '@docmost/transactional/emails/page-mention-email';
|
||||||
|
import { PageUpdateEmail } from '@docmost/transactional/emails/page-update-email';
|
||||||
import { PermissionGrantedEmail } from '@docmost/transactional/emails/permission-granted-email';
|
import { PermissionGrantedEmail } from '@docmost/transactional/emails/permission-granted-email';
|
||||||
import { getPageTitle } from '../../../common/helpers';
|
import { getPageTitle } from '../../../common/helpers';
|
||||||
|
|
||||||
|
const PAGE_UPDATE_COOLDOWN_HOURS = 7;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PageNotificationService {
|
export class PageNotificationService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
private readonly notificationService: NotificationService,
|
private readonly notificationService: NotificationService,
|
||||||
|
private readonly notificationRepo: NotificationRepo,
|
||||||
private readonly spaceMemberRepo: SpaceMemberRepo,
|
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||||
|
private readonly watcherRepo: WatcherRepo,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async processPageMention(data: IPageMentionNotificationJob, appUrl: string) {
|
async processPageMention(data: IPageMentionNotificationJob, appUrl: string) {
|
||||||
@@ -41,10 +49,9 @@ export class PageNotificationService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const usersWithPageAccess =
|
const usersWithPageAccess =
|
||||||
await this.pagePermissionRepo.getUserIdsWithPageAccess(
|
await this.pagePermissionRepo.getUserIdsWithPageAccess(pageId, [
|
||||||
pageId,
|
...usersWithSpaceAccess,
|
||||||
[...usersWithSpaceAccess],
|
]);
|
||||||
);
|
|
||||||
const usersWithAccess = new Set(usersWithPageAccess);
|
const usersWithAccess = new Set(usersWithPageAccess);
|
||||||
|
|
||||||
const accessibleMentions = newMentions.filter((m) =>
|
const accessibleMentions = newMentions.filter((m) =>
|
||||||
@@ -97,6 +104,7 @@ export class PageNotificationService {
|
|||||||
spaceId,
|
spaceId,
|
||||||
data: { mentionId },
|
data: { mentionId },
|
||||||
});
|
});
|
||||||
|
if (!notification) continue;
|
||||||
|
|
||||||
const pageUrl = `${basePageUrl}`;
|
const pageUrl = `${basePageUrl}`;
|
||||||
const subject = `${actor.name} mentioned you in ${pageTitle}`;
|
const subject = `${actor.name} mentioned you in ${pageTitle}`;
|
||||||
@@ -106,6 +114,7 @@ export class PageNotificationService {
|
|||||||
notification.id,
|
notification.id,
|
||||||
subject,
|
subject,
|
||||||
PageMentionEmail({ actorName: actor.name, pageTitle, pageUrl }),
|
PageMentionEmail({ actorName: actor.name, pageTitle, pageUrl }),
|
||||||
|
NotificationType.PAGE_USER_MENTION,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,6 +148,7 @@ export class PageNotificationService {
|
|||||||
spaceId,
|
spaceId,
|
||||||
data: { role },
|
data: { role },
|
||||||
});
|
});
|
||||||
|
if (!notification) continue;
|
||||||
|
|
||||||
const subject = `${actor.name} gave you ${accessLabel} access to ${pageTitle}`;
|
const subject = `${actor.name} gave you ${accessLabel} access to ${pageTitle}`;
|
||||||
|
|
||||||
@@ -156,6 +166,95 @@ export class PageNotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async processPageUpdate(data: IPageUpdateNotificationJob, appUrl: string) {
|
||||||
|
const { pageId, spaceId, workspaceId, actorIds } = data;
|
||||||
|
|
||||||
|
const watcherIds = await this.watcherRepo.getPageWatcherIds(pageId);
|
||||||
|
if (watcherIds.length === 0) return;
|
||||||
|
|
||||||
|
const actorSet = new Set(actorIds);
|
||||||
|
const candidateIds = watcherIds.filter((id) => !actorSet.has(id));
|
||||||
|
if (candidateIds.length === 0) return;
|
||||||
|
|
||||||
|
const afterPrefs = await this.getEligiblePageUpdateUserIds(candidateIds);
|
||||||
|
if (afterPrefs.length === 0) return;
|
||||||
|
|
||||||
|
const recentlyNotified =
|
||||||
|
await this.notificationRepo.getRecentlyNotifiedUserIds(
|
||||||
|
afterPrefs,
|
||||||
|
pageId,
|
||||||
|
NotificationType.PAGE_UPDATED,
|
||||||
|
PAGE_UPDATE_COOLDOWN_HOURS,
|
||||||
|
);
|
||||||
|
const afterCooldown = afterPrefs.filter((id) => !recentlyNotified.has(id));
|
||||||
|
if (afterCooldown.length === 0) return;
|
||||||
|
|
||||||
|
const usersWithSpaceAccess =
|
||||||
|
await this.spaceMemberRepo.getUserIdsWithSpaceAccess(
|
||||||
|
afterCooldown,
|
||||||
|
spaceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const usersWithPageAccess =
|
||||||
|
await this.pagePermissionRepo.getUserIdsWithPageAccess(pageId, [
|
||||||
|
...usersWithSpaceAccess,
|
||||||
|
]);
|
||||||
|
if (usersWithPageAccess.length === 0) return;
|
||||||
|
|
||||||
|
const recipientIds = new Set(usersWithPageAccess);
|
||||||
|
const actorId = actorIds[0];
|
||||||
|
|
||||||
|
const context = await this.getPageContext(actorId, pageId, spaceId, appUrl);
|
||||||
|
if (!context) return;
|
||||||
|
|
||||||
|
const { actor, pageTitle, basePageUrl } = context;
|
||||||
|
|
||||||
|
for (const userId of recipientIds) {
|
||||||
|
const notification = await this.notificationService.create({
|
||||||
|
userId,
|
||||||
|
workspaceId,
|
||||||
|
type: NotificationType.PAGE_UPDATED,
|
||||||
|
actorId,
|
||||||
|
pageId,
|
||||||
|
spaceId,
|
||||||
|
});
|
||||||
|
if (!notification) continue;
|
||||||
|
|
||||||
|
await this.notificationService.queueEmail(
|
||||||
|
userId,
|
||||||
|
notification.id,
|
||||||
|
`${actor.name} updated ${pageTitle}`,
|
||||||
|
PageUpdateEmail({
|
||||||
|
actorName: actor.name,
|
||||||
|
pageTitle,
|
||||||
|
pageUrl: basePageUrl,
|
||||||
|
}),
|
||||||
|
NotificationType.PAGE_UPDATED,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getEligiblePageUpdateUserIds(
|
||||||
|
userIds: string[],
|
||||||
|
): Promise<string[]> {
|
||||||
|
if (userIds.length === 0) return [];
|
||||||
|
|
||||||
|
const users = await this.db
|
||||||
|
.selectFrom('users')
|
||||||
|
.select(['id', 'settings'])
|
||||||
|
.where('id', 'in', userIds)
|
||||||
|
.where('deletedAt', 'is', null)
|
||||||
|
.where('deactivatedAt', 'is', null)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return users
|
||||||
|
.filter((u) => {
|
||||||
|
const settings = u.settings as any;
|
||||||
|
return settings?.notifications?.['page.updated'] !== false;
|
||||||
|
})
|
||||||
|
.map((u) => u.id);
|
||||||
|
}
|
||||||
|
|
||||||
private async getPageContext(
|
private async getPageContext(
|
||||||
actorId: string,
|
actorId: string,
|
||||||
pageId: string,
|
pageId: string,
|
||||||
|
|||||||
@@ -35,4 +35,24 @@ export class UpdateUserDto extends PartialType(
|
|||||||
@MaxLength(70)
|
@MaxLength(70)
|
||||||
@IsString()
|
@IsString()
|
||||||
confirmPassword: string;
|
confirmPassword: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
notificationPageUpdates: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
notificationPageUserMention: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
notificationCommentUserMention: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
notificationCommentCreated: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
notificationCommentResolved: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,24 @@ export class UserService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const notificationSettings: Record<string, string> = {
|
||||||
|
notificationPageUpdates: 'page.updated',
|
||||||
|
notificationPageUserMention: 'page.user_mention',
|
||||||
|
notificationCommentUserMention: 'comment.user_mention',
|
||||||
|
notificationCommentCreated: 'comment.created',
|
||||||
|
notificationCommentResolved: 'comment.resolved',
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [dtoField, settingKey] of Object.entries(notificationSettings)) {
|
||||||
|
if (typeof updateUserDto[dtoField] !== 'undefined') {
|
||||||
|
return this.userRepo.updateNotificationSetting(
|
||||||
|
userId,
|
||||||
|
settingKey as any,
|
||||||
|
updateUserDto[dtoField],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const userBefore = { name: user.name, email: user.email, locale: user.locale };
|
const userBefore = { name: user.name, email: user.email, locale: user.locale };
|
||||||
|
|
||||||
if (updateUserDto.name) {
|
if (updateUserDto.name) {
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
/***
|
import {
|
||||||
import {
|
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
ForbiddenException,
|
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
@@ -16,12 +14,7 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
|||||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||||
import { WatcherPageDto } from './dto/watcher.dto';
|
import { WatcherPageDto } from './dto/watcher.dto';
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
import { PageAccessService } from '../page/page-access/page-access.service';
|
||||||
import {
|
|
||||||
SpaceCaslAction,
|
|
||||||
SpaceCaslSubject,
|
|
||||||
} from '../casl/interfaces/space-ability.type';
|
|
||||||
|
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('pages')
|
@Controller('pages')
|
||||||
@@ -29,7 +22,7 @@ export class WatcherController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly watcherService: WatcherService,
|
private readonly watcherService: WatcherService,
|
||||||
private readonly pageRepo: PageRepo,
|
private readonly pageRepo: PageRepo,
|
||||||
private readonly spaceAbility: SpaceAbilityFactory,
|
private readonly pageAccessService: PageAccessService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@@ -44,10 +37,7 @@ export class WatcherController {
|
|||||||
throw new NotFoundException('Page not found');
|
throw new NotFoundException('Page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
await this.pageAccessService.validateCanView(page, user);
|
||||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.watcherService.watchPage(
|
await this.watcherService.watchPage(
|
||||||
user.id,
|
user.id,
|
||||||
@@ -67,10 +57,7 @@ export class WatcherController {
|
|||||||
throw new NotFoundException('Page not found');
|
throw new NotFoundException('Page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
await this.pageAccessService.validateCanView(page, user);
|
||||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.watcherService.unwatchPage(user.id, page.id);
|
await this.watcherService.unwatchPage(user.id, page.id);
|
||||||
|
|
||||||
@@ -85,15 +72,10 @@ export class WatcherController {
|
|||||||
throw new NotFoundException('Page not found');
|
throw new NotFoundException('Page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
await this.pageAccessService.validateCanView(page, user);
|
||||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
|
|
||||||
const watching = await this.watcherService.isWatchingPage(user.id, page.id);
|
const watching = await this.watcherService.isWatchingPage(user.id, page.id);
|
||||||
|
|
||||||
return { watching };
|
return { watching };
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
***/
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { WatcherService } from './watcher.service';
|
import { WatcherService } from './watcher.service';
|
||||||
import { CaslModule } from '../casl/casl.module';
|
import { WatcherController } from './watcher.controller';
|
||||||
|
import { PageAccessModule } from '../page/page-access/page-access.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CaslModule],
|
imports: [PageAccessModule],
|
||||||
controllers: [],
|
controllers: [WatcherController],
|
||||||
providers: [WatcherService],
|
providers: [WatcherService],
|
||||||
exports: [WatcherService],
|
exports: [WatcherService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -138,6 +138,29 @@ export class NotificationRepo {
|
|||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getRecentlyNotifiedUserIds(
|
||||||
|
userIds: string[],
|
||||||
|
pageId: string,
|
||||||
|
type: string,
|
||||||
|
withinHours: number,
|
||||||
|
): Promise<Set<string>> {
|
||||||
|
if (userIds.length === 0) return new Set();
|
||||||
|
|
||||||
|
const cutoff = new Date(Date.now() - withinHours * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const rows = await this.db
|
||||||
|
.selectFrom('notifications')
|
||||||
|
.select('userId')
|
||||||
|
.where('userId', 'in', userIds)
|
||||||
|
.where('pageId', '=', pageId)
|
||||||
|
.where('type', '=', type)
|
||||||
|
.where('createdAt', '>', cutoff)
|
||||||
|
.groupBy('userId')
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return new Set(rows.map((r) => r.userId));
|
||||||
|
}
|
||||||
|
|
||||||
withActor(eb: ExpressionBuilder<DB, 'notifications'>) {
|
withActor(eb: ExpressionBuilder<DB, 'notifications'>) {
|
||||||
return jsonObjectFrom(
|
return jsonObjectFrom(
|
||||||
eb
|
eb
|
||||||
|
|||||||
@@ -191,6 +191,24 @@ export class UserRepo {
|
|||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateNotificationSetting(
|
||||||
|
userId: string,
|
||||||
|
settingKey: 'page.updated' | 'page.user_mention' | 'comment.user_mention' | 'comment.created' | 'comment.resolved',
|
||||||
|
settingValue: boolean,
|
||||||
|
) {
|
||||||
|
return await this.db
|
||||||
|
.updateTable('users')
|
||||||
|
.set({
|
||||||
|
settings: sql`COALESCE(settings, '{}'::jsonb)
|
||||||
|
|| jsonb_build_object('notifications', COALESCE(settings->'notifications', '{}'::jsonb)
|
||||||
|
|| jsonb_build_object(${settingKey}, ${sql.lit(settingValue)}))`,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where('id', '=', userId)
|
||||||
|
.returning(this.baseFields)
|
||||||
|
.executeTakeFirst();
|
||||||
|
}
|
||||||
|
|
||||||
withUserMfa(eb: ExpressionBuilder<DB, 'users'>) {
|
withUserMfa(eb: ExpressionBuilder<DB, 'users'>) {
|
||||||
return jsonObjectFrom(
|
return jsonObjectFrom(
|
||||||
eb
|
eb
|
||||||
|
|||||||
@@ -60,6 +60,13 @@ export interface IPageMentionNotificationJob {
|
|||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IPageUpdateNotificationJob {
|
||||||
|
pageId: string;
|
||||||
|
spaceId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
actorIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface IPermissionGrantedNotificationJob {
|
export interface IPermissionGrantedNotificationJob {
|
||||||
userIds: string[];
|
userIds: string[];
|
||||||
pageId: string;
|
pageId: string;
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Section, Text } from '@react-email/components';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { content, paragraph } from '../css/styles';
|
||||||
|
import { EmailButton, MailBody } from '../partials/partials';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
actorName: string;
|
||||||
|
pageTitle: string;
|
||||||
|
pageUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PageUpdateEmail = ({
|
||||||
|
actorName,
|
||||||
|
pageTitle,
|
||||||
|
pageUrl,
|
||||||
|
}: Props) => {
|
||||||
|
return (
|
||||||
|
<MailBody>
|
||||||
|
<Section style={content}>
|
||||||
|
<Text style={paragraph}>Hi there,</Text>
|
||||||
|
<Text style={paragraph}>
|
||||||
|
<strong>{actorName}</strong> updated{' '}
|
||||||
|
<strong>{pageTitle}</strong>.
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
<EmailButton href={pageUrl}>View</EmailButton>
|
||||||
|
</MailBody>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PageUpdateEmail;
|
||||||
Reference in New Issue
Block a user