feat: watchers notification and email preferences

This commit is contained in:
Philipinho
2026-03-30 11:54:28 +01:00
parent cbd0dd4a0b
commit 6c60e28250
23 changed files with 531 additions and 41 deletions
@@ -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,