import { Injectable } from '@nestjs/common'; import { SearchDTO, SearchSuggestionDTO } from './dto/search.dto'; import { SearchResponseDto } from './dto/search-response.dto'; import { InjectKysely } from 'nestjs-kysely'; import { KyselyDB } from '@docmost/db/types/kysely.types'; import { sql } from 'kysely'; import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; import { ShareRepo } from '@docmost/db/repos/share/share.repo'; // eslint-disable-next-line @typescript-eslint/no-require-imports const tsquery = require('pg-tsquery')(); @Injectable() export class SearchService { constructor( @InjectKysely() private readonly db: KyselyDB, private pageRepo: PageRepo, private shareRepo: ShareRepo, private spaceMemberRepo: SpaceMemberRepo, ) {} async searchPage( query: string, searchParams: SearchDTO, opts: { userId?: string; workspaceId: string; }, ): Promise { if (query.length < 1) { return; } const searchQuery = tsquery(query.trim() + '*'); let queryResults = this.db .selectFrom('pages') .select([ 'id', 'slugId', 'title', 'icon', 'parentPageId', 'creatorId', 'createdAt', 'updatedAt', sql`ts_rank(tsv, to_tsquery(${searchQuery}))`.as('rank'), sql`ts_headline('english', text_content, to_tsquery(${searchQuery}),'MinWords=9, MaxWords=10, MaxFragments=3')`.as( 'highlight', ), ]) .where('tsv', '@@', sql`to_tsquery(${searchQuery})`) .$if(Boolean(searchParams.creatorId), (qb) => qb.where('creatorId', '=', searchParams.creatorId), ) .orderBy('rank', 'desc') .limit(searchParams.limit | 20) .offset(searchParams.offset || 0); if (!searchParams.shareId) { queryResults = queryResults.select((eb) => this.pageRepo.withSpace(eb)); } if (searchParams.spaceId) { // search by spaceId queryResults = queryResults.where('spaceId', '=', searchParams.spaceId); } else if (opts.userId && !searchParams.spaceId) { // only search spaces the user is a member of const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds( opts.userId, ); if (userSpaceIds.length > 0) { queryResults = queryResults .where('spaceId', 'in', userSpaceIds) .where('workspaceId', '=', opts.workspaceId); } else { return []; } } else if (searchParams.shareId && !searchParams.spaceId && !opts.userId) { // search in shares const shareId = searchParams.shareId; const share = await this.shareRepo.findById(shareId); if (!share || share.workspaceId !== opts.workspaceId) { return []; } const pageIdsToSearch = []; if (share.includeSubPages) { const pageList = await this.pageRepo.getPageAndDescendants( share.pageId, { includeContent: false, }, ); pageIdsToSearch.push(...pageList.map((page) => page.id)); } else { pageIdsToSearch.push(share.pageId); } if (pageIdsToSearch.length > 0) { queryResults = queryResults .where('id', 'in', pageIdsToSearch) .where('workspaceId', '=', opts.workspaceId); } else { return []; } } else { return []; } //@ts-ignore queryResults = await queryResults.execute(); //@ts-ignore const searchResults = queryResults.map((result: SearchResponseDto) => { if (result.highlight) { result.highlight = result.highlight .replace(/\r\n|\r|\n/g, ' ') .replace(/\s+/g, ' '); } return result; }); return searchResults; } async searchSuggestions( suggestion: SearchSuggestionDTO, userId: string, workspaceId: string, ) { let users = []; let groups = []; let pages = []; const limit = suggestion?.limit || 10; const query = suggestion.query.toLowerCase().trim(); if (suggestion.includeUsers) { users = await this.db .selectFrom('users') .select(['id', 'name', 'avatarUrl']) .where((eb) => eb(sql`LOWER(users.name)`, 'like', `%${query}%`)) .where('workspaceId', '=', workspaceId) .where('deletedAt', 'is', null) .limit(limit) .execute(); } if (suggestion.includeGroups) { groups = await this.db .selectFrom('groups') .select(['id', 'name', 'description']) .where((eb) => eb(sql`LOWER(groups.name)`, 'like', `%${query}%`)) .where('workspaceId', '=', workspaceId) .limit(limit) .execute(); } if (suggestion.includePages) { let pageSearch = this.db .selectFrom('pages') .select(['id', 'slugId', 'title', 'icon', 'spaceId']) .where((eb) => eb(sql`LOWER(pages.title)`, 'like', `%${query}%`)) .where('workspaceId', '=', workspaceId) .limit(limit); // only search spaces the user has access to const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId); if (suggestion?.spaceId) { if (userSpaceIds.includes(suggestion.spaceId)) { pageSearch = pageSearch.where('spaceId', '=', suggestion.spaceId); pages = await pageSearch.execute(); } } else if (userSpaceIds?.length > 0) { // we need this check or the query will throw an error if the userSpaceIds array is empty pageSearch = pageSearch.where('spaceId', 'in', userSpaceIds); pages = await pageSearch.execute(); } } return { users, groups, pages }; } }