From 0f02261ee6a9b2becdafbe118c4a6dace09100ab Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Mon, 9 Feb 2026 18:25:35 -0800 Subject: [PATCH] feat: page version history improvements (#1925) * Refactor: use queue for page history * feat: save multiple version contributors * display contributor avatars in history list * fix interval --- .../page-history/components/history-item.tsx | 68 +++++++++++---- .../components/history-modal-mobile.tsx | 17 ++-- .../features/page-history/types/page.types.ts | 1 + .../src/collaboration/collaboration.module.ts | 6 +- apps/server/src/collaboration/constants.ts | 3 + .../extensions/persistence.extension.ts | 54 ++++++++---- .../listeners/history.listener.ts | 52 ------------ .../processors/history.processor.ts | 84 +++++++++++++++++++ .../services/collab-history.service.ts | 30 +++++++ ...000-add-contributor-ids-to-page-history.ts | 15 ++++ .../database/repos/page/page-history.repo.ts | 28 ++++++- apps/server/src/database/types/db.d.ts | 1 + .../queue/constants/queue.constants.ts | 3 + .../queue/constants/queue.interface.ts | 4 + .../src/integrations/queue/queue.module.ts | 8 ++ 15 files changed, 279 insertions(+), 95 deletions(-) create mode 100644 apps/server/src/collaboration/constants.ts delete mode 100644 apps/server/src/collaboration/listeners/history.listener.ts create mode 100644 apps/server/src/collaboration/processors/history.processor.ts create mode 100644 apps/server/src/collaboration/services/collab-history.service.ts create mode 100644 apps/server/src/database/migrations/20260209T120000-add-contributor-ids-to-page-history.ts diff --git a/apps/client/src/features/page-history/components/history-item.tsx b/apps/client/src/features/page-history/components/history-item.tsx index e44614c4..cc56b191 100644 --- a/apps/client/src/features/page-history/components/history-item.tsx +++ b/apps/client/src/features/page-history/components/history-item.tsx @@ -1,4 +1,4 @@ -import { Text, Group, UnstyledButton } from "@mantine/core"; +import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { formattedDate } from "@/lib/time"; import classes from "./css/history.module.css"; @@ -6,6 +6,8 @@ import clsx from "clsx"; import { IPageHistory } from "@/features/page-history/types/page.types"; import { memo, useCallback } from "react"; +const MAX_VISIBLE_AVATARS = 5; + interface HistoryItemProps { historyItem: IPageHistory; index: number; @@ -31,6 +33,9 @@ const HistoryItem = memo(function HistoryItem({ onHover?.(historyItem.id, index); }, [onHover, historyItem.id, index]); + const contributors = historyItem.contributors; + const hasContributors = contributors && contributors.length > 0; + return ( - -
- - {formattedDate(new Date(historyItem.createdAt))} - + {formattedDate(new Date(historyItem.createdAt))} -
- - + + {hasContributors ? ( + <> + + + {contributors.slice(0, MAX_VISIBLE_AVATARS).map((contributor) => ( + + + + ))} + {contributors.length > MAX_VISIBLE_AVATARS && ( + ( +
{c.name}
+ ))} + > + + +{contributors.length - MAX_VISIBLE_AVATARS} + +
+ )} +
+
+ {contributors.length === 1 && ( - {historyItem.lastUpdatedBy?.name} + {contributors[0].name} -
-
-
+ )} + + ) : ( + <> + + + {historyItem.lastUpdatedBy?.name} + + + )}
); diff --git a/apps/client/src/features/page-history/components/history-modal-mobile.tsx b/apps/client/src/features/page-history/components/history-modal-mobile.tsx index 0a54a01c..b73695da 100644 --- a/apps/client/src/features/page-history/components/history-modal-mobile.tsx +++ b/apps/client/src/features/page-history/components/history-modal-mobile.tsx @@ -62,11 +62,18 @@ export default function HistoryModalMobile({ pageId, pageTitle }: Props) { const selectData = useMemo( () => - historyItems.map((item) => ({ - value: item.id, - label: formattedDate(new Date(item.createdAt)), - userName: item.lastUpdatedBy?.name, - })), + historyItems.map((item) => { + const contributors = item.contributors; + const hasContributors = contributors && contributors.length > 0; + const names = hasContributors + ? contributors.map((c) => c.name).join(", ") + : item.lastUpdatedBy?.name; + return { + value: item.id, + label: formattedDate(new Date(item.createdAt)), + userName: names, + }; + }), [historyItems], ); diff --git a/apps/client/src/features/page-history/types/page.types.ts b/apps/client/src/features/page-history/types/page.types.ts index 98a5b9d7..45b2e1f8 100644 --- a/apps/client/src/features/page-history/types/page.types.ts +++ b/apps/client/src/features/page-history/types/page.types.ts @@ -18,4 +18,5 @@ export interface IPageHistory { createdAt: string; updatedAt: string; lastUpdatedBy: IPageHistoryUser; + contributors?: IPageHistoryUser[]; } diff --git a/apps/server/src/collaboration/collaboration.module.ts b/apps/server/src/collaboration/collaboration.module.ts index e9374c53..c4444d0b 100644 --- a/apps/server/src/collaboration/collaboration.module.ts +++ b/apps/server/src/collaboration/collaboration.module.ts @@ -7,9 +7,10 @@ import { CollabWsAdapter } from './adapter/collab-ws.adapter'; import { IncomingMessage } from 'http'; import { WebSocket } from 'ws'; import { TokenModule } from '../core/auth/token.module'; -import { HistoryListener } from './listeners/history.listener'; +import { HistoryProcessor } from './processors/history.processor'; import { LoggerExtension } from './extensions/logger.extension'; import { CollaborationHandler } from './collaboration.handler'; +import { CollabHistoryService } from './services/collab-history.service'; @Module({ providers: [ @@ -17,7 +18,8 @@ import { CollaborationHandler } from './collaboration.handler'; AuthenticationExtension, PersistenceExtension, LoggerExtension, - HistoryListener, + HistoryProcessor, + CollabHistoryService, CollaborationHandler, ], exports: [CollaborationGateway], diff --git a/apps/server/src/collaboration/constants.ts b/apps/server/src/collaboration/constants.ts new file mode 100644 index 00000000..8ce8c825 --- /dev/null +++ b/apps/server/src/collaboration/constants.ts @@ -0,0 +1,3 @@ +export const HISTORY_INTERVAL = 5 * 60 * 1000; +export const HISTORY_FAST_INTERVAL = 60 * 1000; +export const HISTORY_FAST_THRESHOLD = 5 * 60 * 1000; diff --git a/apps/server/src/collaboration/extensions/persistence.extension.ts b/apps/server/src/collaboration/extensions/persistence.extension.ts index 54c4a89e..4548b40c 100644 --- a/apps/server/src/collaboration/extensions/persistence.extension.ts +++ b/apps/server/src/collaboration/extensions/persistence.extension.ts @@ -13,7 +13,6 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { InjectKysely } from 'nestjs-kysely'; import { KyselyDB } from '@docmost/db/types/kysely.types'; import { executeTx } from '@docmost/db/utils'; -import { EventEmitter2 } from '@nestjs/event-emitter'; import { InjectQueue } from '@nestjs/bullmq'; import { QueueJob, QueueName } from '../../integrations/queue/constants'; import { Queue } from 'bullmq'; @@ -22,8 +21,17 @@ import { extractPageMentions, } from '../../common/helpers/prosemirror/utils'; import { isDeepStrictEqual } from 'node:util'; -import { IPageBacklinkJob } from '../../integrations/queue/constants/queue.interface'; +import { + IPageBacklinkJob, + IPageHistoryJob, +} from '../../integrations/queue/constants/queue.interface'; import { Page } from '@docmost/db/types/entity.types'; +import { CollabHistoryService } from '../services/collab-history.service'; +import { + HISTORY_FAST_INTERVAL, + HISTORY_FAST_THRESHOLD, + HISTORY_INTERVAL, +} from '../constants'; @Injectable() export class PersistenceExtension implements Extension { @@ -33,9 +41,10 @@ export class PersistenceExtension implements Extension { constructor( private readonly pageRepo: PageRepo, @InjectKysely() private readonly db: KyselyDB, - private eventEmitter: EventEmitter2, @InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue, @InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue, + @InjectQueue(QueueName.HISTORY_QUEUE) private historyQueue: Queue, + private readonly collabHistory: CollabHistoryService, ) {} async onLoadDocument(data: onLoadDocumentPayload) { @@ -101,6 +110,7 @@ export class PersistenceExtension implements Extension { } let page: Page = null; + const editingUserIds = this.consumeContributors(documentName); try { await executeTx(this.db, async (trx) => { @@ -123,13 +133,9 @@ export class PersistenceExtension implements Extension { let contributorIds = undefined; try { const existingContributors = page.contributorIds || []; - const contributorSet = this.contributors.get(documentName); - contributorSet.add(page.creatorId); - const newContributors = [...contributorSet]; contributorIds = Array.from( - new Set([...existingContributors, ...newContributors]), + new Set([...existingContributors, ...editingUserIds, page.creatorId]), ); - this.contributors.delete(documentName); } catch (err) { //this.logger.debug('Contributors error:' + err?.['message']); } @@ -153,13 +159,7 @@ export class PersistenceExtension implements Extension { } if (page) { - this.eventEmitter.emit('collab.page.updated', { - page: { - ...page, - content: tiptapJson, - lastUpdatedById: context.user.id, - }, - }); + await this.collabHistory.addContributors(pageId, editingUserIds); const mentions = extractMentions(tiptapJson); const pageMentions = extractPageMentions(mentions); @@ -174,6 +174,8 @@ export class PersistenceExtension implements Extension { pageIds: [pageId], workspaceId: page.workspaceId, }); + + await this.enqueuePageHistory(page); } } @@ -193,4 +195,26 @@ export class PersistenceExtension implements Extension { const documentName = data.documentName; this.contributors.delete(documentName); } + + private consumeContributors(documentName: string): string[] { + const contributorSet = this.contributors.get(documentName); + if (!contributorSet) return []; + const userIds = [...contributorSet]; + this.contributors.delete(documentName); + return userIds; + } + + private async enqueuePageHistory(page: Page): Promise { + const pageAge = Date.now() - new Date(page.createdAt).getTime(); + const delay = + pageAge < HISTORY_FAST_THRESHOLD + ? HISTORY_FAST_INTERVAL + : HISTORY_INTERVAL; + + await this.historyQueue.add( + QueueJob.PAGE_HISTORY, + { pageId: page.id } as IPageHistoryJob, + { jobId: page.id, delay }, + ); + } } diff --git a/apps/server/src/collaboration/listeners/history.listener.ts b/apps/server/src/collaboration/listeners/history.listener.ts deleted file mode 100644 index e0d40838..00000000 --- a/apps/server/src/collaboration/listeners/history.listener.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { OnEvent } from '@nestjs/event-emitter'; -import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo'; -import { Page } from '@docmost/db/types/entity.types'; -import { isDeepStrictEqual } from 'node:util'; -import { EnvironmentService } from '../../integrations/environment/environment.service'; - -export class UpdatedPageEvent { - page: Page; -} - -@Injectable() -export class HistoryListener { - private readonly logger = new Logger(HistoryListener.name); - - constructor( - private readonly pageHistoryRepo: PageHistoryRepo, - private readonly environmentService: EnvironmentService, - ) {} - - @OnEvent('collab.page.updated') - async handleCreatePageHistory(event: UpdatedPageEvent) { - const { page } = event; - - const pageCreationTime = new Date(page.createdAt).getTime(); - const currentTime = Date.now(); - const FIVE_MINUTES = this.environmentService.isDevelopment() - ? 60 * 1000 - : 5 * 60 * 1000; - - if (currentTime - pageCreationTime < FIVE_MINUTES) { - return; - } - - const lastHistory = await this.pageHistoryRepo.findPageLastHistory(page.id, { - includeContent: true, - }); - - if ( - !lastHistory || - (!isDeepStrictEqual(lastHistory.content, page.content) && - currentTime - new Date(lastHistory.createdAt).getTime() >= FIVE_MINUTES) - ) { - try { - await this.pageHistoryRepo.saveHistory(page); - this.logger.debug(`New history created for: ${page.id}`); - } catch (err) { - this.logger.error(`Failed to create history for page: ${page.id}`, err); - } - } - } -} diff --git a/apps/server/src/collaboration/processors/history.processor.ts b/apps/server/src/collaboration/processors/history.processor.ts new file mode 100644 index 00000000..985f0b3e --- /dev/null +++ b/apps/server/src/collaboration/processors/history.processor.ts @@ -0,0 +1,84 @@ +import { Logger, OnModuleDestroy } from '@nestjs/common'; +import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq'; +import { Job } from 'bullmq'; +import { QueueJob, QueueName } from '../../integrations/queue/constants'; +import { IPageHistoryJob } from '../../integrations/queue/constants/queue.interface'; +import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo'; +import { PageRepo } from '@docmost/db/repos/page/page.repo'; +import { isDeepStrictEqual } from 'node:util'; +import { CollabHistoryService } from '../services/collab-history.service'; + +@Processor(QueueName.HISTORY_QUEUE) +export class HistoryProcessor extends WorkerHost implements OnModuleDestroy { + private readonly logger = new Logger(HistoryProcessor.name); + + constructor( + private readonly pageHistoryRepo: PageHistoryRepo, + private readonly pageRepo: PageRepo, + private readonly collabHistory: CollabHistoryService, + ) { + super(); + } + + async process(job: Job): Promise { + if (job.name !== QueueJob.PAGE_HISTORY) return; + + try { + const { pageId } = job.data; + + const page = await this.pageRepo.findById(pageId, { + includeContent: true, + }); + + if (!page) { + this.logger.warn(`Page ${pageId} not found, skipping history`); + await this.collabHistory.clearContributors(pageId); + return; + } + + const lastHistory = await this.pageHistoryRepo.findPageLastHistory( + pageId, + { includeContent: true }, + ); + + if ( + !lastHistory || + !isDeepStrictEqual(lastHistory.content, page.content) + ) { + const contributorIds = + await this.collabHistory.popContributors(pageId); + + try { + await this.pageHistoryRepo.saveHistory(page, { contributorIds }); + this.logger.debug(`History created for page: ${pageId}`); + } catch (err) { + await this.collabHistory.addContributors( + pageId, + contributorIds, + ); + throw err; + } + } + } catch (err) { + throw err; + } + } + + @OnWorkerEvent('active') + onActive(job: Job) { + this.logger.debug(`Processing ${job.name} for page: ${job.data.pageId}`); + } + + @OnWorkerEvent('failed') + onError(job: Job) { + this.logger.error( + `Failed ${job.name} for page: ${job.data.pageId}. Reason: ${job.failedReason}`, + ); + } + + async onModuleDestroy(): Promise { + if (this.worker) { + await this.worker.close(); + } + } +} diff --git a/apps/server/src/collaboration/services/collab-history.service.ts b/apps/server/src/collaboration/services/collab-history.service.ts new file mode 100644 index 00000000..b7cf5086 --- /dev/null +++ b/apps/server/src/collaboration/services/collab-history.service.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; +import { RedisService } from '@nestjs-labs/nestjs-ioredis'; +import type { Redis } from 'ioredis'; + +const REDIS_KEY_PREFIX = 'history:contributors:'; + +@Injectable() +export class CollabHistoryService { + private readonly redis: Redis; + + constructor(private readonly redisService: RedisService) { + this.redis = this.redisService.getOrThrow(); + } + + async addContributors(pageId: string, userIds: string[]): Promise { + if (userIds.length === 0) return; + await this.redis.sadd(REDIS_KEY_PREFIX + pageId, ...userIds); + } + + async popContributors(pageId: string): Promise { + const key = REDIS_KEY_PREFIX + pageId; + const count = await this.redis.scard(key); + if (count === 0) return []; + return await this.redis.spop(key, count); + } + + async clearContributors(pageId: string): Promise { + await this.redis.del(REDIS_KEY_PREFIX + pageId); + } +} diff --git a/apps/server/src/database/migrations/20260209T120000-add-contributor-ids-to-page-history.ts b/apps/server/src/database/migrations/20260209T120000-add-contributor-ids-to-page-history.ts new file mode 100644 index 00000000..4e50ad3a --- /dev/null +++ b/apps/server/src/database/migrations/20260209T120000-add-contributor-ids-to-page-history.ts @@ -0,0 +1,15 @@ +import { type Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .alterTable('page_history') + .addColumn('contributor_ids', sql`uuid[]`, (col) => col.defaultTo('{}')) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema + .alterTable('page_history') + .dropColumn('contributor_ids') + .execute(); +} diff --git a/apps/server/src/database/repos/page/page-history.repo.ts b/apps/server/src/database/repos/page/page-history.repo.ts index c61c7e88..aca38f45 100644 --- a/apps/server/src/database/repos/page/page-history.repo.ts +++ b/apps/server/src/database/repos/page/page-history.repo.ts @@ -9,8 +9,8 @@ import { } from '@docmost/db/types/entity.types'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination'; -import { jsonObjectFrom } from 'kysely/helpers/postgres'; -import { ExpressionBuilder } from 'kysely'; +import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; +import { ExpressionBuilder, sql } from 'kysely'; import { DB } from '@docmost/db/types/db'; @Injectable() @@ -25,6 +25,7 @@ export class PageHistoryRepo { 'icon', 'coverPhoto', 'lastUpdatedById', + 'contributorIds', 'spaceId', 'workspaceId', 'createdAt', @@ -44,6 +45,7 @@ export class PageHistoryRepo { .select(this.baseFields) .$if(opts?.includeContent, (qb) => qb.select('content')) .select((eb) => this.withLastUpdatedBy(eb)) + .select((eb) => this.withContributors(eb)) .where('id', '=', pageHistoryId) .executeTakeFirst(); } @@ -60,7 +62,10 @@ export class PageHistoryRepo { .executeTakeFirst(); } - async saveHistory(page: Page, trx?: KyselyTransaction): Promise { + async saveHistory( + page: Page, + opts?: { contributorIds?: string[]; trx?: KyselyTransaction }, + ): Promise { await this.insertPageHistory( { pageId: page.id, @@ -70,10 +75,11 @@ export class PageHistoryRepo { icon: page.icon, coverPhoto: page.coverPhoto, lastUpdatedById: page.lastUpdatedById ?? page.creatorId, + contributorIds: opts?.contributorIds, spaceId: page.spaceId, workspaceId: page.workspaceId, }, - trx, + opts?.trx, ); } @@ -82,6 +88,7 @@ export class PageHistoryRepo { .selectFrom('pageHistory') .select(this.baseFields) .select((eb) => this.withLastUpdatedBy(eb)) + .select((eb) => this.withContributors(eb)) .where('pageId', '=', pageId); return executeWithCursorPagination(query, { @@ -120,4 +127,17 @@ export class PageHistoryRepo { .whereRef('users.id', '=', 'pageHistory.lastUpdatedById'), ).as('lastUpdatedBy'); } + + withContributors(eb: ExpressionBuilder) { + return jsonArrayFrom( + eb + .selectFrom('users') + .select(['users.id', 'users.name', 'users.avatarUrl']) + .whereRef( + 'users.id', + '=', + sql`ANY(${eb.ref('pageHistory.contributorIds')})`, + ), + ).as('contributors'); + } } diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index a4197f4e..60b328ee 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -199,6 +199,7 @@ export interface GroupUsers { export interface PageHistory { content: Json | null; + contributorIds: Generated; coverPhoto: string | null; createdAt: Generated; icon: string | null; diff --git a/apps/server/src/integrations/queue/constants/queue.constants.ts b/apps/server/src/integrations/queue/constants/queue.constants.ts index 5c7aa29a..c194a28c 100644 --- a/apps/server/src/integrations/queue/constants/queue.constants.ts +++ b/apps/server/src/integrations/queue/constants/queue.constants.ts @@ -6,6 +6,7 @@ export enum QueueName { FILE_TASK_QUEUE = '{file-task-queue}', SEARCH_QUEUE = '{search-queue}', AI_QUEUE = '{ai-queue}', + HISTORY_QUEUE = '{history-queue}', } export enum QueueJob { @@ -58,4 +59,6 @@ export enum QueueJob { GENERATE_PAGE_EMBEDDINGS = 'generate-page-embeddings', DELETE_PAGE_EMBEDDINGS = 'delete-page-embeddings', + + PAGE_HISTORY = 'page-history', } diff --git a/apps/server/src/integrations/queue/constants/queue.interface.ts b/apps/server/src/integrations/queue/constants/queue.interface.ts index ce105f1c..cfcd7148 100644 --- a/apps/server/src/integrations/queue/constants/queue.interface.ts +++ b/apps/server/src/integrations/queue/constants/queue.interface.ts @@ -9,4 +9,8 @@ export interface IPageBacklinkJob { export interface IStripeSeatsSyncJob { workspaceId: string; +} + +export interface IPageHistoryJob { + pageId: string; } \ No newline at end of file diff --git a/apps/server/src/integrations/queue/queue.module.ts b/apps/server/src/integrations/queue/queue.module.ts index 6787e010..54037bce 100644 --- a/apps/server/src/integrations/queue/queue.module.ts +++ b/apps/server/src/integrations/queue/queue.module.ts @@ -73,6 +73,14 @@ import { BacklinksProcessor } from './processors/backlinks.processor'; attempts: 1, }, }), + BullModule.registerQueue({ + name: QueueName.HISTORY_QUEUE, + defaultJobOptions: { + removeOnComplete: true, + removeOnFail: true, + attempts: 2, + }, + }), ], exports: [BullModule], providers: [BacklinksProcessor],