feat: keep track of page contributors (#959)

* WIP

* feat: store and retrieve page contributors
This commit is contained in:
Philip Okugbe
2025-04-04 13:03:57 +01:00
committed by GitHub
parent 8aa604637e
commit 64f0531093
7 changed files with 82 additions and 7 deletions

View File

@ -1,5 +1,7 @@
import { import {
afterUnloadDocumentPayload,
Extension, Extension,
onChangePayload,
onLoadDocumentPayload, onLoadDocumentPayload,
onStoreDocumentPayload, onStoreDocumentPayload,
} from '@hocuspocus/server'; } from '@hocuspocus/server';
@ -26,6 +28,7 @@ import { Page } from '@docmost/db/types/entity.types';
@Injectable() @Injectable()
export class PersistenceExtension implements Extension { export class PersistenceExtension implements Extension {
private readonly logger = new Logger(PersistenceExtension.name); private readonly logger = new Logger(PersistenceExtension.name);
private contributors: Map<string, Set<string>> = new Map();
constructor( constructor(
private readonly pageRepo: PageRepo, private readonly pageRepo: PageRepo,
@ -116,12 +119,27 @@ export class PersistenceExtension implements Extension {
return; 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( await this.pageRepo.updatePage(
{ {
content: tiptapJson, content: tiptapJson,
textContent: textContent, textContent: textContent,
ydoc: ydocState, ydoc: ydocState,
lastUpdatedById: context.user.id, lastUpdatedById: context.user.id,
contributorIds: contributorIds,
}, },
pageId, pageId,
trx, trx,
@ -152,4 +170,21 @@ export class PersistenceExtension implements Extension {
} as IPageBacklinkJob); } 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);
}
} }

View File

@ -46,6 +46,7 @@ export class PageController {
includeContent: true, includeContent: true,
includeCreator: true, includeCreator: true,
includeLastUpdatedBy: true, includeLastUpdatedBy: true,
includeContributors: true,
}); });
if (!page) { if (!page) {
@ -93,7 +94,7 @@ export class PageController {
} }
return this.pageService.update( return this.pageService.update(
updatePageDto.pageId, page,
updatePageDto, updatePageDto,
user.id, user.id,
); );

View File

@ -112,21 +112,32 @@ export class PageService {
} }
async update( async update(
pageId: string, page: Page,
updatePageDto: UpdatePageDto, updatePageDto: UpdatePageDto,
userId: string, userId: string,
): Promise<Page> { ): Promise<Page> {
const contributors = new Set<string>(page.contributorIds);
contributors.add(userId);
const contributorIds = Array.from(contributors);
await this.pageRepo.updatePage( await this.pageRepo.updatePage(
{ {
title: updatePageDto.title, title: updatePageDto.title,
icon: updatePageDto.icon, icon: updatePageDto.icon,
lastUpdatedById: userId, lastUpdatedById: userId,
updatedAt: new Date(), 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<DB, 'pages'>) { withHasChildren(eb: ExpressionBuilder<DB, 'pages'>) {

View File

@ -47,7 +47,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
log: (event: LogEvent) => { log: (event: LogEvent) => {
if (environmentService.getNodeEnv() !== 'development') return; if (environmentService.getNodeEnv() !== 'development') return;
const logger = new Logger(DatabaseModule.name); const logger = new Logger(DatabaseModule.name);
if (event.level === 'query') { if (event.level) {
if (process.env.DEBUG_DB?.toLowerCase() === 'true') { if (process.env.DEBUG_DB?.toLowerCase() === 'true') {
logger.debug(event.query.sql); logger.debug(event.query.sql);
logger.debug('query time: ' + event.queryDurationMillis + ' ms'); logger.debug('query time: ' + event.queryDurationMillis + ' ms');

View File

@ -0,0 +1,12 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('pages')
.addColumn('contributor_ids', sql`uuid[]`, (col) => col.defaultTo("{}"))
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.alterTable('pages').dropColumn('contributor_ids').execute();
}

View File

@ -10,9 +10,9 @@ import {
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination'; import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { validate as isValidUUID } from 'uuid'; import { validate as isValidUUID } from 'uuid';
import { ExpressionBuilder } from 'kysely'; import { ExpressionBuilder, sql } from 'kysely';
import { DB } from '@docmost/db/types/db'; 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'; import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
@Injectable() @Injectable()
@ -38,6 +38,7 @@ export class PageRepo {
'createdAt', 'createdAt',
'updatedAt', 'updatedAt',
'deletedAt', 'deletedAt',
'contributorIds',
]; ];
async findById( async findById(
@ -48,6 +49,7 @@ export class PageRepo {
includeSpace?: boolean; includeSpace?: boolean;
includeCreator?: boolean; includeCreator?: boolean;
includeLastUpdatedBy?: boolean; includeLastUpdatedBy?: boolean;
includeContributors?: boolean;
withLock?: boolean; withLock?: boolean;
trx?: KyselyTransaction; trx?: KyselyTransaction;
}, },
@ -68,6 +70,10 @@ export class PageRepo {
query = query.select((eb) => this.withLastUpdatedBy(eb)); query = query.select((eb) => this.withLastUpdatedBy(eb));
} }
if (opts?.includeContributors) {
query = query.select((eb) => this.withContributors(eb));
}
if (opts?.includeSpace) { if (opts?.includeSpace) {
query = query.select((eb) => this.withSpace(eb)); query = query.select((eb) => this.withSpace(eb));
} }
@ -189,6 +195,15 @@ export class PageRepo {
).as('lastUpdatedBy'); ).as('lastUpdatedBy');
} }
withContributors(eb: ExpressionBuilder<DB, 'pages'>) {
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) { async getPageAndDescendants(parentPageId: string) {
return this.db return this.db
.withRecursive('page_hierarchy', (db) => .withRecursive('page_hierarchy', (db) =>

View File

@ -161,6 +161,7 @@ export interface PageHistory {
export interface Pages { export interface Pages {
content: Json | null; content: Json | null;
contributorIds: Generated<string[] | null>;
coverPhoto: string | null; coverPhoto: string | null;
createdAt: Generated<Timestamp>; createdAt: Generated<Timestamp>;
creatorId: string | null; creatorId: string | null;