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'; import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.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, private pagePermissionRepo: PagePermissionRepo, ) {} async searchPage( searchParams: SearchDTO, opts: { userId?: string; workspaceId: string; }, ): Promise<{ items: SearchResponseDto[] }> { const { query } = searchParams; if (query.length < 1) { return { items: [] }; } 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('english', f_unaccent(${searchQuery})))`.as( 'rank', ), sql`ts_headline('english', text_content, to_tsquery('english', f_unaccent(${searchQuery})),'MinWords=9, MaxWords=10, MaxFragments=3')`.as( 'highlight', ), ]) .where( 'tsv', '@@', sql`to_tsquery('english', f_unaccent(${searchQuery}))`, ) .$if(Boolean(searchParams.creatorId), (qb) => qb.where('creatorId', '=', searchParams.creatorId), ) .where('deletedAt', 'is', null) .orderBy('rank', 'desc') .limit(searchParams.limit || 25) .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 queryResults = queryResults .where( 'spaceId', 'in', this.spaceMemberRepo.getUserSpaceIdsQuery(opts.userId), ) .where('workspaceId', '=', opts.workspaceId); } 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 { items: [] }; } 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 { items: [] }; } } else { return { items: [] }; } //@ts-ignore let results: any[] = await queryResults.execute(); // Filter results by page-level permissions (if user is authenticated) if (opts.userId && results.length > 0) { const pageIds = results.map((r: any) => r.id); const accessibleIds = await this.pagePermissionRepo.filterAccessiblePageIds({ pageIds, userId: opts.userId, spaceId: searchParams.spaceId, }); const accessibleSet = new Set(accessibleIds); results = results.filter((r: any) => accessibleSet.has(r.id)); } //@ts-ignore const searchResults = results.map((result: SearchResponseDto) => { if (result.highlight) { result.highlight = result.highlight .replace(/\r\n|\r|\n/g, ' ') .replace(/\s+/g, ' '); } return result; }); return { items: 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) { const userQuery = this.db .selectFrom('users') .select(['id', 'name', 'email', 'avatarUrl']) .where('workspaceId', '=', workspaceId) .where('deletedAt', 'is', null) .where((eb) => eb.or([ eb( sql`LOWER(f_unaccent(users.name))`, 'like', sql`LOWER(f_unaccent(${`%${query}%`}))`, ), eb(sql`users.email`, 'ilike', sql`f_unaccent(${`%${query}%`})`), ]), ) .limit(limit); users = await userQuery.execute(); } if (suggestion.includeGroups) { groups = await this.db .selectFrom('groups') .select(['id', 'name', 'description']) .where((eb) => eb( sql`LOWER(f_unaccent(groups.name))`, 'like', sql`LOWER(f_unaccent(${`%${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(f_unaccent(pages.title))`, 'like', sql`LOWER(f_unaccent(${`%${query}%`}))`, ), ) .where('deletedAt', 'is', null) .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(); } // Filter by page-level permissions if (pages.length > 0) { const pageIds = pages.map((p) => p.id); const accessibleIds = await this.pagePermissionRepo.filterAccessiblePageIds({ pageIds, userId, spaceId: suggestion?.spaceId, }); const accessibleSet = new Set(accessibleIds); pages = pages.filter((p) => accessibleSet.has(p.id)); } } return { users, groups, pages }; } }