feat: search

This commit is contained in:
Philipinho
2024-01-30 00:14:21 +01:00
parent e0e5f7c43d
commit a0ec2f30ca
22 changed files with 509 additions and 161 deletions

View File

@ -0,0 +1,9 @@
export class SearchResponseDto {
id: string;
title: string;
icon: string;
parentPageId: string;
creatorId: string;
rank: string;
highlight: string;
}

View File

@ -0,0 +1,18 @@
import { IsNumber, IsOptional, IsString } from 'class-validator';
export class SearchDTO {
@IsString()
query: string;
@IsOptional()
@IsString()
creatorId?: string;
@IsOptional()
@IsNumber()
limit?: number;
@IsOptional()
@IsNumber()
offset?: number;
}

View File

@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SearchController } from './search.controller';
describe('SearchController', () => {
let controller: SearchController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [SearchController],
}).compile();
controller = module.get<SearchController>(SearchController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@ -0,0 +1,44 @@
import {
Body,
Controller,
HttpCode,
HttpStatus,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { JwtUser } from '../../decorators/jwt-user.decorator';
import { WorkspaceService } from '../workspace/services/workspace.service';
import { JwtGuard } from '../auth/guards/JwtGuard';
import { SearchService } from './search.service';
import { SearchDTO } from './dto/search.dto';
@UseGuards(JwtGuard)
@Controller('search')
export class SearchController {
constructor(
private readonly searchService: SearchService,
private readonly workspaceService: WorkspaceService,
) {}
@HttpCode(HttpStatus.OK)
@Post()
async pageSearch(
@Query('type') type: string,
@Body() searchDto: SearchDTO,
@JwtUser() jwtUser,
) {
const workspaceId = (
await this.workspaceService.getUserCurrentWorkspace(jwtUser.id)
).id;
if (!type || type === 'page') {
return this.searchService.searchPage(
searchDto.query,
searchDto,
workspaceId,
);
}
return;
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { SearchController } from './search.controller';
import { SearchService } from './search.service';
import { AuthModule } from '../auth/auth.module';
import { WorkspaceModule } from '../workspace/workspace.module';
import { PageModule } from '../page/page.module';
@Module({
imports: [AuthModule, WorkspaceModule, PageModule],
controllers: [SearchController],
providers: [SearchService],
})
export class SearchModule {}

View File

@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SearchService } from './search.service';
describe('SearchService', () => {
let service: SearchService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [SearchService],
}).compile();
service = module.get<SearchService>(SearchService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -0,0 +1,72 @@
import { Injectable } from '@nestjs/common';
import { PageRepository } from '../page/repositories/page.repository';
import { SearchDTO } from './dto/search.dto';
import { SearchResponseDto } from './dto/search-response.dto';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const tsquery = require('pg-tsquery')();
@Injectable()
export class SearchService {
constructor(private pageRepository: PageRepository) {}
async searchPage(
query: string,
searchParams: SearchDTO,
workspaceId: string,
): Promise<SearchResponseDto[]> {
if (query.length < 1) {
return;
}
const searchQuery = tsquery(query.trim() + '*');
const selectColumns = [
'page.id as id',
'page.title as title',
'page.icon as icon',
'page.parentPageId as "parentPageId"',
'page.creatorId as "creatorId"',
'page.createdAt as "createdAt"',
'page.updatedAt as "updatedAt"',
];
const searchQueryBuilder = await this.pageRepository
.createQueryBuilder('page')
.select(selectColumns);
searchQueryBuilder.andWhere('page.workspaceId = :workspaceId', {
workspaceId,
});
searchQueryBuilder
.addSelect('ts_rank(page.tsv, to_tsquery(:searchQuery))', 'rank')
.addSelect(
`ts_headline('english', page.textContent, to_tsquery(:searchQuery), 'MinWords=9, MaxWords=10, MaxFragments=10')`,
'highlight',
)
.andWhere('page.tsv @@ to_tsquery(:searchQuery)', { searchQuery })
.orderBy('rank', 'DESC');
if (searchParams?.creatorId) {
searchQueryBuilder.andWhere('page.creatorId = :creatorId', {
creatorId: searchParams.creatorId,
});
}
searchQueryBuilder
.take(searchParams.limit || 20)
.offset(searchParams.offset || 0);
const results = await searchQueryBuilder.getRawMany();
const searchResults = results.map((result) => {
if (result.highlight) {
result.highlight = result.highlight
.replace(/\r\n|\r|\n/g, ' ')
.replace(/\s+/g, ' ');
}
return result;
});
return searchResults;
}
}