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> 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> 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",
|
||||
"Yesterday": "Yesterday",
|
||||
"This week": "This week",
|
||||
|
||||
@@ -49,6 +49,8 @@ export function NotificationItem({
|
||||
return notification.data?.role === "writer"
|
||||
? "<bold>{{name}}</bold> gave you edit 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:
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ export type NotificationType =
|
||||
| "comment.created"
|
||||
| "comment.resolved"
|
||||
| "page.user_mention"
|
||||
| "page.permission_granted";
|
||||
| "page.permission_granted"
|
||||
| "page.updated";
|
||||
|
||||
export type INotification = {
|
||||
id: string;
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
IconArrowRight,
|
||||
IconArrowsHorizontal,
|
||||
IconDots,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
IconFileExport,
|
||||
IconHistory,
|
||||
IconLink,
|
||||
@@ -40,6 +42,11 @@ import { PageStateSegmentedControl } from "@/features/user/components/page-state
|
||||
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||
import { PageShareModal } from "@/ee/page-permission";
|
||||
import {
|
||||
useWatchStatusQuery,
|
||||
useWatchPageMutation,
|
||||
useUnwatchPageMutation,
|
||||
} from "@/features/page/queries/watcher-query";
|
||||
|
||||
interface PageHeaderMenuProps {
|
||||
readOnly?: boolean;
|
||||
@@ -123,6 +130,9 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
] = useDisclosure(false);
|
||||
const [pageEditor] = useAtom(pageEditorAtom);
|
||||
const pageUpdatedAt = useTimeAgo(page?.updatedAt);
|
||||
const { data: watchStatus } = useWatchStatusQuery(page?.id);
|
||||
const watchPage = useWatchPageMutation();
|
||||
const unwatchPage = useUnwatchPageMutation();
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const pageUrl =
|
||||
@@ -185,6 +195,23 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
>
|
||||
{t("Copy as Markdown")}
|
||||
</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.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;
|
||||
fullPageWidth: boolean; // 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;
|
||||
}
|
||||
|
||||
@@ -33,6 +38,13 @@ export interface IUserSettings {
|
||||
fullPageWidth: boolean;
|
||||
pageEditMode: string;
|
||||
};
|
||||
notifications?: {
|
||||
'page.updated'?: boolean;
|
||||
'page.user_mention'?: boolean;
|
||||
'comment.user_mention'?: boolean;
|
||||
'comment.created'?: boolean;
|
||||
'comment.resolved'?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
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 PageWidthPref from "@/features/user/components/page-width-pref.tsx";
|
||||
import PageEditPref from "@/features/user/components/page-state-pref";
|
||||
import NotificationPref from "@/features/user/components/notification-pref";
|
||||
import { getAppName } from "@/lib/config.ts";
|
||||
import { Divider } from "@mantine/core";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
@@ -33,6 +34,10 @@ export default function AccountPreferences() {
|
||||
<Divider my={"md"} />
|
||||
|
||||
<PageEditPref />
|
||||
|
||||
<Divider my={"md"} />
|
||||
|
||||
<NotificationPref />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { Logger, OnModuleDestroy } from '@nestjs/common';
|
||||
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 { 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 { 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';
|
||||
import { NotificationType } from '../../core/notification/notification.constants';
|
||||
|
||||
@Processor(QueueName.HISTORY_QUEUE)
|
||||
export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
@@ -18,6 +23,7 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly collabHistory: CollabHistoryService,
|
||||
private readonly watcherService: WatcherService,
|
||||
@InjectQueue(QueueName.NOTIFICATION_QUEUE) private notificationQueue: Queue,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -47,8 +53,7 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
!lastHistory ||
|
||||
!isDeepStrictEqual(lastHistory.content, page.content)
|
||||
) {
|
||||
const contributorIds =
|
||||
await this.collabHistory.popContributors(pageId);
|
||||
const contributorIds = await this.collabHistory.popContributors(pageId);
|
||||
|
||||
try {
|
||||
await this.watcherService.addPageWatchers(
|
||||
@@ -61,12 +66,24 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
||||
await this.pageHistoryRepo.saveHistory(page, { contributorIds });
|
||||
this.logger.debug(`History created for page: ${pageId}`);
|
||||
} catch (err) {
|
||||
await this.collabHistory.addContributors(
|
||||
pageId,
|
||||
contributorIds,
|
||||
);
|
||||
await this.collabHistory.addContributors(pageId, contributorIds);
|
||||
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) {
|
||||
throw err;
|
||||
|
||||
@@ -4,6 +4,7 @@ export const NotificationType = {
|
||||
COMMENT_RESOLVED: 'comment.resolved',
|
||||
PAGE_USER_MENTION: 'page.user_mention',
|
||||
PAGE_PERMISSION_GRANTED: 'page.permission_granted',
|
||||
PAGE_UPDATED: 'page.updated',
|
||||
} as const;
|
||||
|
||||
export type NotificationType =
|
||||
|
||||
@@ -8,11 +8,13 @@ import {
|
||||
ICommentNotificationJob,
|
||||
ICommentResolvedNotificationJob,
|
||||
IPageMentionNotificationJob,
|
||||
IPageUpdateNotificationJob,
|
||||
IPermissionGrantedNotificationJob,
|
||||
} 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';
|
||||
import { NotificationType } from './notification.constants';
|
||||
|
||||
@Processor(QueueName.NOTIFICATION_QUEUE)
|
||||
export class NotificationProcessor
|
||||
@@ -35,6 +37,7 @@ export class NotificationProcessor
|
||||
| ICommentNotificationJob
|
||||
| ICommentResolvedNotificationJob
|
||||
| IPageMentionNotificationJob
|
||||
| IPageUpdateNotificationJob
|
||||
| IPermissionGrantedNotificationJob,
|
||||
void
|
||||
>,
|
||||
@@ -76,6 +79,14 @@ export class NotificationProcessor
|
||||
break;
|
||||
}
|
||||
|
||||
case NotificationType.PAGE_UPDATED: {
|
||||
await this.pageNotificationService.processPageUpdate(
|
||||
job.data as IPageUpdateNotificationJob,
|
||||
appUrl,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
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 { WsGateway } from '../../ws/ws.gateway';
|
||||
import { MailService } from '../../integrations/mail/mail.service';
|
||||
import { NotificationType } from './notification.constants';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationService {
|
||||
@@ -19,6 +20,16 @@ export class NotificationService {
|
||||
) {}
|
||||
|
||||
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);
|
||||
|
||||
this.wsGateway.server
|
||||
@@ -53,17 +64,24 @@ export class NotificationService {
|
||||
notificationId: string,
|
||||
subject: string,
|
||||
template: any,
|
||||
type?: NotificationType,
|
||||
) {
|
||||
try {
|
||||
const user = await this.db
|
||||
.selectFrom('users')
|
||||
.select(['email'])
|
||||
.select(['email', 'settings'])
|
||||
.where('id', '=', userId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.where('deactivatedAt', 'is', null)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!user?.email) return;
|
||||
|
||||
if (type) {
|
||||
const settings = user.settings as any;
|
||||
if (settings?.notifications?.[type] === false) return;
|
||||
}
|
||||
|
||||
await this.mailService.sendToQueue({
|
||||
to: user.email,
|
||||
subject,
|
||||
|
||||
@@ -86,12 +86,14 @@ export class CommentNotificationService {
|
||||
spaceId,
|
||||
commentId,
|
||||
});
|
||||
if (!notification) continue;
|
||||
|
||||
await this.notificationService.queueEmail(
|
||||
userId,
|
||||
notification.id,
|
||||
`${actor.name} mentioned you in a comment`,
|
||||
CommentMentionEmail({ actorName: actor.name, pageTitle, pageUrl }),
|
||||
NotificationType.COMMENT_USER_MENTION,
|
||||
);
|
||||
|
||||
notifiedUserIds.add(userId);
|
||||
@@ -110,12 +112,14 @@ export class CommentNotificationService {
|
||||
spaceId,
|
||||
commentId,
|
||||
});
|
||||
if (!notification) continue;
|
||||
|
||||
await this.notificationService.queueEmail(
|
||||
recipientId,
|
||||
notification.id,
|
||||
`${actor.name} commented on ${pageTitle}`,
|
||||
CommentCreateEmail({ actorName: actor.name, pageTitle, pageUrl }),
|
||||
NotificationType.COMMENT_CREATED,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -171,6 +175,7 @@ export class CommentNotificationService {
|
||||
spaceId,
|
||||
commentId,
|
||||
});
|
||||
if (!notification) return;
|
||||
|
||||
const subject = `${actor.name} resolved a comment on ${pageTitle}`;
|
||||
|
||||
@@ -179,6 +184,7 @@ export class CommentNotificationService {
|
||||
notification.id,
|
||||
subject,
|
||||
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 {
|
||||
IPageMentionNotificationJob,
|
||||
IPageUpdateNotificationJob,
|
||||
IPermissionGrantedNotificationJob,
|
||||
} from '../../../integrations/queue/constants/queue.interface';
|
||||
import { NotificationService } from '../notification.service';
|
||||
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 { 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 { PageUpdateEmail } from '@docmost/transactional/emails/page-update-email';
|
||||
import { PermissionGrantedEmail } from '@docmost/transactional/emails/permission-granted-email';
|
||||
import { getPageTitle } from '../../../common/helpers';
|
||||
|
||||
const PAGE_UPDATE_COOLDOWN_HOURS = 7;
|
||||
|
||||
@Injectable()
|
||||
export class PageNotificationService {
|
||||
constructor(
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly notificationService: NotificationService,
|
||||
private readonly notificationRepo: NotificationRepo,
|
||||
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
private readonly watcherRepo: WatcherRepo,
|
||||
) {}
|
||||
|
||||
async processPageMention(data: IPageMentionNotificationJob, appUrl: string) {
|
||||
@@ -41,10 +49,9 @@ export class PageNotificationService {
|
||||
);
|
||||
|
||||
const usersWithPageAccess =
|
||||
await this.pagePermissionRepo.getUserIdsWithPageAccess(
|
||||
pageId,
|
||||
[...usersWithSpaceAccess],
|
||||
);
|
||||
await this.pagePermissionRepo.getUserIdsWithPageAccess(pageId, [
|
||||
...usersWithSpaceAccess,
|
||||
]);
|
||||
const usersWithAccess = new Set(usersWithPageAccess);
|
||||
|
||||
const accessibleMentions = newMentions.filter((m) =>
|
||||
@@ -97,6 +104,7 @@ export class PageNotificationService {
|
||||
spaceId,
|
||||
data: { mentionId },
|
||||
});
|
||||
if (!notification) continue;
|
||||
|
||||
const pageUrl = `${basePageUrl}`;
|
||||
const subject = `${actor.name} mentioned you in ${pageTitle}`;
|
||||
@@ -106,6 +114,7 @@ export class PageNotificationService {
|
||||
notification.id,
|
||||
subject,
|
||||
PageMentionEmail({ actorName: actor.name, pageTitle, pageUrl }),
|
||||
NotificationType.PAGE_USER_MENTION,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -139,6 +148,7 @@ export class PageNotificationService {
|
||||
spaceId,
|
||||
data: { role },
|
||||
});
|
||||
if (!notification) continue;
|
||||
|
||||
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(
|
||||
actorId: string,
|
||||
pageId: string,
|
||||
|
||||
@@ -35,4 +35,24 @@ export class UpdateUserDto extends PartialType(
|
||||
@MaxLength(70)
|
||||
@IsString()
|
||||
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 };
|
||||
|
||||
if (updateUserDto.name) {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
/***
|
||||
import {
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
@@ -16,12 +14,7 @@ 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';
|
||||
|
||||
import { PageAccessService } from '../page/page-access/page-access.service';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('pages')
|
||||
@@ -29,7 +22,7 @@ export class WatcherController {
|
||||
constructor(
|
||||
private readonly watcherService: WatcherService,
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
private readonly pageAccessService: PageAccessService,
|
||||
) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -44,10 +37,7 @@ export class WatcherController {
|
||||
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.pageAccessService.validateCanView(page, user);
|
||||
|
||||
await this.watcherService.watchPage(
|
||||
user.id,
|
||||
@@ -67,10 +57,7 @@ export class WatcherController {
|
||||
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.pageAccessService.validateCanView(page, user);
|
||||
|
||||
await this.watcherService.unwatchPage(user.id, page.id);
|
||||
|
||||
@@ -85,15 +72,10 @@ export class WatcherController {
|
||||
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.pageAccessService.validateCanView(page, user);
|
||||
|
||||
const watching = await this.watcherService.isWatchingPage(user.id, page.id);
|
||||
|
||||
return { watching };
|
||||
}
|
||||
|
||||
}
|
||||
***/
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
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({
|
||||
imports: [CaslModule],
|
||||
controllers: [],
|
||||
imports: [PageAccessModule],
|
||||
controllers: [WatcherController],
|
||||
providers: [WatcherService],
|
||||
exports: [WatcherService],
|
||||
})
|
||||
|
||||
@@ -138,6 +138,29 @@ export class NotificationRepo {
|
||||
.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'>) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
|
||||
@@ -191,6 +191,24 @@ export class UserRepo {
|
||||
.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'>) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
|
||||
@@ -60,6 +60,13 @@ export interface IPageMentionNotificationJob {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface IPageUpdateNotificationJob {
|
||||
pageId: string;
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
actorIds: string[];
|
||||
}
|
||||
|
||||
export interface IPermissionGrantedNotificationJob {
|
||||
userIds: 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