diff --git a/apps/server/src/collaboration/extensions/persistence.extension.ts b/apps/server/src/collaboration/extensions/persistence.extension.ts index 49278172..a1f9f519 100644 --- a/apps/server/src/collaboration/extensions/persistence.extension.ts +++ b/apps/server/src/collaboration/extensions/persistence.extension.ts @@ -1,5 +1,7 @@ import { + afterUnloadDocumentPayload, Extension, + onChangePayload, onLoadDocumentPayload, onStoreDocumentPayload, } from '@hocuspocus/server'; @@ -26,6 +28,7 @@ import { Page } from '@docmost/db/types/entity.types'; @Injectable() export class PersistenceExtension implements Extension { private readonly logger = new Logger(PersistenceExtension.name); + private contributors: Map> = new Map(); constructor( private readonly pageRepo: PageRepo, @@ -116,12 +119,27 @@ export class PersistenceExtension implements Extension { return; } + 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]), + ); + this.contributors.delete(documentName); + } catch (err) { + this.logger.log('Contributors error:' + err?.['message']); + } + await this.pageRepo.updatePage( { content: tiptapJson, textContent: textContent, ydoc: ydocState, lastUpdatedById: context.user.id, + contributorIds: contributorIds, }, pageId, trx, @@ -152,4 +170,21 @@ export class PersistenceExtension implements Extension { } as IPageBacklinkJob); } } + + async onChange(data: onChangePayload) { + const documentName = data.documentName; + const userId = data.context?.user.id; + if (!userId) return; + + if (!this.contributors.has(documentName)) { + this.contributors.set(documentName, new Set()); + } + + this.contributors.get(documentName).add(userId); + } + + async afterUnloadDocument(data: afterUnloadDocumentPayload) { + const documentName = data.documentName; + this.contributors.delete(documentName); + } } diff --git a/apps/server/src/core/page/page.controller.ts b/apps/server/src/core/page/page.controller.ts index 21dfcaf2..ec2a086d 100644 --- a/apps/server/src/core/page/page.controller.ts +++ b/apps/server/src/core/page/page.controller.ts @@ -46,6 +46,7 @@ export class PageController { includeContent: true, includeCreator: true, includeLastUpdatedBy: true, + includeContributors: true, }); if (!page) { @@ -93,7 +94,7 @@ export class PageController { } return this.pageService.update( - updatePageDto.pageId, + page, updatePageDto, user.id, ); diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts index 97f9377f..7ca54197 100644 --- a/apps/server/src/core/page/services/page.service.ts +++ b/apps/server/src/core/page/services/page.service.ts @@ -112,21 +112,32 @@ export class PageService { } async update( - pageId: string, + page: Page, updatePageDto: UpdatePageDto, userId: string, ): Promise { + const contributors = new Set(page.contributorIds); + contributors.add(userId); + const contributorIds = Array.from(contributors); + await this.pageRepo.updatePage( { title: updatePageDto.title, icon: updatePageDto.icon, lastUpdatedById: userId, updatedAt: new Date(), + contributorIds: contributorIds, }, - pageId, + page.id, ); - return await this.pageRepo.findById(pageId); + return await this.pageRepo.findById(page.id, { + includeSpace: true, + includeContent: true, + includeCreator: true, + includeLastUpdatedBy: true, + includeContributors: true, + }); } withHasChildren(eb: ExpressionBuilder) { diff --git a/apps/server/src/database/database.module.ts b/apps/server/src/database/database.module.ts index d1f8366e..930bb59b 100644 --- a/apps/server/src/database/database.module.ts +++ b/apps/server/src/database/database.module.ts @@ -47,7 +47,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val)); log: (event: LogEvent) => { if (environmentService.getNodeEnv() !== 'development') return; const logger = new Logger(DatabaseModule.name); - if (event.level === 'query') { + if (event.level) { if (process.env.DEBUG_DB?.toLowerCase() === 'true') { logger.debug(event.query.sql); logger.debug('query time: ' + event.queryDurationMillis + ' ms'); diff --git a/apps/server/src/database/migrations/20250327T145832-add-contributorIds-to-pages.ts b/apps/server/src/database/migrations/20250327T145832-add-contributorIds-to-pages.ts new file mode 100644 index 00000000..ecad09d8 --- /dev/null +++ b/apps/server/src/database/migrations/20250327T145832-add-contributorIds-to-pages.ts @@ -0,0 +1,12 @@ +import { type Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .alterTable('pages') + .addColumn('contributor_ids', sql`uuid[]`, (col) => col.defaultTo("{}")) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable('pages').dropColumn('contributor_ids').execute(); +} diff --git a/apps/server/src/database/repos/page/page.repo.ts b/apps/server/src/database/repos/page/page.repo.ts index 0e01c657..317c3e07 100644 --- a/apps/server/src/database/repos/page/page.repo.ts +++ b/apps/server/src/database/repos/page/page.repo.ts @@ -10,9 +10,9 @@ import { import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { executeWithPagination } from '@docmost/db/pagination/pagination'; import { validate as isValidUUID } from 'uuid'; -import { ExpressionBuilder } from 'kysely'; +import { ExpressionBuilder, sql } from 'kysely'; import { DB } from '@docmost/db/types/db'; -import { jsonObjectFrom } from 'kysely/helpers/postgres'; +import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; @Injectable() @@ -38,6 +38,7 @@ export class PageRepo { 'createdAt', 'updatedAt', 'deletedAt', + 'contributorIds', ]; async findById( @@ -48,6 +49,7 @@ export class PageRepo { includeSpace?: boolean; includeCreator?: boolean; includeLastUpdatedBy?: boolean; + includeContributors?: boolean; withLock?: boolean; trx?: KyselyTransaction; }, @@ -68,6 +70,10 @@ export class PageRepo { query = query.select((eb) => this.withLastUpdatedBy(eb)); } + if (opts?.includeContributors) { + query = query.select((eb) => this.withContributors(eb)); + } + if (opts?.includeSpace) { query = query.select((eb) => this.withSpace(eb)); } @@ -189,6 +195,15 @@ export class PageRepo { ).as('lastUpdatedBy'); } + withContributors(eb: ExpressionBuilder) { + return jsonArrayFrom( + eb + .selectFrom('users') + .select(['users.id', 'users.name', 'users.avatarUrl']) + .whereRef('users.id', '=', sql`ANY(${eb.ref('pages.contributorIds')})`), + ).as('contributors'); + } + async getPageAndDescendants(parentPageId: string) { return this.db .withRecursive('page_hierarchy', (db) => diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index f9022ecd..eae94943 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -161,6 +161,7 @@ export interface PageHistory { export interface Pages { content: Json | null; + contributorIds: Generated; coverPhoto: string | null; createdAt: Generated; creatorId: string | null;