feat: page history

This commit is contained in:
Philipinho
2023-11-22 20:42:34 +00:00
parent 21347e6c42
commit 3f9b6e1380
50 changed files with 995 additions and 200 deletions

View File

@ -29,8 +29,8 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.431.0",
"@aws-sdk/s3-request-presigner": "^3.431.0",
"@hocuspocus/server": "^2.7.1",
"@hocuspocus/transformer": "^2.7.1",
"@hocuspocus/server": "^2.8.1",
"@hocuspocus/transformer": "^2.8.1",
"@nestjs/common": "^10.2.7",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.2.7",
@ -59,6 +59,7 @@
"@nestjs/schematics": "^10.0.2",
"@nestjs/testing": "^10.2.7",
"@types/bcrypt": "^5.0.0",
"@types/debounce": "^1.2.4",
"@types/fs-extra": "^11.0.2",
"@types/jest": "^29.5.5",
"@types/mime-types": "^2.1.2",

View File

@ -4,18 +4,24 @@ import WebSocket from 'ws';
import { AuthenticationExtension } from './extensions/authentication.extension';
import { PersistenceExtension } from './extensions/persistence.extension';
import { Injectable } from '@nestjs/common';
import { HistoryExtension } from './extensions/history.extension';
@Injectable()
export class CollaborationGateway {
constructor(
private authenticationExtension: AuthenticationExtension,
private persistenceExtension: PersistenceExtension,
private historyExtension: HistoryExtension,
) {}
private hocuspocus = HocuspocusServer.configure({
debounce: 5000,
maxDebounce: 10000,
extensions: [this.authenticationExtension, this.persistenceExtension],
extensions: [
this.authenticationExtension,
this.persistenceExtension,
this.historyExtension,
],
});
handleConnection(client: WebSocket, request: IncomingMessage): any {

View File

@ -9,12 +9,14 @@ import { HttpAdapterHost } from '@nestjs/core';
import { CollabWsAdapter } from './adapter/collab-ws.adapter';
import { IncomingMessage } from 'http';
import { WebSocket } from 'ws';
import { HistoryExtension } from './extensions/history.extension';
@Module({
providers: [
CollaborationGateway,
AuthenticationExtension,
PersistenceExtension,
HistoryExtension,
],
imports: [UserModule, AuthModule, PageModule],
})

View File

@ -0,0 +1,64 @@
import {
Extension,
onChangePayload,
onDisconnectPayload,
} from '@hocuspocus/server';
import { Injectable } from '@nestjs/common';
import { PageService } from '../../core/page/services/page.service';
import { PageHistoryService } from '../../core/page/services/page-history.service';
@Injectable()
export class HistoryExtension implements Extension {
ACTIVE_EDITING_INTERVAL = 10 * 60 * 1000; // 10 minutes
historyIntervalMap = new Map<string, NodeJS.Timeout>();
lastEditTimeMap = new Map<string, number>();
constructor(
private readonly pageService: PageService,
private readonly pageHistoryService: PageHistoryService,
) {}
async onChange(data: onChangePayload): Promise<void> {
const pageId = data.documentName;
this.lastEditTimeMap.set(pageId, Date.now());
if (!this.historyIntervalMap.has(pageId)) {
const historyInterval = setInterval(() => {
if (this.isActiveEditing(pageId)) {
this.recordHistory(pageId);
}
}, this.ACTIVE_EDITING_INTERVAL);
this.historyIntervalMap.set(pageId, historyInterval);
}
}
async onDisconnect(data: onDisconnectPayload): Promise<void> {
const pageId = data.documentName;
if (data.clientsCount === 0) {
if (this.historyIntervalMap.has(pageId)) {
clearInterval(this.historyIntervalMap.get(pageId));
this.historyIntervalMap.delete(pageId);
this.lastEditTimeMap.delete(pageId);
}
}
}
isActiveEditing(pageId: string): boolean {
const lastEditTime = this.lastEditTimeMap.get(pageId);
if (!lastEditTime) {
return false;
}
return Date.now() - lastEditTime < this.ACTIVE_EDITING_INTERVAL;
}
async recordHistory(pageId: string) {
try {
const page = await this.pageService.findWithContent(pageId);
// Todo: compare if data is the same as the previous version
await this.pageHistoryService.saveHistory(page);
console.log(`New history created for: ${pageId}`);
} catch (err) {
console.error('An error occurred saving page history', err);
}
}
}

View File

@ -14,12 +14,13 @@ export class PersistenceExtension implements Extension {
async onLoadDocument(data: onLoadDocumentPayload) {
const { documentName, document } = data;
const pageId = documentName;
if (!document.isEmpty('default')) {
return;
}
const page = await this.pageService.findById(documentName);
const page = await this.pageService.findWithAllFields(pageId);
if (!page) {
console.log('page does not exist.');

View File

@ -9,10 +9,6 @@ export class CreatePageDto {
@IsString()
title?: string;
@IsOptional()
@IsString()
content?: string;
@IsOptional()
@IsString()
parentPageId?: string;

View File

@ -0,0 +1,6 @@
import { IsUUID } from 'class-validator';
export class HistoryDetailsDto {
@IsUUID()
id: string;
}

View File

@ -0,0 +1,6 @@
import { IsUUID } from 'class-validator';
export class PageHistoryDto {
@IsUUID()
pageId: string;
}

View File

@ -0,0 +1,63 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Workspace } from '../../workspace/entities/workspace.entity';
import { Page } from './page.entity';
import { User } from '../../user/entities/user.entity';
@Entity('page_history')
export class PageHistory {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
pageId: string;
@ManyToOne(() => Page, (page) => page.pageHistory, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'pageId' })
page: Page;
@Column({ length: 500, nullable: true })
title: string;
@Column({ type: 'jsonb', nullable: true })
content: string;
@Column({ nullable: true })
slug: string;
@Column({ nullable: true })
icon: string;
@Column({ nullable: true })
coverPhoto: string;
@Column({ type: 'int' })
version: number;
@Column({ type: 'uuid' })
lastUpdatedById: string;
@ManyToOne(() => User)
@JoinColumn({ name: 'lastUpdatedById' })
lastUpdatedBy: User;
@Column()
workspaceId: string;
@ManyToOne(() => Workspace, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'workspaceId' })
workspace: Workspace;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@ -12,6 +12,7 @@ import {
import { User } from '../../user/entities/user.entity';
import { Workspace } from '../../workspace/entities/workspace.entity';
import { Comment } from '../../comment/entities/comment.entity';
import { PageHistory } from './page-history.entity';
@Entity('pages')
export class Page {
@ -101,6 +102,9 @@ export class Page {
@OneToMany(() => Page, (page) => page.parentPage, { onDelete: 'CASCADE' })
childPages: Page[];
@OneToMany(() => PageHistory, (pageHistory) => pageHistory.page)
pageHistory: PageHistory[];
@OneToMany(() => Comment, (comment) => comment.page)
comments: Comment[];
}

View File

@ -17,6 +17,9 @@ import { MovePageDto } from './dto/move-page.dto';
import { PageDetailsDto } from './dto/page-details.dto';
import { DeletePageDto } from './dto/delete-page.dto';
import { PageOrderingService } from './services/page-ordering.service';
import { PageHistoryService } from './services/page-history.service';
import { HistoryDetailsDto } from './dto/history-details.dto';
import { PageHistoryDto } from './dto/page-history.dto';
@UseGuards(JwtGuard)
@Controller('pages')
@ -24,13 +27,14 @@ export class PageController {
constructor(
private readonly pageService: PageService,
private readonly pageOrderService: PageOrderingService,
private readonly pageHistoryService: PageHistoryService,
private readonly workspaceService: WorkspaceService,
) {}
@HttpCode(HttpStatus.OK)
@Post('/details')
async getPage(@Body() input: PageDetailsDto) {
return this.pageService.findWithoutYDoc(input.id);
return this.pageService.findOne(input.id);
}
@HttpCode(HttpStatus.CREATED)
@ -118,4 +122,16 @@ export class PageController {
return this.pageOrderService.convertToTree(workspaceId);
}
@HttpCode(HttpStatus.OK)
@Post('/history')
async getPageHistory(@Body() dto: PageHistoryDto) {
return this.pageHistoryService.findHistoryByPageId(dto.pageId);
}
@HttpCode(HttpStatus.OK)
@Post('/history/details')
async get(@Body() dto: HistoryDetailsDto) {
return this.pageHistoryService.findOne(dto.id);
}
}

View File

@ -8,15 +8,24 @@ import { AuthModule } from '../auth/auth.module';
import { WorkspaceModule } from '../workspace/workspace.module';
import { PageOrderingService } from './services/page-ordering.service';
import { PageOrdering } from './entities/page-ordering.entity';
import { PageHistoryService } from './services/page-history.service';
import { PageHistory } from './entities/page-history.entity';
import { PageHistoryRepository } from './repositories/page-history.repository';
@Module({
imports: [
TypeOrmModule.forFeature([Page, PageOrdering]),
TypeOrmModule.forFeature([Page, PageOrdering, PageHistory]),
AuthModule,
WorkspaceModule,
],
controllers: [PageController],
providers: [PageService, PageOrderingService, PageRepository],
exports: [PageService, PageOrderingService, PageRepository],
providers: [
PageService,
PageOrderingService,
PageHistoryService,
PageRepository,
PageHistoryRepository,
],
exports: [PageService, PageOrderingService, PageHistoryService],
})
export class PageModule {}

View File

@ -0,0 +1,26 @@
import { DataSource, Repository } from 'typeorm';
import { Injectable } from '@nestjs/common';
import { PageHistory } from '../entities/page-history.entity';
@Injectable()
export class PageHistoryRepository extends Repository<PageHistory> {
constructor(private dataSource: DataSource) {
super(PageHistory, dataSource.createEntityManager());
}
async findById(pageId: string) {
return this.findOne({
where: {
id: pageId,
},
relations: ['lastUpdatedBy'],
select: {
lastUpdatedBy: {
id: true,
name: true,
avatarUrl: true,
},
},
});
}
}

View File

@ -8,33 +8,49 @@ export class PageRepository extends Repository<Page> {
super(Page, dataSource.createEntityManager());
}
async findById(pageId: string) {
return this.findOneBy({ id: pageId });
}
public baseFields = [
'page.id',
'page.title',
'page.slug',
'page.icon',
'page.coverPhoto',
'page.shareId',
'page.parentPageId',
'page.creatorId',
'page.lastUpdatedById',
'page.workspaceId',
'page.isLocked',
'page.status',
'page.publishedAt',
'page.createdAt',
'page.updatedAt',
'page.deletedAt',
];
async findWithoutYDoc(pageId: string) {
private async baseFind(pageId: string, selectFields: string[]) {
return this.dataSource
.createQueryBuilder(Page, 'page')
.where('page.id = :id', { id: pageId })
.select([
'page.id',
'page.title',
'page.slug',
'page.icon',
'page.coverPhoto',
'page.editor',
'page.shareId',
'page.parentPageId',
'page.creatorId',
'page.lastUpdatedById',
'page.workspaceId',
'page.isLocked',
'page.status',
'page.publishedAt',
'page.createdAt',
'page.updatedAt',
'page.deletedAt',
])
.select(selectFields)
.getOne();
}
async findById(pageId: string) {
return this.baseFind(pageId, this.baseFields);
}
async findWithYDoc(pageId: string) {
const extendedFields = [...this.baseFields, 'page.ydoc'];
return this.baseFind(pageId, extendedFields);
}
async findWithContent(pageId: string) {
const extendedFields = [...this.baseFields, 'page.content'];
return this.baseFind(pageId, extendedFields);
}
async findWithAllFields(pageId: string) {
const extendedFields = [...this.baseFields, 'page.content', 'page.ydoc'];
return this.baseFind(pageId, extendedFields);
}
}

View File

@ -0,0 +1,61 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { PageHistory } from '../entities/page-history.entity';
import { Page } from '../entities/page.entity';
import { PageHistoryRepository } from '../repositories/page-history.repository';
@Injectable()
export class PageHistoryService {
constructor(private pageHistoryRepo: PageHistoryRepository) {
}
async findOne(historyId: string): Promise<PageHistory> {
const history = await this.pageHistoryRepo.findById(historyId);
if (!history) {
throw new BadRequestException('History not found');
}
return history;
}
async saveHistory(page: Page): Promise<void> {
const pageHistory = new PageHistory();
pageHistory.pageId = page.id;
pageHistory.title = page.title;
pageHistory.content = page.content;
pageHistory.slug = page.slug;
pageHistory.icon = page.icon;
pageHistory.version = 1; // TODO: make incremental
pageHistory.coverPhoto = page.coverPhoto;
pageHistory.lastUpdatedById = page.lastUpdatedById ?? page.creatorId;
pageHistory.workspaceId = page.workspaceId;
await this.pageHistoryRepo.save(pageHistory);
}
async findHistoryByPageId(pageId: string, limit = 50, offset = 0) {
const history = await this.pageHistoryRepo
.createQueryBuilder('history')
.where('history.pageId = :pageId', { pageId })
.leftJoinAndSelect('history.lastUpdatedBy', 'user')
.select([
'history.id',
'history.pageId',
'history.title',
'history.slug',
'history.icon',
'history.coverPhoto',
'history.version',
'history.lastUpdatedById',
'history.workspaceId',
'history.createdAt',
'history.updatedAt',
'user.id',
'user.name',
'user.avatarUrl',
])
.orderBy('history.updatedAt', 'DESC')
.offset(offset)
.take(limit)
.getMany();
return history;
}
}

View File

@ -35,8 +35,25 @@ export class PageService {
return this.pageRepository.findById(pageId);
}
async findWithoutYDoc(pageId: string) {
return this.pageRepository.findWithoutYDoc(pageId);
async findWithContent(pageId: string) {
return this.pageRepository.findWithContent(pageId);
}
async findWithYdoc(pageId: string) {
return this.pageRepository.findWithYDoc(pageId);
}
async findWithAllFields(pageId: string) {
return this.pageRepository.findWithAllFields(pageId);
}
async findOne(pageId: string): Promise<Page> {
const page = await this.findById(pageId);
if (!page) {
throw new BadRequestException('Page not found');
}
return page;
}
async create(
@ -85,7 +102,7 @@ export class PageService {
throw new BadRequestException(`Page not found`);
}
return await this.pageRepository.findWithoutYDoc(pageId);
return await this.pageRepository.findById(pageId);
}
async updateState(
@ -240,25 +257,7 @@ export class PageService {
const pages = await this.pageRepository
.createQueryBuilder('page')
.where('page.workspaceId = :workspaceId', { workspaceId })
.select([
'page.id',
'page.title',
'page.slug',
'page.icon',
'page.coverPhoto',
'page.editor',
'page.shareId',
'page.parentPageId',
'page.creatorId',
'page.lastUpdatedById',
'page.workspaceId',
'page.isLocked',
'page.status',
'page.publishedAt',
'page.createdAt',
'page.updatedAt',
'page.deletedAt',
])
.select(this.pageRepository.baseFields)
.orderBy('page.updatedAt', 'DESC')
.offset(offset)
.take(limit)