mirror of
https://github.com/docmost/docmost.git
synced 2026-05-14 20:54:07 +08:00
feat: page details section and backlinks (#2186)
* feat: page details section and backlinks
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
import { IsIn, IsNotEmpty, IsString } from 'class-validator';
|
||||
import { PageIdDto } from './page.dto';
|
||||
|
||||
export type BacklinkDirection = 'incoming' | 'outgoing';
|
||||
|
||||
export class BacklinksListDto extends PageIdDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsIn(['incoming', 'outgoing'])
|
||||
direction: BacklinkDirection;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { PageService } from './services/page.service';
|
||||
import { BacklinkService } from './services/backlink.service';
|
||||
import { PageAccessService } from './page-access/page-access.service';
|
||||
import { CreatePageDto } from './dto/create-page.dto';
|
||||
import { UpdatePageDto } from './dto/update-page.dto';
|
||||
@@ -38,6 +39,7 @@ import { RecentPageDto } from './dto/recent-page.dto';
|
||||
import { CreatedByUserDto } from './dto/created-by-user.dto';
|
||||
import { DuplicatePageDto } from './dto/duplicate-page.dto';
|
||||
import { DeletedPageDto } from './dto/deleted-page.dto';
|
||||
import { BacklinksListDto } from './dto/backlink.dto';
|
||||
import {
|
||||
jsonToHtml,
|
||||
jsonToMarkdown,
|
||||
@@ -58,6 +60,7 @@ export class PageController {
|
||||
private readonly pageHistoryService: PageHistoryService,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
private readonly pageAccessService: PageAccessService,
|
||||
private readonly backlinkService: BacklinkService,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
@@ -96,6 +99,42 @@ export class PageController {
|
||||
return { ...page, permissions };
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('backlinks-count')
|
||||
async getBacklinksCount(
|
||||
@Body() dto: PageIdDto,
|
||||
@AuthUser() user: User,
|
||||
): Promise<{ incoming: number; outgoing: number }> {
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
if (!page) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
await this.pageAccessService.validateCanView(page, user);
|
||||
|
||||
return this.backlinkService.countByPageId(page.id, user.id);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('backlinks')
|
||||
async getBacklinks(
|
||||
@Body() dto: BacklinksListDto,
|
||||
@Body() pagination: PaginationOptions,
|
||||
@AuthUser() user: User,
|
||||
) {
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
if (!page) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
await this.pageAccessService.validateCanView(page, user);
|
||||
|
||||
return this.backlinkService.findByPageId(
|
||||
page.id,
|
||||
dto.direction,
|
||||
user.id,
|
||||
pagination,
|
||||
);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('create')
|
||||
async create(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PageService } from './services/page.service';
|
||||
import { PageController } from './page.controller';
|
||||
import { PageHistoryService } from './services/page-history.service';
|
||||
import { TrashCleanupService } from './services/trash-cleanup.service';
|
||||
import { BacklinkService } from './services/backlink.service';
|
||||
import { StorageModule } from '../../integrations/storage/storage.module';
|
||||
import { CollaborationModule } from '../../collaboration/collaboration.module';
|
||||
import { WatcherModule } from '../watcher/watcher.module';
|
||||
@@ -10,8 +11,18 @@ import { TransclusionModule } from './transclusion/transclusion.module';
|
||||
|
||||
@Module({
|
||||
controllers: [PageController],
|
||||
providers: [PageService, PageHistoryService, TrashCleanupService],
|
||||
providers: [
|
||||
PageService,
|
||||
PageHistoryService,
|
||||
TrashCleanupService,
|
||||
BacklinkService,
|
||||
],
|
||||
exports: [PageService, PageHistoryService],
|
||||
imports: [StorageModule, CollaborationModule, WatcherModule, TransclusionModule],
|
||||
imports: [
|
||||
StorageModule,
|
||||
CollaborationModule,
|
||||
WatcherModule,
|
||||
TransclusionModule,
|
||||
],
|
||||
})
|
||||
export class PageModule {}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { BacklinkService } from './backlink.service';
|
||||
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
|
||||
describe('BacklinkService.countByPageId', () => {
|
||||
let service: BacklinkService;
|
||||
let backlinkRepo: jest.Mocked<BacklinkRepo>;
|
||||
let permissionRepo: jest.Mocked<PagePermissionRepo>;
|
||||
|
||||
const pageId = '00000000-0000-0000-0000-000000000001';
|
||||
const userId = '00000000-0000-0000-0000-000000000099';
|
||||
|
||||
beforeEach(async () => {
|
||||
const backlinkRepoMock: jest.Mocked<Partial<BacklinkRepo>> = {
|
||||
findRelatedPageIds: jest.fn(),
|
||||
findPagesByIdsPaginated: jest.fn(),
|
||||
};
|
||||
const permissionRepoMock: jest.Mocked<Partial<PagePermissionRepo>> = {
|
||||
filterAccessiblePageIds: jest.fn(),
|
||||
};
|
||||
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
BacklinkService,
|
||||
{ provide: BacklinkRepo, useValue: backlinkRepoMock },
|
||||
{ provide: PagePermissionRepo, useValue: permissionRepoMock },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get(BacklinkService);
|
||||
backlinkRepo = module.get(BacklinkRepo) as jest.Mocked<BacklinkRepo>;
|
||||
permissionRepo = module.get(
|
||||
PagePermissionRepo,
|
||||
) as jest.Mocked<PagePermissionRepo>;
|
||||
});
|
||||
|
||||
it('returns post-filter counts for both directions', async () => {
|
||||
backlinkRepo.findRelatedPageIds.mockImplementation(async (_id, dir) =>
|
||||
dir === 'incoming' ? ['a', 'b', 'c'] : ['x', 'y'],
|
||||
);
|
||||
permissionRepo.filterAccessiblePageIds.mockImplementation(
|
||||
async ({ pageIds }) =>
|
||||
pageIds.filter((id) => id !== 'b' && id !== 'y'),
|
||||
);
|
||||
|
||||
const result = await service.countByPageId(pageId, userId);
|
||||
|
||||
expect(result).toEqual({ incoming: 2, outgoing: 1 });
|
||||
expect(permissionRepo.filterAccessiblePageIds).toHaveBeenCalledWith({
|
||||
pageIds: ['a', 'b', 'c'],
|
||||
userId,
|
||||
});
|
||||
expect(permissionRepo.filterAccessiblePageIds).toHaveBeenCalledWith({
|
||||
pageIds: ['x', 'y'],
|
||||
userId,
|
||||
});
|
||||
});
|
||||
|
||||
it('skips the permission filter when there are no candidates', async () => {
|
||||
backlinkRepo.findRelatedPageIds.mockResolvedValue([]);
|
||||
permissionRepo.filterAccessiblePageIds.mockResolvedValue([]);
|
||||
|
||||
const result = await service.countByPageId(pageId, userId);
|
||||
|
||||
expect(result).toEqual({ incoming: 0, outgoing: 0 });
|
||||
expect(permissionRepo.filterAccessiblePageIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes the userId to findRelatedPageIds so the repo can apply space membership filtering', async () => {
|
||||
backlinkRepo.findRelatedPageIds.mockResolvedValue([]);
|
||||
|
||||
await service.countByPageId(pageId, userId);
|
||||
|
||||
expect(backlinkRepo.findRelatedPageIds).toHaveBeenCalledWith(
|
||||
pageId,
|
||||
'incoming',
|
||||
userId,
|
||||
);
|
||||
expect(backlinkRepo.findRelatedPageIds).toHaveBeenCalledWith(
|
||||
pageId,
|
||||
'outgoing',
|
||||
userId,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BacklinkService.findByPageId', () => {
|
||||
let service: BacklinkService;
|
||||
let backlinkRepo: jest.Mocked<BacklinkRepo>;
|
||||
let permissionRepo: jest.Mocked<PagePermissionRepo>;
|
||||
|
||||
const pageId = '00000000-0000-0000-0000-000000000001';
|
||||
const userId = '00000000-0000-0000-0000-000000000099';
|
||||
|
||||
beforeEach(async () => {
|
||||
const backlinkRepoMock: jest.Mocked<Partial<BacklinkRepo>> = {
|
||||
findRelatedPageIds: jest.fn(),
|
||||
findPagesByIdsPaginated: jest.fn(),
|
||||
};
|
||||
const permissionRepoMock: jest.Mocked<Partial<PagePermissionRepo>> = {
|
||||
filterAccessiblePageIds: jest.fn(),
|
||||
};
|
||||
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
BacklinkService,
|
||||
{ provide: BacklinkRepo, useValue: backlinkRepoMock },
|
||||
{ provide: PagePermissionRepo, useValue: permissionRepoMock },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get(BacklinkService);
|
||||
backlinkRepo = module.get(BacklinkRepo) as jest.Mocked<BacklinkRepo>;
|
||||
permissionRepo = module.get(
|
||||
PagePermissionRepo,
|
||||
) as jest.Mocked<PagePermissionRepo>;
|
||||
});
|
||||
|
||||
it('passes filtered ids through to the paginated repo call', async () => {
|
||||
backlinkRepo.findRelatedPageIds.mockResolvedValue(['a', 'b']);
|
||||
permissionRepo.filterAccessiblePageIds.mockResolvedValue(['a']);
|
||||
backlinkRepo.findPagesByIdsPaginated.mockResolvedValue({
|
||||
items: [],
|
||||
meta: {
|
||||
limit: 20,
|
||||
hasNextPage: false,
|
||||
hasPrevPage: false,
|
||||
nextCursor: null,
|
||||
prevCursor: null,
|
||||
},
|
||||
} as any);
|
||||
|
||||
await service.findByPageId(pageId, 'incoming', userId, { limit: 20 } as any);
|
||||
|
||||
expect(backlinkRepo.findPagesByIdsPaginated).toHaveBeenCalledWith(
|
||||
['a'],
|
||||
expect.objectContaining({ limit: 20 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('hands an empty list to the repo when there are no accessible ids', async () => {
|
||||
backlinkRepo.findRelatedPageIds.mockResolvedValue([]);
|
||||
backlinkRepo.findPagesByIdsPaginated.mockResolvedValue({
|
||||
items: [],
|
||||
meta: {
|
||||
limit: 20,
|
||||
hasNextPage: false,
|
||||
hasPrevPage: false,
|
||||
nextCursor: null,
|
||||
prevCursor: null,
|
||||
},
|
||||
} as any);
|
||||
|
||||
await service.findByPageId(pageId, 'incoming', userId, { limit: 20 } as any);
|
||||
|
||||
expect(backlinkRepo.findPagesByIdsPaginated).toHaveBeenCalledWith(
|
||||
[],
|
||||
expect.objectContaining({ limit: 20 }),
|
||||
);
|
||||
expect(permissionRepo.filterAccessiblePageIds).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
|
||||
export type BacklinkDirection = 'incoming' | 'outgoing';
|
||||
|
||||
@Injectable()
|
||||
export class BacklinkService {
|
||||
constructor(
|
||||
private readonly backlinkRepo: BacklinkRepo,
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
) {}
|
||||
|
||||
async countByPageId(
|
||||
pageId: string,
|
||||
userId: string,
|
||||
): Promise<{ incoming: number; outgoing: number }> {
|
||||
const [incomingIds, outgoingIds] = await Promise.all([
|
||||
this.accessibleRelatedIds(pageId, 'incoming', userId),
|
||||
this.accessibleRelatedIds(pageId, 'outgoing', userId),
|
||||
]);
|
||||
return { incoming: incomingIds.length, outgoing: outgoingIds.length };
|
||||
}
|
||||
|
||||
async findByPageId(
|
||||
pageId: string,
|
||||
direction: BacklinkDirection,
|
||||
userId: string,
|
||||
pagination: PaginationOptions,
|
||||
) {
|
||||
const accessibleIds = await this.accessibleRelatedIds(
|
||||
pageId,
|
||||
direction,
|
||||
userId,
|
||||
);
|
||||
return this.backlinkRepo.findPagesByIdsPaginated(accessibleIds, pagination);
|
||||
}
|
||||
|
||||
private async accessibleRelatedIds(
|
||||
pageId: string,
|
||||
direction: BacklinkDirection,
|
||||
userId: string,
|
||||
): Promise<string[]> {
|
||||
const candidateIds = await this.backlinkRepo.findRelatedPageIds(
|
||||
pageId,
|
||||
direction,
|
||||
userId,
|
||||
);
|
||||
if (candidateIds.length === 0) return [];
|
||||
return this.pagePermissionRepo.filterAccessiblePageIds({
|
||||
pageIds: candidateIds,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user