mirror of
https://github.com/docmost/docmost.git
synced 2026-05-16 22:41:30 +08:00
feat(ee): page verification workflow (#2102)
* feat: page verification workflow * feat: refactor page-verification * sync * fix type * fix * fix * notification icon * use full word * accept .license file * - update templates - update migration and notification * fix copy * update audit labels * sync * add space name
This commit is contained in:
@@ -5,6 +5,11 @@ export const NotificationType = {
|
||||
PAGE_USER_MENTION: 'page.user_mention',
|
||||
PAGE_PERMISSION_GRANTED: 'page.permission_granted',
|
||||
PAGE_UPDATED: 'page.updated',
|
||||
PAGE_VERIFICATION_EXPIRING: 'page.verification_expiring',
|
||||
PAGE_VERIFICATION_EXPIRED: 'page.verification_expired',
|
||||
PAGE_VERIFIED: 'page.verified',
|
||||
PAGE_APPROVAL_REQUESTED: 'page.approval_requested',
|
||||
PAGE_APPROVAL_REJECTED: 'page.approval_rejected',
|
||||
} as const;
|
||||
|
||||
export type NotificationType =
|
||||
|
||||
@@ -4,6 +4,7 @@ import { NotificationController } from './notification.controller';
|
||||
import { NotificationProcessor } from './notification.processor';
|
||||
import { CommentNotificationService } from './services/comment.notification';
|
||||
import { PageNotificationService } from './services/page.notification';
|
||||
import { VerificationNotificationService } from './services/verification.notification';
|
||||
import { PageUpdateEmailRateLimiter } from './services/page-update-email-rate-limiter';
|
||||
|
||||
@Module({
|
||||
@@ -14,6 +15,7 @@ import { PageUpdateEmailRateLimiter } from './services/page-update-email-rate-li
|
||||
NotificationProcessor,
|
||||
CommentNotificationService,
|
||||
PageNotificationService,
|
||||
VerificationNotificationService,
|
||||
PageUpdateEmailRateLimiter,
|
||||
],
|
||||
exports: [NotificationService],
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
import { Logger, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
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 {
|
||||
IApprovalRejectedNotificationJob,
|
||||
IApprovalRequestedNotificationJob,
|
||||
ICommentNotificationJob,
|
||||
ICommentResolvedNotificationJob,
|
||||
IPageMentionNotificationJob,
|
||||
IPageUpdateNotificationJob,
|
||||
IPageVerifiedNotificationJob,
|
||||
IPermissionGrantedNotificationJob,
|
||||
IVerificationExpiringNotificationJob,
|
||||
IVerificationExpiredNotificationJob,
|
||||
IVerificationReconcileJob,
|
||||
} from '../../integrations/queue/constants/queue.interface';
|
||||
import { CommentNotificationService } from './services/comment.notification';
|
||||
import { PageNotificationService } from './services/page.notification';
|
||||
import { VerificationNotificationService } from './services/verification.notification';
|
||||
import { DomainService } from '../../integrations/environment/domain.service';
|
||||
|
||||
@Processor(QueueName.NOTIFICATION_QUEUE)
|
||||
@@ -25,7 +33,9 @@ export class NotificationProcessor
|
||||
constructor(
|
||||
private readonly commentNotificationService: CommentNotificationService,
|
||||
private readonly pageNotificationService: PageNotificationService,
|
||||
private readonly verificationNotificationService: VerificationNotificationService,
|
||||
private readonly domainService: DomainService,
|
||||
private readonly moduleRef: ModuleRef,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
) {
|
||||
super();
|
||||
@@ -37,12 +47,23 @@ export class NotificationProcessor
|
||||
| ICommentResolvedNotificationJob
|
||||
| IPageMentionNotificationJob
|
||||
| IPageUpdateNotificationJob
|
||||
| IPermissionGrantedNotificationJob,
|
||||
| IPermissionGrantedNotificationJob
|
||||
| IVerificationExpiringNotificationJob
|
||||
| IVerificationExpiredNotificationJob
|
||||
| IVerificationReconcileJob
|
||||
| IPageVerifiedNotificationJob
|
||||
| IApprovalRequestedNotificationJob
|
||||
| IApprovalRejectedNotificationJob,
|
||||
void
|
||||
>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const workspaceId = (job.data as { workspaceId: string }).workspaceId;
|
||||
if (job.name === QueueJob.VERIFICATION_RECONCILE) {
|
||||
await this.runVerificationReconcile();
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceId = await this.resolveWorkspaceId(job);
|
||||
const appUrl = await this.getWorkspaceUrl(workspaceId);
|
||||
|
||||
switch (job.name) {
|
||||
@@ -92,6 +113,45 @@ export class NotificationProcessor
|
||||
break;
|
||||
}
|
||||
|
||||
case QueueJob.PAGE_VERIFICATION_EXPIRING: {
|
||||
await this.verificationNotificationService.processVerificationExpiring(
|
||||
job.data as IVerificationExpiringNotificationJob,
|
||||
appUrl,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case QueueJob.PAGE_VERIFICATION_EXPIRED: {
|
||||
await this.verificationNotificationService.processVerificationExpired(
|
||||
job.data as IVerificationExpiredNotificationJob,
|
||||
appUrl,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case QueueJob.PAGE_VERIFIED_NOTIFICATION: {
|
||||
await this.verificationNotificationService.processPageVerified(
|
||||
job.data as IPageVerifiedNotificationJob,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case QueueJob.PAGE_APPROVAL_REQUESTED_NOTIFICATION: {
|
||||
await this.verificationNotificationService.processApprovalRequested(
|
||||
job.data as IApprovalRequestedNotificationJob,
|
||||
appUrl,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case QueueJob.PAGE_APPROVAL_REJECTED_NOTIFICATION: {
|
||||
await this.verificationNotificationService.processApprovalRejected(
|
||||
job.data as IApprovalRejectedNotificationJob,
|
||||
appUrl,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
this.logger.warn(`Unknown notification job: ${job.name}`);
|
||||
}
|
||||
@@ -102,6 +162,49 @@ export class NotificationProcessor
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveWorkspaceId(job: Job): Promise<string> {
|
||||
if (
|
||||
job.name === QueueJob.PAGE_VERIFICATION_EXPIRING ||
|
||||
job.name === QueueJob.PAGE_VERIFICATION_EXPIRED
|
||||
) {
|
||||
const { verificationId } = job.data as { verificationId: string };
|
||||
const row = await this.db
|
||||
.selectFrom('pageVerifications')
|
||||
.select('workspaceId')
|
||||
.where('id', '=', verificationId)
|
||||
.executeTakeFirst();
|
||||
return row?.workspaceId ?? '';
|
||||
}
|
||||
return (job.data as { workspaceId: string }).workspaceId;
|
||||
}
|
||||
|
||||
private async runVerificationReconcile(): Promise<void> {
|
||||
let eeModule: { PageVerificationSchedulerService?: unknown };
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
eeModule = require('../../ee/page-verification/page-verification-scheduler.service');
|
||||
} catch {
|
||||
this.logger.debug(
|
||||
'VERIFICATION_RECONCILE fired but EE scheduler not bundled in this build',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const schedulerClass = eeModule.PageVerificationSchedulerService as
|
||||
| (new (...args: unknown[]) => { reconcile(): Promise<void> })
|
||||
| undefined;
|
||||
if (!schedulerClass) return;
|
||||
|
||||
const scheduler = this.moduleRef.get(schedulerClass, { strict: false });
|
||||
if (!scheduler) {
|
||||
this.logger.warn(
|
||||
'VERIFICATION_RECONCILE fired but scheduler service not resolvable',
|
||||
);
|
||||
return;
|
||||
}
|
||||
await scheduler.reconcile();
|
||||
}
|
||||
|
||||
private async getWorkspaceUrl(workspaceId: string): Promise<string> {
|
||||
const workspace = await this.db
|
||||
.selectFrom('workspaces')
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import {
|
||||
IApprovalRejectedNotificationJob,
|
||||
IApprovalRequestedNotificationJob,
|
||||
IPageVerifiedNotificationJob,
|
||||
IVerificationExpiringNotificationJob,
|
||||
IVerificationExpiredNotificationJob,
|
||||
} from '../../../integrations/queue/constants/queue.interface';
|
||||
import { NotificationService } from '../notification.service';
|
||||
import { NotificationType } from '../notification.constants';
|
||||
import { VerificationExpiringEmail } from '@docmost/transactional/emails/verification-expiring-email';
|
||||
import { VerificationExpiredEmail } from '@docmost/transactional/emails/verification-expired-email';
|
||||
import { ApprovalRequestedEmail } from '@docmost/transactional/emails/approval-requested-email';
|
||||
import { ApprovalRejectedEmail } from '@docmost/transactional/emails/approval-rejected-email';
|
||||
import { getPageTitle } from '../../../common/helpers';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
|
||||
@Injectable()
|
||||
export class VerificationNotificationService {
|
||||
constructor(
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly notificationService: NotificationService,
|
||||
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
) {}
|
||||
|
||||
private async getAlreadyNotifiedUserIds(
|
||||
pageVerificationId: string,
|
||||
type: string,
|
||||
): Promise<Set<string>> {
|
||||
const rows = await this.db
|
||||
.selectFrom('notifications')
|
||||
.select('userId')
|
||||
.where('pageVerificationId', '=', pageVerificationId)
|
||||
.where('type', '=', type)
|
||||
.execute();
|
||||
return new Set(rows.map((r) => r.userId));
|
||||
}
|
||||
|
||||
private async filterAccessibleRecipients(
|
||||
userIds: string[],
|
||||
pageId: string,
|
||||
spaceId: string,
|
||||
): Promise<string[]> {
|
||||
if (userIds.length === 0) return [];
|
||||
const inSpace = await this.spaceMemberRepo.getUserIdsWithSpaceAccess(
|
||||
userIds,
|
||||
spaceId,
|
||||
);
|
||||
if (inSpace.size === 0) return [];
|
||||
return this.pagePermissionRepo.getUserIdsWithPageAccess(pageId, [
|
||||
...inSpace,
|
||||
]);
|
||||
}
|
||||
|
||||
async processVerificationExpiring(
|
||||
data: IVerificationExpiringNotificationJob,
|
||||
appUrl: string,
|
||||
) {
|
||||
const verification = await this.db
|
||||
.selectFrom('pageVerifications')
|
||||
.selectAll()
|
||||
.where('id', '=', data.verificationId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!verification) return;
|
||||
if (verification.type !== 'expiring') return;
|
||||
if (!verification.expiresAt) return;
|
||||
const expiresAtMs = new Date(verification.expiresAt).getTime();
|
||||
if (expiresAtMs <= Date.now()) return;
|
||||
|
||||
const verifierRows = await this.db
|
||||
.selectFrom('pageVerifiers')
|
||||
.select('userId')
|
||||
.where('pageVerificationId', '=', verification.id)
|
||||
.execute();
|
||||
const verifierIds = verifierRows.map((r) => r.userId);
|
||||
if (verifierIds.length === 0) return;
|
||||
|
||||
const accessibleVerifierIds = await this.filterAccessibleRecipients(
|
||||
verifierIds,
|
||||
verification.pageId,
|
||||
verification.spaceId,
|
||||
);
|
||||
if (accessibleVerifierIds.length === 0) return;
|
||||
|
||||
const alreadyNotified = await this.getAlreadyNotifiedUserIds(
|
||||
verification.id,
|
||||
NotificationType.PAGE_VERIFICATION_EXPIRING,
|
||||
);
|
||||
const recipients = accessibleVerifierIds.filter(
|
||||
(id) => !alreadyNotified.has(id),
|
||||
);
|
||||
if (recipients.length === 0) return;
|
||||
|
||||
const context = await this.getPageContext(
|
||||
verification.pageId,
|
||||
verification.spaceId,
|
||||
appUrl,
|
||||
);
|
||||
if (!context) return;
|
||||
|
||||
const { pageTitle, spaceName, basePageUrl } = context;
|
||||
const expiresAtIso = new Date(verification.expiresAt).toISOString();
|
||||
|
||||
for (const userId of recipients) {
|
||||
const notification = await this.notificationService.create({
|
||||
userId,
|
||||
workspaceId: verification.workspaceId,
|
||||
type: NotificationType.PAGE_VERIFICATION_EXPIRING,
|
||||
pageId: verification.pageId,
|
||||
spaceId: verification.spaceId,
|
||||
pageVerificationId: verification.id,
|
||||
data: { expiresAt: expiresAtIso },
|
||||
});
|
||||
|
||||
const subject = `"${pageTitle}" needs to be re-verified soon`;
|
||||
|
||||
await this.notificationService.queueEmail(
|
||||
userId,
|
||||
notification.id,
|
||||
subject,
|
||||
VerificationExpiringEmail({
|
||||
pageTitle,
|
||||
spaceName,
|
||||
pageUrl: basePageUrl,
|
||||
expiresAt: new Date(verification.expiresAt).toLocaleDateString(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async processVerificationExpired(
|
||||
data: IVerificationExpiredNotificationJob,
|
||||
appUrl: string,
|
||||
) {
|
||||
const verification = await this.db
|
||||
.selectFrom('pageVerifications')
|
||||
.selectAll()
|
||||
.where('id', '=', data.verificationId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!verification) return;
|
||||
if (verification.type !== 'expiring') return;
|
||||
if (!verification.expiresAt) return;
|
||||
if (new Date(verification.expiresAt).getTime() > Date.now()) return;
|
||||
|
||||
const verifierRows = await this.db
|
||||
.selectFrom('pageVerifiers')
|
||||
.select('userId')
|
||||
.where('pageVerificationId', '=', verification.id)
|
||||
.execute();
|
||||
const verifierIds = verifierRows.map((r) => r.userId);
|
||||
if (verifierIds.length === 0) return;
|
||||
|
||||
const accessibleVerifierIds = await this.filterAccessibleRecipients(
|
||||
verifierIds,
|
||||
verification.pageId,
|
||||
verification.spaceId,
|
||||
);
|
||||
if (accessibleVerifierIds.length === 0) return;
|
||||
|
||||
const alreadyNotified = await this.getAlreadyNotifiedUserIds(
|
||||
verification.id,
|
||||
NotificationType.PAGE_VERIFICATION_EXPIRED,
|
||||
);
|
||||
const recipients = accessibleVerifierIds.filter(
|
||||
(id) => !alreadyNotified.has(id),
|
||||
);
|
||||
if (recipients.length === 0) return;
|
||||
|
||||
const context = await this.getPageContext(
|
||||
verification.pageId,
|
||||
verification.spaceId,
|
||||
appUrl,
|
||||
);
|
||||
if (!context) return;
|
||||
|
||||
const { pageTitle, spaceName, basePageUrl } = context;
|
||||
|
||||
for (const userId of recipients) {
|
||||
const notification = await this.notificationService.create({
|
||||
userId,
|
||||
workspaceId: verification.workspaceId,
|
||||
type: NotificationType.PAGE_VERIFICATION_EXPIRED,
|
||||
pageId: verification.pageId,
|
||||
spaceId: verification.spaceId,
|
||||
pageVerificationId: verification.id,
|
||||
});
|
||||
|
||||
const subject = `"${pageTitle}" verification has expired`;
|
||||
|
||||
await this.notificationService.queueEmail(
|
||||
userId,
|
||||
notification.id,
|
||||
subject,
|
||||
VerificationExpiredEmail({
|
||||
pageTitle,
|
||||
spaceName,
|
||||
pageUrl: basePageUrl,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async processPageVerified(data: IPageVerifiedNotificationJob) {
|
||||
const { verifierIds, pageId, spaceId, workspaceId, actorId } = data;
|
||||
if (verifierIds.length === 0) return;
|
||||
|
||||
const accessibleVerifierIds = await this.filterAccessibleRecipients(
|
||||
verifierIds,
|
||||
pageId,
|
||||
spaceId,
|
||||
);
|
||||
if (accessibleVerifierIds.length === 0) return;
|
||||
|
||||
for (const userId of accessibleVerifierIds) {
|
||||
await this.notificationService.create({
|
||||
userId,
|
||||
workspaceId,
|
||||
type: NotificationType.PAGE_VERIFIED,
|
||||
actorId,
|
||||
pageId,
|
||||
spaceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async processApprovalRequested(
|
||||
data: IApprovalRequestedNotificationJob,
|
||||
appUrl: string,
|
||||
) {
|
||||
const { verifierIds, pageId, spaceId, workspaceId, actorId } = data;
|
||||
if (verifierIds.length === 0) return;
|
||||
|
||||
const accessibleVerifierIds = await this.filterAccessibleRecipients(
|
||||
verifierIds,
|
||||
pageId,
|
||||
spaceId,
|
||||
);
|
||||
if (accessibleVerifierIds.length === 0) return;
|
||||
|
||||
const context = await this.getPageContext(pageId, spaceId, appUrl);
|
||||
if (!context) return;
|
||||
|
||||
const { pageTitle, spaceName, basePageUrl } = context;
|
||||
const actorName = await this.getUserName(actorId);
|
||||
|
||||
for (const userId of accessibleVerifierIds) {
|
||||
const notification = await this.notificationService.create({
|
||||
userId,
|
||||
workspaceId,
|
||||
type: NotificationType.PAGE_APPROVAL_REQUESTED,
|
||||
actorId,
|
||||
pageId,
|
||||
spaceId,
|
||||
});
|
||||
|
||||
const subject = `"${pageTitle}" needs your approval`;
|
||||
|
||||
await this.notificationService.queueEmail(
|
||||
userId,
|
||||
notification.id,
|
||||
subject,
|
||||
ApprovalRequestedEmail({
|
||||
actorName,
|
||||
pageTitle,
|
||||
spaceName,
|
||||
pageUrl: basePageUrl,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async processApprovalRejected(
|
||||
data: IApprovalRejectedNotificationJob,
|
||||
appUrl: string,
|
||||
) {
|
||||
const { pageId, spaceId, workspaceId, actorId, requestedById, comment } =
|
||||
data;
|
||||
|
||||
const recipients = await this.filterAccessibleRecipients(
|
||||
[requestedById],
|
||||
pageId,
|
||||
spaceId,
|
||||
);
|
||||
if (recipients.length === 0) return;
|
||||
|
||||
const context = await this.getPageContext(pageId, spaceId, appUrl);
|
||||
if (!context) return;
|
||||
|
||||
const { pageTitle, spaceName, basePageUrl } = context;
|
||||
const actorName = await this.getUserName(actorId);
|
||||
|
||||
const notification = await this.notificationService.create({
|
||||
userId: requestedById,
|
||||
workspaceId,
|
||||
type: NotificationType.PAGE_APPROVAL_REJECTED,
|
||||
actorId,
|
||||
pageId,
|
||||
spaceId,
|
||||
});
|
||||
|
||||
const subject = `"${pageTitle}" was returned for revision`;
|
||||
|
||||
await this.notificationService.queueEmail(
|
||||
requestedById,
|
||||
notification.id,
|
||||
subject,
|
||||
ApprovalRejectedEmail({
|
||||
actorName,
|
||||
pageTitle,
|
||||
spaceName,
|
||||
pageUrl: basePageUrl,
|
||||
comment,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async getUserName(userId: string): Promise<string> {
|
||||
const user = await this.db
|
||||
.selectFrom('users')
|
||||
.select('name')
|
||||
.where('id', '=', userId)
|
||||
.executeTakeFirst();
|
||||
return user?.name ?? 'Someone';
|
||||
}
|
||||
|
||||
private async getPageContext(
|
||||
pageId: string,
|
||||
spaceId: string,
|
||||
appUrl: string,
|
||||
) {
|
||||
const [page, space] = await Promise.all([
|
||||
this.db
|
||||
.selectFrom('pages')
|
||||
.select(['id', 'title', 'slugId'])
|
||||
.where('id', '=', pageId)
|
||||
.executeTakeFirst(),
|
||||
this.db
|
||||
.selectFrom('spaces')
|
||||
.select(['id', 'slug', 'name'])
|
||||
.where('id', '=', spaceId)
|
||||
.executeTakeFirst(),
|
||||
]);
|
||||
|
||||
if (!page || !space) return null;
|
||||
|
||||
const basePageUrl = `${appUrl}/s/${space.slug}/p/${page.slugId}`;
|
||||
return { pageTitle: getPageTitle(page.title), spaceName: space.name ?? space.slug, basePageUrl };
|
||||
}
|
||||
}
|
||||
@@ -452,6 +452,20 @@ export class PageService {
|
||||
.where('pageId', 'in', pageIdsToMove)
|
||||
.execute();
|
||||
|
||||
// Update page verifications
|
||||
await trx
|
||||
.updateTable('pageVerifications')
|
||||
.set({ spaceId: spaceId })
|
||||
.where('pageId', 'in', pageIdsToMove)
|
||||
.execute();
|
||||
|
||||
// Update notifications — access follows the page after a move
|
||||
await trx
|
||||
.updateTable('notifications')
|
||||
.set({ spaceId: spaceId })
|
||||
.where('pageId', 'in', pageIdsToMove)
|
||||
.execute();
|
||||
|
||||
// Update attachments
|
||||
await this.attachmentRepo.updateAttachmentsByPageId(
|
||||
{ spaceId },
|
||||
|
||||
Reference in New Issue
Block a user