feat: refactor page-verification

This commit is contained in:
Philipinho
2026-04-11 23:54:36 +01:00
parent 77f0aa6483
commit 1bd63101d6
20 changed files with 504 additions and 118 deletions
@@ -143,6 +143,18 @@ export function getPageId(documentName: string) {
return documentName.split('.')[1];
}
export function isEmptyParagraphDoc(tiptapJson: JSONContent): boolean {
if (!tiptapJson || tiptapJson.type !== 'doc') return false;
const content = tiptapJson.content;
if (!Array.isArray(content) || content.length !== 1) return false;
const child = content[0];
if (!child || child.type !== 'paragraph') return false;
return (
!child.content ||
(Array.isArray(child.content) && child.content.length === 0)
);
}
function stripUnknownNodes(
json: JSONContent,
schema: Schema,
@@ -18,6 +18,7 @@ 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 { isEmptyParagraphDoc } from '../collaboration.util';
@Processor(QueueName.HISTORY_QUEUE)
export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
@@ -55,6 +56,14 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
{ includeContent: true },
);
if (!lastHistory && isEmptyParagraphDoc(page.content)) {
this.logger.debug(
`Skipping first history for page ${pageId}: empty content`,
);
await this.collabHistory.clearContributors(pageId);
return;
}
if (
!lastHistory ||
!isDeepStrictEqual(lastHistory.content, page.content)
@@ -1,4 +1,5 @@
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';
@@ -15,6 +16,7 @@ import {
IPermissionGrantedNotificationJob,
IVerificationExpiringNotificationJob,
IVerificationExpiredNotificationJob,
IVerificationReconcileJob,
} from '../../integrations/queue/constants/queue.interface';
import { CommentNotificationService } from './services/comment.notification';
import { PageNotificationService } from './services/page.notification';
@@ -33,6 +35,7 @@ export class NotificationProcessor
private readonly pageNotificationService: PageNotificationService,
private readonly verificationNotificationService: VerificationNotificationService,
private readonly domainService: DomainService,
private readonly moduleRef: ModuleRef,
@InjectKysely() private readonly db: KyselyDB,
) {
super();
@@ -47,6 +50,7 @@ export class NotificationProcessor
| IPermissionGrantedNotificationJob
| IVerificationExpiringNotificationJob
| IVerificationExpiredNotificationJob
| IVerificationReconcileJob
| IPageVerifiedNotificationJob
| IApprovalRequestedNotificationJob
| IApprovalRejectedNotificationJob,
@@ -54,7 +58,12 @@ export class NotificationProcessor
>,
): 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) {
@@ -153,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')
@@ -8,6 +8,7 @@ import { WsGateway } from '../../ws/ws.gateway';
import { MailService } from '../../integrations/mail/mail.service';
import { NotificationTab, NotificationType, NotificationTypeToSettingKey } from './notification.constants';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
@Injectable()
export class NotificationService {
@@ -16,11 +17,108 @@ export class NotificationService {
constructor(
private readonly notificationRepo: NotificationRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly wsGateway: WsGateway,
private readonly mailService: MailService,
@InjectKysely() private readonly db: KyselyDB,
) {}
/**
* Returns the subset of `ids` pointing to notifications the user can
* currently see. Enforces the same dual gate as `findByUserId`:
* 1. `spaceId IS NULL` or user is a current member of `spaceId`.
* 2. `pageId IS NULL` or user has page-level access to `pageId`.
*
* Returning an empty array when `ids` is empty is a shortcut callers use to
* make the mark/count paths no-ops.
*/
private async filterAccessibleNotificationIds(
ids: string[],
userId: string,
): Promise<string[]> {
if (ids.length === 0) return [];
const rows = await this.db
.selectFrom('notifications')
.select(['id', 'pageId'])
.where('id', 'in', ids)
.where('userId', '=', userId)
.where((eb) =>
eb.or([
eb('spaceId', 'is', null),
eb(
'spaceId',
'in',
this.spaceMemberRepo.getUserSpaceIdsQuery(userId),
),
]),
)
.execute();
if (rows.length === 0) return [];
const pageIds = rows
.map((r) => r.pageId)
.filter((p): p is string => !!p);
if (pageIds.length === 0) {
return rows.map((r) => r.id);
}
const accessiblePageIds =
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds,
userId,
});
const accessibleSet = new Set(accessiblePageIds);
return rows
.filter((r) => !r.pageId || accessibleSet.has(r.pageId))
.map((r) => r.id);
}
private async listUnreadAccessibleNotificationIds(
userId: string,
): Promise<string[]> {
const rows = await this.db
.selectFrom('notifications')
.select(['id', 'pageId'])
.where('userId', '=', userId)
.where('readAt', 'is', null)
.where((eb) =>
eb.or([
eb('spaceId', 'is', null),
eb(
'spaceId',
'in',
this.spaceMemberRepo.getUserSpaceIdsQuery(userId),
),
]),
)
.execute();
if (rows.length === 0) return [];
const pageIds = rows
.map((r) => r.pageId)
.filter((p): p is string => !!p);
if (pageIds.length === 0) {
return rows.map((r) => r.id);
}
const accessiblePageIds =
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds,
userId,
});
const accessibleSet = new Set(accessiblePageIds);
return rows
.filter((r) => !r.pageId || accessibleSet.has(r.pageId))
.map((r) => r.id);
}
async create(data: InsertableNotification) {
const user = await this.db
.selectFrom('users')
@@ -73,19 +171,34 @@ export class NotificationService {
}
async getUnreadCount(userId: string) {
return this.notificationRepo.getUnreadCount(userId);
const accessibleIds =
await this.listUnreadAccessibleNotificationIds(userId);
return accessibleIds.length;
}
async markAsRead(notificationId: string, userId: string) {
return this.notificationRepo.markAsRead(notificationId, userId);
const accessibleIds = await this.filterAccessibleNotificationIds(
[notificationId],
userId,
);
if (accessibleIds.length === 0) return;
return this.notificationRepo.markAsRead(accessibleIds[0], userId);
}
async markMultipleAsRead(notificationIds: string[], userId: string) {
return this.notificationRepo.markMultipleAsRead(notificationIds, userId);
const accessibleIds = await this.filterAccessibleNotificationIds(
notificationIds,
userId,
);
if (accessibleIds.length === 0) return;
return this.notificationRepo.markMultipleAsRead(accessibleIds, userId);
}
async markAllAsRead(userId: string) {
return this.notificationRepo.markAllAsRead(userId);
const accessibleIds =
await this.listUnreadAccessibleNotificationIds(userId);
if (accessibleIds.length === 0) return;
return this.notificationRepo.markMultipleAsRead(accessibleIds, userId);
}
async queueEmail(
@@ -15,34 +15,83 @@ import { VerificationExpiredEmail } from '@docmost/transactional/emails/verifica
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 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 { verifierIds, pageId, spaceId, workspaceId, expiresAt } = data;
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 context = await this.getPageContext(pageId, spaceId, appUrl);
const accessibleVerifierIds = await this.filterAccessibleRecipients(
verifierIds,
verification.pageId,
verification.spaceId,
);
if (accessibleVerifierIds.length === 0) return;
const context = await this.getPageContext(
verification.pageId,
verification.spaceId,
appUrl,
);
if (!context) return;
const { pageTitle, basePageUrl } = context;
const expiresAtIso = new Date(verification.expiresAt).toISOString();
for (const userId of verifierIds) {
for (const userId of accessibleVerifierIds) {
const notification = await this.notificationService.create({
userId,
workspaceId,
workspaceId: verification.workspaceId,
type: NotificationType.PAGE_VERIFICATION_EXPIRING,
pageId,
spaceId,
data: { expiresAt },
pageId: verification.pageId,
spaceId: verification.spaceId,
data: { expiresAt: expiresAtIso },
});
const subject = `"${pageTitle}" needs to be re-verified soon`;
@@ -54,7 +103,7 @@ export class VerificationNotificationService {
VerificationExpiringEmail({
pageTitle,
pageUrl: basePageUrl,
expiresAt: new Date(expiresAt).toLocaleDateString(),
expiresAt: new Date(verification.expiresAt).toLocaleDateString(),
}),
);
}
@@ -64,21 +113,44 @@ export class VerificationNotificationService {
data: IVerificationExpiredNotificationJob,
appUrl: string,
) {
const { verifierIds, pageId, spaceId, workspaceId } = data;
const v = await this.db
.selectFrom('pageVerifications')
.selectAll()
.where('id', '=', data.verificationId)
.executeTakeFirst();
if (!v) return;
if (v.type !== 'expiring') return;
if (!v.expiresAt) return;
if (new Date(v.expiresAt).getTime() > Date.now()) return;
const verifierRows = await this.db
.selectFrom('pageVerifiers')
.select('userId')
.where('pageVerificationId', '=', v.id)
.execute();
const verifierIds = verifierRows.map((r) => r.userId);
if (verifierIds.length === 0) return;
const context = await this.getPageContext(pageId, spaceId, appUrl);
const accessibleVerifierIds = await this.filterAccessibleRecipients(
verifierIds,
v.pageId,
v.spaceId,
);
if (accessibleVerifierIds.length === 0) return;
const context = await this.getPageContext(v.pageId, v.spaceId, appUrl);
if (!context) return;
const { pageTitle, basePageUrl } = context;
for (const userId of verifierIds) {
for (const userId of accessibleVerifierIds) {
const notification = await this.notificationService.create({
userId,
workspaceId,
workspaceId: v.workspaceId,
type: NotificationType.PAGE_VERIFICATION_EXPIRED,
pageId,
spaceId,
pageId: v.pageId,
spaceId: v.spaceId,
});
const subject = `"${pageTitle}" verification has expired`;
@@ -99,7 +171,14 @@ export class VerificationNotificationService {
const { verifierIds, pageId, spaceId, workspaceId, actorId } = data;
if (verifierIds.length === 0) return;
for (const userId of verifierIds) {
const accessibleVerifierIds = await this.filterAccessibleRecipients(
verifierIds,
pageId,
spaceId,
);
if (accessibleVerifierIds.length === 0) return;
for (const userId of accessibleVerifierIds) {
await this.notificationService.create({
userId,
workspaceId,
@@ -118,13 +197,20 @@ export class VerificationNotificationService {
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, basePageUrl } = context;
const actorName = await this.getUserName(actorId);
for (const userId of verifierIds) {
for (const userId of accessibleVerifierIds) {
const notification = await this.notificationService.create({
userId,
workspaceId,
@@ -156,6 +242,13 @@ export class VerificationNotificationService {
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;
@@ -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 },
@@ -15,9 +15,7 @@ export async function up(db: Kysely<any>): Promise<void> {
.addColumn('space_id', 'uuid', (col) =>
col.notNull().references('spaces.id').onDelete('cascade'),
)
.addColumn('type', 'varchar', (col) =>
col.notNull().defaultTo('expiring'),
)
.addColumn('type', 'varchar', (col) => col.notNull().defaultTo('expiring'))
.addColumn('status', 'varchar')
.addColumn('mode', 'varchar')
.addColumn('period_amount', 'integer')
@@ -27,7 +25,6 @@ export async function up(db: Kysely<any>): Promise<void> {
col.references('users.id').onDelete('set null'),
)
.addColumn('expires_at', 'timestamptz')
.addColumn('notified_at', 'timestamptz')
.addColumn('requested_at', 'timestamptz')
.addColumn('requested_by_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
@@ -55,17 +52,12 @@ export async function up(db: Kysely<any>): Promise<void> {
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('page_verification_id', 'uuid', (col) =>
col
.notNull()
.references('page_verifications.id')
.onDelete('cascade'),
col.notNull().references('page_verifications.id').onDelete('cascade'),
)
.addColumn('user_id', 'uuid', (col) =>
col.notNull().references('users.id').onDelete('cascade'),
)
.addColumn('is_primary', 'boolean', (col) =>
col.notNull().defaultTo(false),
)
.addColumn('is_primary', 'boolean', (col) => col.notNull().defaultTo(false))
.addColumn('added_by_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
@@ -80,12 +72,29 @@ export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createIndex('idx_page_verifications_expires_at')
.ifNotExists()
.on('page_verifications')
.column('expires_at')
.where('expires_at', 'is not', null)
.execute();
await db.schema
.createIndex('idx_page_verifications_workspace_id_id')
.ifNotExists()
.on('page_verifications')
.columns(['workspace_id', 'id desc'])
.execute();
await db.schema
.createIndex('idx_page_verifications_space_id')
.ifNotExists()
.on('page_verifications')
.column('space_id')
.execute();
await db.schema
.createIndex('idx_page_verifiers_user_id')
.ifNotExists()
.on('page_verifiers')
.column('user_id')
.execute();
-1
View File
@@ -443,7 +443,6 @@ export interface PageVerifications {
verifiedAt: Timestamp | null;
verifiedById: string | null;
expiresAt: Timestamp | null;
notifiedAt: Timestamp | null;
requestedAt: Timestamp | null;
requestedById: string | null;
rejectedAt: Timestamp | null;
@@ -73,6 +73,7 @@ export enum QueueJob {
PAGE_UPDATE_DIGEST = 'page-update-digest',
PAGE_VERIFICATION_EXPIRING = 'page-verification-expiring',
PAGE_VERIFICATION_EXPIRED = 'page-verification-expired',
VERIFICATION_RECONCILE = 'verification-reconcile',
PAGE_VERIFIED_NOTIFICATION = 'page-verified-notification',
PAGE_APPROVAL_REQUESTED_NOTIFICATION = 'page-approval-requested-notification',
PAGE_APPROVAL_REJECTED_NOTIFICATION = 'page-approval-rejected-notification',
@@ -79,20 +79,14 @@ export interface IPermissionGrantedNotificationJob {
export interface IVerificationExpiringNotificationJob {
verificationId: string;
pageId: string;
spaceId: string;
workspaceId: string;
verifierIds: string[];
expiresAt: string;
}
export interface IVerificationExpiredNotificationJob {
verificationId: string;
pageId: string;
spaceId: string;
workspaceId: string;
verifierIds: string[];
expiresAt: string;
}
export interface IVerificationReconcileJob {
// no payload
}
export interface IPageVerifiedNotificationJob {