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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user