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 {
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<string, Set<string>> = 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);
}
}

View File

@ -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,
);

View File

@ -112,21 +112,32 @@ export class PageService {
}
async update(
pageId: string,
page: Page,
updatePageDto: UpdatePageDto,
userId: string,
): Promise<Page> {
const contributors = new Set<string>(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<DB, 'pages'>) {

View File

@ -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');

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 { 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<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) {
return this.db
.withRecursive('page_hierarchy', (db) =>

View File

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