feat(ee): page-level access/permissions (#1971)

* Add page_hierarchy table

* feat(ee): page-level permissions

* pagination

* rename migration
fixes

* fix

* tabs

* fix theme

* cleanup

* sync

* page permissions notification
* other fixes

* sharing disbled

* fix column nodes

* toggle error handling
This commit is contained in:
Philip Okugbe
2026-02-26 19:49:10 +00:00
committed by GitHub
parent 22f33bab7c
commit 59e945562d
75 changed files with 4235 additions and 363 deletions
@@ -3,6 +3,7 @@ export const NotificationType = {
COMMENT_CREATED: 'comment.created',
COMMENT_RESOLVED: 'comment.resolved',
PAGE_USER_MENTION: 'page.user_mention',
PAGE_PERMISSION_GRANTED: 'page.permission_granted',
} as const;
export type NotificationType =
@@ -4,10 +4,9 @@ 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],
imports: [],
controllers: [NotificationController],
providers: [
NotificationService,
@@ -8,6 +8,7 @@ import {
ICommentNotificationJob,
ICommentResolvedNotificationJob,
IPageMentionNotificationJob,
IPermissionGrantedNotificationJob,
} from '../../integrations/queue/constants/queue.interface';
import { CommentNotificationService } from './services/comment.notification';
import { PageNotificationService } from './services/page.notification';
@@ -33,7 +34,8 @@ export class NotificationProcessor
job: Job<
| ICommentNotificationJob
| ICommentResolvedNotificationJob
| IPageMentionNotificationJob,
| IPageMentionNotificationJob
| IPermissionGrantedNotificationJob,
void
>,
): Promise<void> {
@@ -66,6 +68,14 @@ export class NotificationProcessor
break;
}
case QueueJob.PAGE_PERMISSION_GRANTED: {
await this.pageNotificationService.processPermissionGranted(
job.data as IPermissionGrantedNotificationJob,
appUrl,
);
break;
}
default:
this.logger.warn(`Unknown notification job: ${job.name}`);
}
@@ -8,6 +8,7 @@ import {
import { NotificationService } from '../notification.service';
import { NotificationType } from '../notification.constants';
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 { CommentMentionEmail } from '@docmost/transactional/emails/comment-mention-email';
import { CommentCreateEmail } from '@docmost/transactional/emails/comment-created-email';
@@ -22,6 +23,7 @@ export class CommentNotificationService {
@InjectKysely() private readonly db: KyselyDB,
private readonly notificationService: NotificationService,
private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly watcherRepo: WatcherRepo,
) {}
@@ -59,12 +61,19 @@ export class CommentNotificationService {
const allCandidateIds = [
...new Set([...mentionedUserIds, ...recipientIds]),
];
const usersWithAccess =
const usersWithSpaceAccess =
await this.spaceMemberRepo.getUserIdsWithSpaceAccess(
allCandidateIds,
spaceId,
);
const usersWithPageAccess =
await this.pagePermissionRepo.getUserIdsWithPageAccess(
pageId,
[...usersWithSpaceAccess],
);
const usersWithAccess = new Set(usersWithPageAccess);
for (const userId of mentionedUserIds) {
if (!usersWithAccess.has(userId)) continue;
@@ -146,6 +155,13 @@ export class CommentNotificationService {
return;
}
const hasPageAccess =
await this.pagePermissionRepo.getUserIdsWithPageAccess(
pageId,
[commentCreatorId],
);
if (hasPageAccess.length === 0) return;
const notification = await this.notificationService.create({
userId: commentCreatorId,
workspaceId,
@@ -1,11 +1,16 @@
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 {
IPageMentionNotificationJob,
IPermissionGrantedNotificationJob,
} 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 { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { PageMentionEmail } from '@docmost/transactional/emails/page-mention-email';
import { PermissionGrantedEmail } from '@docmost/transactional/emails/permission-granted-email';
import { getPageTitle } from '../../../common/helpers';
@Injectable()
@@ -14,6 +19,7 @@ export class PageNotificationService {
@InjectKysely() private readonly db: KyselyDB,
private readonly notificationService: NotificationService,
private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
) {}
async processPageMention(data: IPageMentionNotificationJob, appUrl: string) {
@@ -28,12 +34,19 @@ export class PageNotificationService {
if (newMentions.length === 0) return;
const candidateUserIds = newMentions.map((m) => m.userId);
const usersWithAccess =
const usersWithSpaceAccess =
await this.spaceMemberRepo.getUserIdsWithSpaceAccess(
candidateUserIds,
spaceId,
);
const usersWithPageAccess =
await this.pagePermissionRepo.getUserIdsWithPageAccess(
pageId,
[...usersWithSpaceAccess],
);
const usersWithAccess = new Set(usersWithPageAccess);
const accessibleMentions = newMentions.filter((m) =>
usersWithAccess.has(m.userId),
);
@@ -97,6 +110,52 @@ export class PageNotificationService {
}
}
async processPermissionGranted(
data: IPermissionGrantedNotificationJob,
appUrl: string,
) {
const { userIds, pageId, spaceId, workspaceId, actorId, role } = data;
if (userIds.length === 0) return;
const usersWithSpaceAccess =
await this.spaceMemberRepo.getUserIdsWithSpaceAccess(userIds, spaceId);
if (usersWithSpaceAccess.size === 0) return;
const context = await this.getPageContext(actorId, pageId, spaceId, appUrl);
if (!context) return;
const { actor, pageTitle, basePageUrl } = context;
const accessLabel = role === 'writer' ? 'edit' : 'view';
for (const userId of usersWithSpaceAccess) {
const notification = await this.notificationService.create({
userId,
workspaceId,
type: NotificationType.PAGE_PERMISSION_GRANTED,
actorId,
pageId,
spaceId,
data: { role },
});
const subject = `${actor.name} gave you ${accessLabel} access to ${pageTitle}`;
await this.notificationService.queueEmail(
userId,
notification.id,
subject,
PermissionGrantedEmail({
actorName: actor.name,
pageTitle,
pageUrl: basePageUrl,
accessLabel,
}),
);
}
}
private async getPageContext(
actorId: string,
pageId: string,