feat: page verification workflow

This commit is contained in:
Philipinho
2026-04-11 16:21:43 +01:00
parent b4f009513e
commit 759ce0611d
45 changed files with 3470 additions and 20 deletions
+2
View File
@@ -25,6 +25,7 @@ import { CacheModule } from '@nestjs/cache-manager';
import KeyvRedis from '@keyv/redis';
import { LoggerModule } from './common/logger/logger.module';
import { ClsModule } from 'nestjs-cls';
import { NoopAuditModule } from './integrations/audit/audit.module';
const enterpriseModules = [];
try {
@@ -47,6 +48,7 @@ try {
middleware: { mount: true },
}),
LoggerModule,
NoopAuditModule,
CoreModule,
DatabaseModule,
EnvironmentModule,
@@ -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',
-11
View File
@@ -20,10 +20,6 @@ import { AuditContextMiddleware } from '../common/middlewares/audit-context.midd
import { ShareModule } from './share/share.module';
import { NotificationModule } from './notification/notification.module';
import { WatcherModule } from './watcher/watcher.module';
import {
AUDIT_SERVICE,
NoopAuditService,
} from '../integrations/audit/audit.service';
import { ClsMiddleware } from 'nestjs-cls';
@Module({
@@ -43,13 +39,6 @@ import { ClsMiddleware } from 'nestjs-cls';
NotificationModule,
WatcherModule,
],
providers: [
{
provide: AUDIT_SERVICE,
useClass: NoopAuditService,
},
],
exports: [AUDIT_SERVICE],
})
export class CoreModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
@@ -4,6 +4,11 @@ export const NotificationType = {
COMMENT_RESOLVED: 'comment.resolved',
PAGE_USER_MENTION: 'page.user_mention',
PAGE_PERMISSION_GRANTED: 'page.permission_granted',
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';
@Module({
imports: [],
@@ -13,6 +14,7 @@ import { PageNotificationService } from './services/page.notification';
NotificationProcessor,
CommentNotificationService,
PageNotificationService,
VerificationNotificationService,
],
exports: [NotificationService],
})
@@ -5,13 +5,19 @@ 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,
IPageVerifiedNotificationJob,
IPermissionGrantedNotificationJob,
IVerificationExpiringNotificationJob,
IVerificationExpiredNotificationJob,
} 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)
@@ -24,6 +30,7 @@ export class NotificationProcessor
constructor(
private readonly commentNotificationService: CommentNotificationService,
private readonly pageNotificationService: PageNotificationService,
private readonly verificationNotificationService: VerificationNotificationService,
private readonly domainService: DomainService,
@InjectKysely() private readonly db: KyselyDB,
) {
@@ -35,7 +42,12 @@ export class NotificationProcessor
| ICommentNotificationJob
| ICommentResolvedNotificationJob
| IPageMentionNotificationJob
| IPermissionGrantedNotificationJob,
| IPermissionGrantedNotificationJob
| IVerificationExpiringNotificationJob
| IVerificationExpiredNotificationJob
| IPageVerifiedNotificationJob
| IApprovalRequestedNotificationJob
| IApprovalRejectedNotificationJob,
void
>,
): Promise<void> {
@@ -76,6 +88,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}`);
}
@@ -0,0 +1,221 @@
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';
@Injectable()
export class VerificationNotificationService {
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly notificationService: NotificationService,
) {}
async processVerificationExpiring(
data: IVerificationExpiringNotificationJob,
appUrl: string,
) {
const { verifierIds, pageId, spaceId, workspaceId, expiresAt } = data;
if (verifierIds.length === 0) return;
const context = await this.getPageContext(pageId, spaceId, appUrl);
if (!context) return;
const { pageTitle, basePageUrl } = context;
for (const userId of verifierIds) {
const notification = await this.notificationService.create({
userId,
workspaceId,
type: NotificationType.PAGE_VERIFICATION_EXPIRING,
pageId,
spaceId,
data: { expiresAt },
});
const subject = `"${pageTitle}" needs to be re-verified soon`;
await this.notificationService.queueEmail(
userId,
notification.id,
subject,
VerificationExpiringEmail({
pageTitle,
pageUrl: basePageUrl,
expiresAt: new Date(expiresAt).toLocaleDateString(),
}),
);
}
}
async processVerificationExpired(
data: IVerificationExpiredNotificationJob,
appUrl: string,
) {
const { verifierIds, pageId, spaceId, workspaceId } = data;
if (verifierIds.length === 0) return;
const context = await this.getPageContext(pageId, spaceId, appUrl);
if (!context) return;
const { pageTitle, basePageUrl } = context;
for (const userId of verifierIds) {
const notification = await this.notificationService.create({
userId,
workspaceId,
type: NotificationType.PAGE_VERIFICATION_EXPIRED,
pageId,
spaceId,
});
const subject = `"${pageTitle}" verification has expired`;
await this.notificationService.queueEmail(
userId,
notification.id,
subject,
VerificationExpiredEmail({
pageTitle,
pageUrl: basePageUrl,
}),
);
}
}
async processPageVerified(data: IPageVerifiedNotificationJob) {
const { verifierIds, pageId, spaceId, workspaceId, actorId } = data;
if (verifierIds.length === 0) return;
for (const userId of verifierIds) {
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 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) {
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,
pageUrl: basePageUrl,
}),
);
}
}
async processApprovalRejected(
data: IApprovalRejectedNotificationJob,
appUrl: string,
) {
const { pageId, spaceId, workspaceId, actorId, requestedById, comment } =
data;
const context = await this.getPageContext(pageId, spaceId, appUrl);
if (!context) return;
const { pageTitle, 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,
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'])
.where('id', '=', spaceId)
.executeTakeFirst(),
]);
if (!page || !space) return null;
const basePageUrl = `${appUrl}/s/${space.slug}/p/${page.slugId}`;
return { pageTitle: getPageTitle(page.title), basePageUrl };
}
}
@@ -0,0 +1,97 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
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('notified_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')
.on('page_verifications')
.column('expires_at')
.execute();
await db.schema
.createIndex('idx_page_verifiers_user_id')
.on('page_verifiers')
.column('user_id')
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('page_verifiers').ifExists().execute();
await db.schema.dropTable('page_verifications').ifExists().execute();
}
+36
View File
@@ -429,6 +429,40 @@ export interface PagePermissions {
updatedAt: Generated<Timestamp>;
}
export interface PageVerifications {
id: Generated<string>;
pageId: string;
workspaceId: string;
spaceId: string;
type: Generated<string>;
status: string | null;
mode: string | null;
periodAmount: number | null;
periodUnit: string | null;
verifiedAt: Timestamp | null;
verifiedById: string | null;
expiresAt: Timestamp | null;
notifiedAt: 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<Timestamp>;
updatedAt: Generated<Timestamp>;
}
export interface PageVerifiers {
id: Generated<string>;
pageVerificationId: string;
userId: string;
isPrimary: Generated<boolean>;
addedById: string | null;
createdAt: Generated<Timestamp>;
}
export interface DB {
apiKeys: ApiKeys;
attachments: Attachments;
@@ -445,6 +479,8 @@ export interface DB {
pageAccess: PageAccess;
pagePermissions: PagePermissions;
pageHistory: PageHistory;
pageVerifications: PageVerifications;
pageVerifiers: PageVerifiers;
pages: Pages;
shares: Shares;
spaceMembers: SpaceMembers;
@@ -6,6 +6,8 @@ import {
Notifications,
PageAccess as _PageAccess,
PagePermissions as _PagePermissions,
PageVerifications as _PageVerifications,
PageVerifiers as _PageVerifiers,
Pages,
Spaces,
Users,
@@ -157,6 +159,15 @@ export type PagePermission = Selectable<_PagePermissions>;
export type InsertablePagePermission = Insertable<_PagePermissions>;
export type UpdatablePagePermission = Updateable<Omit<_PagePermissions, 'id'>>;
// Page Verification
export type PageVerification = Selectable<_PageVerifications>;
export type InsertablePageVerification = Insertable<_PageVerifications>;
export type UpdatablePageVerification = Updateable<Omit<_PageVerifications, 'id'>>;
// Page Verifier
export type PageVerifier = Selectable<_PageVerifiers>;
export type InsertablePageVerifier = Insertable<_PageVerifiers>;
// Audit
export type Audit = Selectable<_Audit>;
export type InsertableAudit = Insertable<_Audit>;
@@ -0,0 +1,14 @@
import { Global, Module } from '@nestjs/common';
import { AUDIT_SERVICE, NoopAuditService } from './audit.service';
@Global()
@Module({
providers: [
{
provide: AUDIT_SERVICE,
useClass: NoopAuditService,
},
],
exports: [AUDIT_SERVICE],
})
export class NoopAuditModule {}
@@ -69,6 +69,11 @@ export enum QueueJob {
COMMENT_RESOLVED_NOTIFICATION = 'comment-resolved-notification',
PAGE_MENTION_NOTIFICATION = 'page-mention-notification',
PAGE_PERMISSION_GRANTED = 'page-permission-granted',
PAGE_VERIFICATION_EXPIRING = 'page-verification-expiring',
PAGE_VERIFICATION_EXPIRED = 'page-verification-expired',
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',
@@ -68,3 +68,46 @@ export interface IPermissionGrantedNotificationJob {
actorId: string;
role: string;
}
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 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;
}
@@ -0,0 +1,50 @@
import { Section, Text, Button } from '@react-email/components';
import * as React from 'react';
import { button, content, paragraph } from '../css/styles';
import { MailBody } from '../partials/partials';
interface Props {
actorName: string;
pageTitle: string;
pageUrl: string;
comment?: string;
}
export const ApprovalRejectedEmail = ({
actorName,
pageTitle,
pageUrl,
comment,
}: Props) => {
return (
<MailBody>
<Section style={content}>
<Text style={paragraph}>Hi there,</Text>
<Text style={paragraph}>
<strong>{actorName}</strong> returned{' '}
<strong>{pageTitle}</strong> for revision.
</Text>
{comment && (
<Text style={{ ...paragraph, fontStyle: 'italic' }}>
&ldquo;{comment}&rdquo;
</Text>
)}
</Section>
<Section
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingLeft: '15px',
paddingBottom: '15px',
}}
>
<Button href={pageUrl} style={button}>
View page
</Button>
</Section>
</MailBody>
);
};
export default ApprovalRejectedEmail;
@@ -0,0 +1,43 @@
import { Section, Text, Button } from '@react-email/components';
import * as React from 'react';
import { button, content, paragraph } from '../css/styles';
import { MailBody } from '../partials/partials';
interface Props {
actorName: string;
pageTitle: string;
pageUrl: string;
}
export const ApprovalRequestedEmail = ({
actorName,
pageTitle,
pageUrl,
}: Props) => {
return (
<MailBody>
<Section style={content}>
<Text style={paragraph}>Hi there,</Text>
<Text style={paragraph}>
<strong>{actorName}</strong> submitted{' '}
<strong>{pageTitle}</strong> for your approval.
</Text>
</Section>
<Section
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingLeft: '15px',
paddingBottom: '15px',
}}
>
<Button href={pageUrl} style={button}>
Review page
</Button>
</Section>
</MailBody>
);
};
export default ApprovalRequestedEmail;
@@ -0,0 +1,38 @@
import { Section, Text, Button } from '@react-email/components';
import * as React from 'react';
import { button, content, paragraph } from '../css/styles';
import { MailBody } from '../partials/partials';
interface Props {
pageTitle: string;
pageUrl: string;
}
export const VerificationExpiredEmail = ({ pageTitle, pageUrl }: Props) => {
return (
<MailBody>
<Section style={content}>
<Text style={paragraph}>Hi there,</Text>
<Text style={paragraph}>
The verification for <strong>{pageTitle}</strong> has expired. Please
re-verify the page to confirm it is still accurate.
</Text>
</Section>
<Section
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingLeft: '15px',
paddingBottom: '15px',
}}
>
<Button href={pageUrl} style={button}>
Re-verify page
</Button>
</Section>
</MailBody>
);
};
export default VerificationExpiredEmail;
@@ -0,0 +1,43 @@
import { Section, Text, Button } from '@react-email/components';
import * as React from 'react';
import { button, content, paragraph } from '../css/styles';
import { MailBody } from '../partials/partials';
interface Props {
pageTitle: string;
pageUrl: string;
expiresAt: string;
}
export const VerificationExpiringEmail = ({
pageTitle,
pageUrl,
expiresAt,
}: Props) => {
return (
<MailBody>
<Section style={content}>
<Text style={paragraph}>Hi there,</Text>
<Text style={paragraph}>
The page <strong>{pageTitle}</strong> needs to be re-verified. The
verification expires on <strong>{expiresAt}</strong>.
</Text>
</Section>
<Section
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingLeft: '15px',
paddingBottom: '15px',
}}
>
<Button href={pageUrl} style={button}>
Review page
</Button>
</Section>
</MailBody>
);
};
export default VerificationExpiringEmail;