diff --git a/apps/client/src/features/notification/types/notification.types.ts b/apps/client/src/features/notification/types/notification.types.ts
index f64e3648..266b9a6e 100644
--- a/apps/client/src/features/notification/types/notification.types.ts
+++ b/apps/client/src/features/notification/types/notification.types.ts
@@ -4,7 +4,12 @@ export type NotificationType =
| "comment.resolved"
| "page.user_mention"
| "page.permission_granted"
- | "page.updated";
+ | "page.updated"
+ | "page.verification_expiring"
+ | "page.verification_expired"
+ | "page.verified"
+ | "page.approval_requested"
+ | "page.approval_rejected";
export type INotification = {
id: string;
diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx
index 7b8412ad..8b81fbc3 100644
--- a/apps/client/src/features/page/components/header/page-header-menu.tsx
+++ b/apps/client/src/features/page/components/header/page-header-menu.tsx
@@ -44,6 +44,10 @@ import { PageStateSegmentedControl } from "@/features/user/components/page-state
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
import { PageShareModal } from "@/ee/page-permission";
+import {
+ PageVerificationMenuItem,
+ PageVerificationModal,
+} from "@/ee/page-verification";
import {
useFavoriteIds,
useAddFavoriteMutation,
@@ -135,6 +139,10 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
movePageModalOpened,
{ open: openMovePageModal, close: closeMoveSpaceModal },
] = useDisclosure(false);
+ const [
+ verificationOpened,
+ { open: openVerificationModal, close: closeVerificationModal },
+ ] = useDisclosure(false);
const [pageEditor] = useAtom(pageEditorAtom);
const pageUpdatedAt = useTimeAgo(page?.updatedAt);
const favoriteIds = useFavoriteIds("page");
@@ -261,6 +269,13 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
{t("Page history")}
+ {!readOnly && (
+
+ )}
+
{!readOnly && (
@@ -350,6 +365,12 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
onClose={closeMoveSpaceModal}
open={movePageModalOpened}
/>
+
+
>
);
}
diff --git a/apps/client/src/features/page/types/page.types.ts b/apps/client/src/features/page/types/page.types.ts
index 448b55ed..0bba09ff 100644
--- a/apps/client/src/features/page/types/page.types.ts
+++ b/apps/client/src/features/page/types/page.types.ts
@@ -22,6 +22,7 @@ export interface IPage {
creator: ICreator;
lastUpdatedBy: ILastUpdatedBy;
deletedBy: IDeletedBy;
+ contributors?: IContributor[];
space: Partial;
permissions?: {
canEdit: boolean;
@@ -29,6 +30,12 @@ export interface IPage {
};
}
+export interface IContributor {
+ id: string;
+ name: string;
+ avatarUrl: string;
+}
+
interface ICreator {
id: string;
name: string;
diff --git a/apps/client/src/features/websocket/types/types.ts b/apps/client/src/features/websocket/types/types.ts
index 1f20aca5..e925d0c1 100644
--- a/apps/client/src/features/websocket/types/types.ts
+++ b/apps/client/src/features/websocket/types/types.ts
@@ -85,6 +85,11 @@ export type RefetchRootTreeNodeEvent = {
spaceId: string;
};
+export type VerificationUpdatedEvent = {
+ operation: "verificationUpdated";
+ pageId: string;
+};
+
export type WebSocketEvent =
| InvalidateEvent
| CommentCreatedEvent
@@ -96,4 +101,5 @@ export type WebSocketEvent =
| AddTreeNodeEvent
| MoveTreeNodeEvent
| DeleteTreeNodeEvent
- | RefetchRootTreeNodeEvent;
+ | RefetchRootTreeNodeEvent
+ | VerificationUpdatedEvent;
diff --git a/apps/client/src/features/websocket/use-query-subscription.ts b/apps/client/src/features/websocket/use-query-subscription.ts
index c661f953..4bb11c5c 100644
--- a/apps/client/src/features/websocket/use-query-subscription.ts
+++ b/apps/client/src/features/websocket/use-query-subscription.ts
@@ -157,6 +157,11 @@ export const useQuerySubscription = () => {
});
break;
}
+ case "verificationUpdated":
+ queryClient.invalidateQueries({
+ queryKey: ["page-verification-info", data.pageId],
+ });
+ break;
}
});
}, [queryClient, socket]);
diff --git a/apps/client/src/pages/page/page.tsx b/apps/client/src/pages/page/page.tsx
index f0fc93fa..b4e3baa8 100644
--- a/apps/client/src/pages/page/page.tsx
+++ b/apps/client/src/pages/page/page.tsx
@@ -107,6 +107,8 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
slugId={page.slugId}
spaceSlug={page?.space?.slug}
editable={canEdit}
+ creator={page.creator}
+ contributors={page.contributors}
canComment={canComment}
/>
diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts
index 6cfed052..d8802b34 100644
--- a/apps/server/src/collaboration/collaboration.util.ts
+++ b/apps/server/src/collaboration/collaboration.util.ts
@@ -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,
diff --git a/apps/server/src/collaboration/processors/history.processor.ts b/apps/server/src/collaboration/processors/history.processor.ts
index 5374e745..6f26d3fa 100644
--- a/apps/server/src/collaboration/processors/history.processor.ts
+++ b/apps/server/src/collaboration/processors/history.processor.ts
@@ -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 as any)) {
+ this.logger.debug(
+ `Skipping first history for page ${pageId}: empty content`,
+ );
+ await this.collabHistory.clearContributors(pageId);
+ return;
+ }
+
if (
!lastHistory ||
!isDeepStrictEqual(lastHistory.content, page.content)
diff --git a/apps/server/src/common/events/audit-events.ts b/apps/server/src/common/events/audit-events.ts
index 144cb22d..955a6ba5 100644
--- a/apps/server/src/common/events/audit-events.ts
+++ b/apps/server/src/common/events/audit-events.ts
@@ -59,6 +59,14 @@ export const AuditEvent = {
PAGE_RESTRICTION_REMOVED: 'page.restriction_removed',
PAGE_PERMISSION_ADDED: 'page.permission_added',
PAGE_PERMISSION_REMOVED: 'page.permission_removed',
+ // Page verification
+ PAGE_VERIFICATION_CREATED: 'page.verification_created',
+ PAGE_VERIFICATION_UPDATED: 'page.verification_updated',
+ PAGE_VERIFICATION_REMOVED: 'page.verification_removed',
+ PAGE_VERIFIED: 'page.verified',
+ PAGE_APPROVAL_REQUESTED: 'page.approval_requested',
+ PAGE_APPROVAL_REJECTED: 'page.approval_rejected',
+ PAGE_MARKED_OBSOLETE: 'page.marked_obsolete',
// Share
SHARE_CREATED: 'share.created',
diff --git a/apps/server/src/core/notification/notification.constants.ts b/apps/server/src/core/notification/notification.constants.ts
index 8f7f5049..fc42bc64 100644
--- a/apps/server/src/core/notification/notification.constants.ts
+++ b/apps/server/src/core/notification/notification.constants.ts
@@ -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 =
diff --git a/apps/server/src/core/notification/notification.module.ts b/apps/server/src/core/notification/notification.module.ts
index 83778294..9aa452a5 100644
--- a/apps/server/src/core/notification/notification.module.ts
+++ b/apps/server/src/core/notification/notification.module.ts
@@ -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],
diff --git a/apps/server/src/core/notification/notification.processor.ts b/apps/server/src/core/notification/notification.processor.ts
index e3d3a883..c477c398 100644
--- a/apps/server/src/core/notification/notification.processor.ts
+++ b/apps/server/src/core/notification/notification.processor.ts
@@ -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 {
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 {
+ 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 {
+ 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 })
+ | 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 {
const workspace = await this.db
.selectFrom('workspaces')
diff --git a/apps/server/src/core/notification/services/verification.notification.ts b/apps/server/src/core/notification/services/verification.notification.ts
new file mode 100644
index 00000000..351649e5
--- /dev/null
+++ b/apps/server/src/core/notification/services/verification.notification.ts
@@ -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> {
+ 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 {
+ 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 {
+ 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 };
+ }
+}
diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts
index b62d9864..0c8149f9 100644
--- a/apps/server/src/core/page/services/page.service.ts
+++ b/apps/server/src/core/page/services/page.service.ts
@@ -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 },
diff --git a/apps/server/src/database/migrations/20260413T121647-page-verifications.ts b/apps/server/src/database/migrations/20260413T121647-page-verifications.ts
new file mode 100644
index 00000000..9d501506
--- /dev/null
+++ b/apps/server/src/database/migrations/20260413T121647-page-verifications.ts
@@ -0,0 +1,117 @@
+import { Kysely, sql } from 'kysely';
+
+export async function up(db: Kysely): Promise {
+ await db.schema
+ .createTable('page_verifications')
+ .addColumn('id', 'uuid', (col) =>
+ col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
+ )
+ .addColumn('page_id', 'uuid', (col) =>
+ col.notNull().unique().references('pages.id').onDelete('cascade'),
+ )
+ .addColumn('workspace_id', 'uuid', (col) =>
+ col.notNull().references('workspaces.id').onDelete('cascade'),
+ )
+ .addColumn('space_id', 'uuid', (col) =>
+ col.notNull().references('spaces.id').onDelete('cascade'),
+ )
+ .addColumn('type', 'varchar', (col) => col.notNull().defaultTo('expiring'))
+ .addColumn('status', 'varchar')
+ .addColumn('mode', 'varchar')
+ .addColumn('period_amount', 'integer')
+ .addColumn('period_unit', 'varchar')
+ .addColumn('verified_at', 'timestamptz')
+ .addColumn('verified_by_id', 'uuid', (col) =>
+ col.references('users.id').onDelete('set null'),
+ )
+ .addColumn('expires_at', 'timestamptz')
+ .addColumn('requested_at', 'timestamptz')
+ .addColumn('requested_by_id', 'uuid', (col) =>
+ col.references('users.id').onDelete('set null'),
+ )
+ .addColumn('rejected_at', 'timestamptz')
+ .addColumn('rejected_by_id', 'uuid', (col) =>
+ col.references('users.id').onDelete('set null'),
+ )
+ .addColumn('rejection_comment', 'text')
+ .addColumn('data', 'jsonb')
+ .addColumn('creator_id', 'uuid', (col) =>
+ col.references('users.id').onDelete('set null'),
+ )
+ .addColumn('created_at', 'timestamptz', (col) =>
+ col.notNull().defaultTo(sql`now()`),
+ )
+ .addColumn('updated_at', 'timestamptz', (col) =>
+ col.notNull().defaultTo(sql`now()`),
+ )
+ .execute();
+
+ await db.schema
+ .createTable('page_verifiers')
+ .addColumn('id', 'uuid', (col) =>
+ col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
+ )
+ .addColumn('page_verification_id', 'uuid', (col) =>
+ 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('added_by_id', 'uuid', (col) =>
+ col.references('users.id').onDelete('set null'),
+ )
+ .addColumn('created_at', 'timestamptz', (col) =>
+ col.notNull().defaultTo(sql`now()`),
+ )
+ .addUniqueConstraint('page_verifiers_verification_user_unique', [
+ 'page_verification_id',
+ 'user_id',
+ ])
+ .execute();
+
+ 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();
+
+ await db.schema
+ .alterTable('notifications')
+ .addColumn('page_verification_id', 'uuid', (col) =>
+ col.references('page_verifications.id').onDelete('cascade'),
+ )
+ .execute();
+}
+
+export async function down(db: Kysely): Promise {
+ await db.schema
+ .alterTable('notifications')
+ .dropColumn('page_verification_id')
+ .execute();
+ await db.schema.dropTable('page_verifiers').ifExists().execute();
+ await db.schema.dropTable('page_verifications').ifExists().execute();
+}
diff --git a/apps/server/src/database/repos/notification/notification.repo.ts b/apps/server/src/database/repos/notification/notification.repo.ts
index 2914dbfc..9abca423 100644
--- a/apps/server/src/database/repos/notification/notification.repo.ts
+++ b/apps/server/src/database/repos/notification/notification.repo.ts
@@ -11,7 +11,10 @@ import { ExpressionBuilder } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
-import { NotificationTab, NotificationType } from '../../../core/notification/notification.constants';
+import {
+ NotificationTab,
+ NotificationType,
+} from '../../../core/notification/notification.constants';
@Injectable()
export class NotificationRepo {
@@ -43,7 +46,11 @@ export class NotificationRepo {
.where((eb) =>
eb.or([
eb('spaceId', 'is', null),
- eb('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)),
+ eb(
+ 'spaceId',
+ 'in',
+ this.spaceMemberRepo.getUserSpaceIdsQuery(userId),
+ ),
]),
);
@@ -62,6 +69,14 @@ export class NotificationRepo {
});
}
+ async insert(notification: InsertableNotification): Promise {
+ return this.db
+ .insertInto('notifications')
+ .values(notification)
+ .returningAll()
+ .executeTakeFirst();
+ }
+
async getUnreadCount(userId: string): Promise {
const result = await this.db
.selectFrom('notifications')
@@ -71,7 +86,11 @@ export class NotificationRepo {
.where((eb) =>
eb.or([
eb('spaceId', 'is', null),
- eb('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)),
+ eb(
+ 'spaceId',
+ 'in',
+ this.spaceMemberRepo.getUserSpaceIdsQuery(userId),
+ ),
]),
)
.executeTakeFirst();
@@ -79,14 +98,6 @@ export class NotificationRepo {
return Number(result?.count ?? 0);
}
- async insert(notification: InsertableNotification): Promise {
- return this.db
- .insertInto('notifications')
- .values(notification)
- .returningAll()
- .executeTakeFirst();
- }
-
async markAsRead(notificationId: string, userId: string): Promise {
await this.db
.updateTable('notifications')
@@ -94,12 +105,6 @@ export class NotificationRepo {
.where('id', '=', notificationId)
.where('userId', '=', userId)
.where('readAt', 'is', null)
- .where((eb) =>
- eb.or([
- eb('spaceId', 'is', null),
- eb('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)),
- ]),
- )
.execute();
}
@@ -116,21 +121,6 @@ export class NotificationRepo {
.where('id', 'in', notificationIds)
.where('userId', '=', userId)
.where('readAt', 'is', null)
- .where((eb) =>
- eb.or([
- eb('spaceId', 'is', null),
- eb('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)),
- ]),
- )
- .execute();
- }
-
- async markAsEmailed(notificationId: string): Promise {
- await this.db
- .updateTable('notifications')
- .set({ emailedAt: new Date() })
- .where('id', '=', notificationId)
- .where('emailedAt', 'is', null)
.execute();
}
@@ -140,12 +130,15 @@ export class NotificationRepo {
.set({ readAt: new Date() })
.where('userId', '=', userId)
.where('readAt', 'is', null)
- .where((eb) =>
- eb.or([
- eb('spaceId', 'is', null),
- eb('spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(userId)),
- ]),
- )
+ .execute();
+ }
+
+ async markAsEmailed(notificationId: string): Promise {
+ await this.db
+ .updateTable('notifications')
+ .set({ emailedAt: new Date() })
+ .where('id', '=', notificationId)
+ .where('emailedAt', 'is', null)
.execute();
}
diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts
index 6ec3790c..9df706ca 100644
--- a/apps/server/src/database/types/db.d.ts
+++ b/apps/server/src/database/types/db.d.ts
@@ -400,6 +400,7 @@ export interface Notifications {
pageId: string | null;
spaceId: string | null;
commentId: string | null;
+ pageVerificationId: string | null;
data: Json | null;
readAt: Timestamp | null;
emailedAt: Timestamp | null;
@@ -441,6 +442,39 @@ export interface PagePermissions {
updatedAt: Generated;
}
+export interface PageVerifications {
+ id: Generated;
+ pageId: string;
+ workspaceId: string;
+ spaceId: string;
+ type: Generated;
+ status: string | null;
+ mode: string | null;
+ periodAmount: number | null;
+ periodUnit: string | null;
+ verifiedAt: Timestamp | null;
+ verifiedById: string | null;
+ expiresAt: Timestamp | null;
+ requestedAt: Timestamp | null;
+ requestedById: string | null;
+ rejectedAt: Timestamp | null;
+ rejectedById: string | null;
+ rejectionComment: string | null;
+ data: Json | null;
+ creatorId: string | null;
+ createdAt: Generated;
+ updatedAt: Generated;
+}
+
+export interface PageVerifiers {
+ id: Generated;
+ pageVerificationId: string;
+ userId: string;
+ isPrimary: Generated;
+ addedById: string | null;
+ createdAt: Generated;
+}
+
export interface Templates {
id: Generated;
title: string | null;
@@ -519,6 +553,8 @@ export interface DB {
pageAccess: PageAccess;
pagePermissions: PagePermissions;
pageHistory: PageHistory;
+ pageVerifications: PageVerifications;
+ pageVerifiers: PageVerifiers;
pages: Pages;
shares: Shares;
spaceMembers: SpaceMembers;
diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts
index a15b99f4..481321bc 100644
--- a/apps/server/src/database/types/entity.types.ts
+++ b/apps/server/src/database/types/entity.types.ts
@@ -8,6 +8,8 @@ import {
Notifications,
PageAccess as _PageAccess,
PagePermissions as _PagePermissions,
+ PageVerifications as _PageVerifications,
+ PageVerifiers as _PageVerifiers,
Pages,
Spaces,
Users,
@@ -182,6 +184,15 @@ export type PagePermission = Selectable<_PagePermissions>;
export type InsertablePagePermission = Insertable<_PagePermissions>;
export type UpdatablePagePermission = Updateable>;
+// Page Verification
+export type PageVerification = Selectable<_PageVerifications>;
+export type InsertablePageVerification = Insertable<_PageVerifications>;
+export type UpdatablePageVerification = Updateable>;
+
+// Page Verifier
+export type PageVerifier = Selectable<_PageVerifiers>;
+export type InsertablePageVerifier = Insertable<_PageVerifiers>;
+
// User Session
export type UserSession = Selectable;
export type InsertableUserSession = Insertable;
diff --git a/apps/server/src/ee b/apps/server/src/ee
index e9429b8f..a5b5e10e 160000
--- a/apps/server/src/ee
+++ b/apps/server/src/ee
@@ -1 +1 @@
-Subproject commit e9429b8fdfdc3402b6a18232d08961c8f133dd5d
+Subproject commit a5b5e10eec0363463d920c7ffdd9f5e51bb474ff
diff --git a/apps/server/src/integrations/queue/constants/queue.constants.ts b/apps/server/src/integrations/queue/constants/queue.constants.ts
index 6d92f09c..03460739 100644
--- a/apps/server/src/integrations/queue/constants/queue.constants.ts
+++ b/apps/server/src/integrations/queue/constants/queue.constants.ts
@@ -71,6 +71,12 @@ export enum QueueJob {
PAGE_MENTION_NOTIFICATION = 'page-mention-notification',
PAGE_PERMISSION_GRANTED = 'page-permission-granted',
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',
AUDIT_LOG = 'audit-log',
AUDIT_CLEANUP = 'audit-cleanup',
diff --git a/apps/server/src/integrations/queue/constants/queue.interface.ts b/apps/server/src/integrations/queue/constants/queue.interface.ts
index 58937419..6dea53c3 100644
--- a/apps/server/src/integrations/queue/constants/queue.interface.ts
+++ b/apps/server/src/integrations/queue/constants/queue.interface.ts
@@ -76,3 +76,40 @@ export interface IPermissionGrantedNotificationJob {
actorId: string;
role: string;
}
+
+export interface IVerificationExpiringNotificationJob {
+ verificationId: string;
+}
+
+export interface IVerificationExpiredNotificationJob {
+ verificationId: string;
+}
+
+export interface IVerificationReconcileJob {
+ // no payload
+}
+
+export interface IPageVerifiedNotificationJob {
+ pageId: string;
+ spaceId: string;
+ workspaceId: string;
+ actorId: string;
+ verifierIds: string[];
+}
+
+export interface IApprovalRequestedNotificationJob {
+ pageId: string;
+ spaceId: string;
+ workspaceId: string;
+ actorId: string;
+ verifierIds: string[];
+}
+
+export interface IApprovalRejectedNotificationJob {
+ pageId: string;
+ spaceId: string;
+ workspaceId: string;
+ actorId: string;
+ requestedById: string;
+ comment?: string;
+}
diff --git a/apps/server/src/integrations/transactional/emails/approval-rejected-email.tsx b/apps/server/src/integrations/transactional/emails/approval-rejected-email.tsx
new file mode 100644
index 00000000..c647ef8d
--- /dev/null
+++ b/apps/server/src/integrations/transactional/emails/approval-rejected-email.tsx
@@ -0,0 +1,41 @@
+import { Section, Text } from '@react-email/components';
+import * as React from 'react';
+import { content, paragraph } from '../css/styles';
+import { EmailButton, MailBody } from '../partials/partials';
+
+interface Props {
+ actorName: string;
+ pageTitle: string;
+ spaceName: string;
+ pageUrl: string;
+ comment?: string;
+}
+
+export const ApprovalRejectedEmail = ({
+ actorName,
+ pageTitle,
+ spaceName,
+ pageUrl,
+ comment,
+}: Props) => {
+ return (
+
+
+ Hi there,
+
+ {actorName} returned{' '}
+ {pageTitle} in the{' '}
+ {spaceName} space for revision.
+
+ {comment && (
+
+ “{comment}”
+
+ )}
+
+ View page
+
+ );
+};
+
+export default ApprovalRejectedEmail;
diff --git a/apps/server/src/integrations/transactional/emails/approval-requested-email.tsx b/apps/server/src/integrations/transactional/emails/approval-requested-email.tsx
new file mode 100644
index 00000000..f3c27659
--- /dev/null
+++ b/apps/server/src/integrations/transactional/emails/approval-requested-email.tsx
@@ -0,0 +1,34 @@
+import { Section, Text } from '@react-email/components';
+import * as React from 'react';
+import { content, paragraph } from '../css/styles';
+import { EmailButton, MailBody } from '../partials/partials';
+
+interface Props {
+ actorName: string;
+ pageTitle: string;
+ spaceName: string;
+ pageUrl: string;
+}
+
+export const ApprovalRequestedEmail = ({
+ actorName,
+ pageTitle,
+ spaceName,
+ pageUrl,
+}: Props) => {
+ return (
+
+
+ Hi there,
+
+ {actorName} submitted{' '}
+ {pageTitle} in the{' '}
+ {spaceName} space for your approval.
+
+
+ Review page
+
+ );
+};
+
+export default ApprovalRequestedEmail;
diff --git a/apps/server/src/integrations/transactional/emails/page-update-email.tsx b/apps/server/src/integrations/transactional/emails/page-update-email.tsx
index c4c85769..876b4f31 100644
--- a/apps/server/src/integrations/transactional/emails/page-update-email.tsx
+++ b/apps/server/src/integrations/transactional/emails/page-update-email.tsx
@@ -27,7 +27,7 @@ export const PageUpdateEmail = ({
{pageTitle}
{' '}
- in {spaceName}.
+ in the {spaceName} space.
View page
diff --git a/apps/server/src/integrations/transactional/emails/verification-expired-email.tsx b/apps/server/src/integrations/transactional/emails/verification-expired-email.tsx
new file mode 100644
index 00000000..be808333
--- /dev/null
+++ b/apps/server/src/integrations/transactional/emails/verification-expired-email.tsx
@@ -0,0 +1,28 @@
+import { Section, Text } from '@react-email/components';
+import * as React from 'react';
+import { content, paragraph } from '../css/styles';
+import { EmailButton, MailBody } from '../partials/partials';
+
+interface Props {
+ pageTitle: string;
+ spaceName: string;
+ pageUrl: string;
+}
+
+export const VerificationExpiredEmail = ({ pageTitle, spaceName, pageUrl }: Props) => {
+ return (
+
+
+ Hi there,
+
+ The verification for {pageTitle} in the{' '}
+ {spaceName} space has expired. Please re-verify the
+ page to confirm it is still accurate.
+
+
+ Re-verify page
+
+ );
+};
+
+export default VerificationExpiredEmail;
diff --git a/apps/server/src/integrations/transactional/emails/verification-expiring-email.tsx b/apps/server/src/integrations/transactional/emails/verification-expiring-email.tsx
new file mode 100644
index 00000000..16bb4977
--- /dev/null
+++ b/apps/server/src/integrations/transactional/emails/verification-expiring-email.tsx
@@ -0,0 +1,34 @@
+import { Section, Text } from '@react-email/components';
+import * as React from 'react';
+import { content, paragraph } from '../css/styles';
+import { EmailButton, MailBody } from '../partials/partials';
+
+interface Props {
+ pageTitle: string;
+ spaceName: string;
+ pageUrl: string;
+ expiresAt: string;
+}
+
+export const VerificationExpiringEmail = ({
+ pageTitle,
+ spaceName,
+ pageUrl,
+ expiresAt,
+}: Props) => {
+ return (
+
+
+ Hi there,
+
+ The page {pageTitle} in the{' '}
+ {spaceName} space needs to be re-verified. The
+ verification expires on {expiresAt}.
+
+
+ Review page
+
+ );
+};
+
+export default VerificationExpiringEmail;