diff --git a/apps/api/src/knowledge/dto/index.ts b/apps/api/src/knowledge/dto/index.ts index 120371e..1fe3c76 100644 --- a/apps/api/src/knowledge/dto/index.ts +++ b/apps/api/src/knowledge/dto/index.ts @@ -3,3 +3,8 @@ export { UpdateEntryDto } from "./update-entry.dto"; export { EntryQueryDto } from "./entry-query.dto"; export { CreateTagDto } from "./create-tag.dto"; export { UpdateTagDto } from "./update-tag.dto"; +export { + SearchQueryDto, + TagSearchDto, + RecentEntriesDto, +} from "./search-query.dto"; diff --git a/apps/api/src/knowledge/dto/search-query.dto.ts b/apps/api/src/knowledge/dto/search-query.dto.ts new file mode 100644 index 0000000..81f48bd --- /dev/null +++ b/apps/api/src/knowledge/dto/search-query.dto.ts @@ -0,0 +1,81 @@ +import { + IsOptional, + IsString, + IsInt, + Min, + Max, + IsArray, + IsEnum, +} from "class-validator"; +import { Type, Transform } from "class-transformer"; +import { EntryStatus } from "@prisma/client"; + +/** + * DTO for full-text search query parameters + */ +export class SearchQueryDto { + @IsString({ message: "q (query) must be a string" }) + q!: string; + + @IsOptional() + @IsEnum(EntryStatus, { message: "status must be a valid EntryStatus" }) + status?: EntryStatus; + + @IsOptional() + @Type(() => Number) + @IsInt({ message: "page must be an integer" }) + @Min(1, { message: "page must be at least 1" }) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt({ message: "limit must be an integer" }) + @Min(1, { message: "limit must be at least 1" }) + @Max(100, { message: "limit must not exceed 100" }) + limit?: number; +} + +/** + * DTO for searching by tags + */ +export class TagSearchDto { + @Transform(({ value }) => + typeof value === "string" ? value.split(",") : value + ) + @IsArray({ message: "tags must be an array" }) + @IsString({ each: true, message: "each tag must be a string" }) + tags!: string[]; + + @IsOptional() + @IsEnum(EntryStatus, { message: "status must be a valid EntryStatus" }) + status?: EntryStatus; + + @IsOptional() + @Type(() => Number) + @IsInt({ message: "page must be an integer" }) + @Min(1, { message: "page must be at least 1" }) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt({ message: "limit must be an integer" }) + @Min(1, { message: "limit must be at least 1" }) + @Max(100, { message: "limit must not exceed 100" }) + limit?: number; +} + +/** + * DTO for recent entries query + */ +export class RecentEntriesDto { + @IsOptional() + @Type(() => Number) + @IsInt({ message: "limit must be an integer" }) + @Min(1, { message: "limit must be at least 1" }) + @Max(50, { message: "limit must not exceed 50" }) + limit?: number; + + @IsOptional() + @IsEnum(EntryStatus, { message: "status must be a valid EntryStatus" }) + status?: EntryStatus; +} diff --git a/apps/api/src/knowledge/knowledge.module.ts b/apps/api/src/knowledge/knowledge.module.ts index d826d33..a22a58c 100644 --- a/apps/api/src/knowledge/knowledge.module.ts +++ b/apps/api/src/knowledge/knowledge.module.ts @@ -3,12 +3,14 @@ import { PrismaModule } from "../prisma/prisma.module"; import { AuthModule } from "../auth/auth.module"; import { KnowledgeService } from "./knowledge.service"; import { KnowledgeController } from "./knowledge.controller"; +import { SearchController } from "./search.controller"; import { LinkResolutionService } from "./services/link-resolution.service"; +import { SearchService } from "./services/search.service"; @Module({ imports: [PrismaModule, AuthModule], - controllers: [KnowledgeController], - providers: [KnowledgeService, LinkResolutionService], - exports: [KnowledgeService, LinkResolutionService], + controllers: [KnowledgeController, SearchController], + providers: [KnowledgeService, LinkResolutionService, SearchService], + exports: [KnowledgeService, LinkResolutionService, SearchService], }) export class KnowledgeModule {} diff --git a/apps/api/src/knowledge/search.controller.spec.ts b/apps/api/src/knowledge/search.controller.spec.ts new file mode 100644 index 0000000..7c25562 --- /dev/null +++ b/apps/api/src/knowledge/search.controller.spec.ts @@ -0,0 +1,197 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { EntryStatus } from "@prisma/client"; +import { SearchController } from "./search.controller"; +import { SearchService } from "./services/search.service"; +import { AuthGuard } from "../auth/guards/auth.guard"; +import { WorkspaceGuard, PermissionGuard } from "../common/guards"; + +describe("SearchController", () => { + let controller: SearchController; + + const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440000"; + + const mockSearchService = { + search: vi.fn(), + searchByTags: vi.fn(), + recentEntries: vi.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [SearchController], + providers: [ + { + provide: SearchService, + useValue: mockSearchService, + }, + ], + }) + .overrideGuard(AuthGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(WorkspaceGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(PermissionGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = module.get(SearchController); + + vi.clearAllMocks(); + }); + + describe("search", () => { + it("should call searchService.search with correct parameters", async () => { + const mockResult = { + data: [], + pagination: { page: 1, limit: 20, total: 0, totalPages: 0 }, + query: "test", + }; + mockSearchService.search.mockResolvedValue(mockResult); + + const result = await controller.search(mockWorkspaceId, { + q: "test", + page: 1, + limit: 20, + }); + + expect(mockSearchService.search).toHaveBeenCalledWith( + "test", + mockWorkspaceId, + { + status: undefined, + page: 1, + limit: 20, + } + ); + expect(result).toEqual(mockResult); + }); + + it("should pass status filter to service", async () => { + mockSearchService.search.mockResolvedValue({ + data: [], + pagination: { page: 1, limit: 20, total: 0, totalPages: 0 }, + query: "test", + }); + + await controller.search(mockWorkspaceId, { + q: "test", + status: EntryStatus.PUBLISHED, + }); + + expect(mockSearchService.search).toHaveBeenCalledWith( + "test", + mockWorkspaceId, + { + status: EntryStatus.PUBLISHED, + page: undefined, + limit: undefined, + } + ); + }); + }); + + describe("searchByTags", () => { + it("should call searchService.searchByTags with correct parameters", async () => { + const mockResult = { + data: [], + pagination: { page: 1, limit: 20, total: 0, totalPages: 0 }, + }; + mockSearchService.searchByTags.mockResolvedValue(mockResult); + + const result = await controller.searchByTags(mockWorkspaceId, { + tags: ["api", "documentation"], + page: 1, + limit: 20, + }); + + expect(mockSearchService.searchByTags).toHaveBeenCalledWith( + ["api", "documentation"], + mockWorkspaceId, + { + status: undefined, + page: 1, + limit: 20, + } + ); + expect(result).toEqual(mockResult); + }); + + it("should pass status filter to service", async () => { + mockSearchService.searchByTags.mockResolvedValue({ + data: [], + pagination: { page: 1, limit: 20, total: 0, totalPages: 0 }, + }); + + await controller.searchByTags(mockWorkspaceId, { + tags: ["api"], + status: EntryStatus.DRAFT, + }); + + expect(mockSearchService.searchByTags).toHaveBeenCalledWith( + ["api"], + mockWorkspaceId, + { + status: EntryStatus.DRAFT, + page: undefined, + limit: undefined, + } + ); + }); + }); + + describe("recentEntries", () => { + it("should call searchService.recentEntries with correct parameters", async () => { + const mockEntries = [ + { + id: "entry-1", + title: "Recent Entry", + slug: "recent-entry", + tags: [], + }, + ]; + mockSearchService.recentEntries.mockResolvedValue(mockEntries); + + const result = await controller.recentEntries(mockWorkspaceId, { + limit: 10, + }); + + expect(mockSearchService.recentEntries).toHaveBeenCalledWith( + mockWorkspaceId, + 10, + undefined + ); + expect(result).toEqual({ + data: mockEntries, + count: 1, + }); + }); + + it("should use default limit of 10", async () => { + mockSearchService.recentEntries.mockResolvedValue([]); + + await controller.recentEntries(mockWorkspaceId, {}); + + expect(mockSearchService.recentEntries).toHaveBeenCalledWith( + mockWorkspaceId, + 10, + undefined + ); + }); + + it("should pass status filter to service", async () => { + mockSearchService.recentEntries.mockResolvedValue([]); + + await controller.recentEntries(mockWorkspaceId, { + status: EntryStatus.PUBLISHED, + limit: 5, + }); + + expect(mockSearchService.recentEntries).toHaveBeenCalledWith( + mockWorkspaceId, + 5, + EntryStatus.PUBLISHED + ); + }); + }); +}); diff --git a/apps/api/src/knowledge/search.controller.ts b/apps/api/src/knowledge/search.controller.ts new file mode 100644 index 0000000..5d3f7b0 --- /dev/null +++ b/apps/api/src/knowledge/search.controller.ts @@ -0,0 +1,88 @@ +import { Controller, Get, Query, UseGuards } from "@nestjs/common"; +import { SearchService } from "./services/search.service"; +import { SearchQueryDto, TagSearchDto, RecentEntriesDto } from "./dto"; +import { AuthGuard } from "../auth/guards/auth.guard"; +import { WorkspaceGuard, PermissionGuard } from "../common/guards"; +import { Workspace, Permission, RequirePermission } from "../common/decorators"; + +/** + * Controller for knowledge search endpoints + * All endpoints require authentication and workspace context + */ +@Controller("knowledge/search") +@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard) +export class SearchController { + constructor(private readonly searchService: SearchService) {} + + /** + * GET /api/knowledge/search + * Full-text search across knowledge entries + * Searches title and content with relevance ranking + * Requires: Any workspace member + * + * @query q - The search query string (required) + * @query status - Filter by entry status (optional) + * @query page - Page number (default: 1) + * @query limit - Results per page (default: 20, max: 100) + */ + @Get() + @RequirePermission(Permission.WORKSPACE_ANY) + async search( + @Workspace() workspaceId: string, + @Query() query: SearchQueryDto + ) { + return this.searchService.search(query.q, workspaceId, { + status: query.status, + page: query.page, + limit: query.limit, + }); + } + + /** + * GET /api/knowledge/search/by-tags + * Search entries by tags (entries must have ALL specified tags) + * Requires: Any workspace member + * + * @query tags - Comma-separated list of tag slugs (required) + * @query status - Filter by entry status (optional) + * @query page - Page number (default: 1) + * @query limit - Results per page (default: 20, max: 100) + */ + @Get("by-tags") + @RequirePermission(Permission.WORKSPACE_ANY) + async searchByTags( + @Workspace() workspaceId: string, + @Query() query: TagSearchDto + ) { + return this.searchService.searchByTags(query.tags, workspaceId, { + status: query.status, + page: query.page, + limit: query.limit, + }); + } + + /** + * GET /api/knowledge/search/recent + * Get recently modified entries + * Requires: Any workspace member + * + * @query limit - Maximum number of entries (default: 10, max: 50) + * @query status - Filter by entry status (optional) + */ + @Get("recent") + @RequirePermission(Permission.WORKSPACE_ANY) + async recentEntries( + @Workspace() workspaceId: string, + @Query() query: RecentEntriesDto + ) { + const entries = await this.searchService.recentEntries( + workspaceId, + query.limit || 10, + query.status + ); + return { + data: entries, + count: entries.length, + }; + } +} diff --git a/apps/api/src/knowledge/services/search.service.spec.ts b/apps/api/src/knowledge/services/search.service.spec.ts new file mode 100644 index 0000000..d7f96ce --- /dev/null +++ b/apps/api/src/knowledge/services/search.service.spec.ts @@ -0,0 +1,318 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { EntryStatus } from "@prisma/client"; +import { SearchService } from "./search.service"; +import { PrismaService } from "../../prisma/prisma.service"; + +describe("SearchService", () => { + let service: SearchService; + let prismaService: any; + + const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440000"; + + beforeEach(async () => { + const mockQueryRaw = vi.fn(); + const mockKnowledgeEntryCount = vi.fn(); + const mockKnowledgeEntryFindMany = vi.fn(); + const mockKnowledgeEntryTagFindMany = vi.fn(); + + const mockPrismaService = { + $queryRaw: mockQueryRaw, + knowledgeEntry: { + count: mockKnowledgeEntryCount, + findMany: mockKnowledgeEntryFindMany, + }, + knowledgeEntryTag: { + findMany: mockKnowledgeEntryTagFindMany, + }, + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SearchService, + { + provide: PrismaService, + useValue: mockPrismaService, + }, + ], + }).compile(); + + service = module.get(SearchService); + prismaService = module.get(PrismaService); + }); + + describe("search", () => { + it("should return empty results for empty query", async () => { + const result = await service.search("", mockWorkspaceId); + + expect(result.data).toEqual([]); + expect(result.pagination.total).toBe(0); + expect(result.query).toBe(""); + }); + + it("should return empty results for whitespace-only query", async () => { + const result = await service.search(" ", mockWorkspaceId); + + expect(result.data).toEqual([]); + expect(result.pagination.total).toBe(0); + }); + + it("should perform full-text search and return ranked results", async () => { + const mockSearchResults = [ + { + id: "entry-1", + workspace_id: mockWorkspaceId, + slug: "test-entry", + title: "Test Entry", + content: "This is test content", + content_html: "

This is test content

", + summary: "Test summary", + status: EntryStatus.PUBLISHED, + visibility: "WORKSPACE", + created_at: new Date(), + updated_at: new Date(), + created_by: "user-1", + updated_by: "user-1", + rank: 0.5, + headline: "This is test content", + }, + ]; + + prismaService.$queryRaw + .mockResolvedValueOnce(mockSearchResults) + .mockResolvedValueOnce([{ count: BigInt(1) }]); + + prismaService.knowledgeEntryTag.findMany.mockResolvedValue([ + { + entryId: "entry-1", + tag: { + id: "tag-1", + name: "Documentation", + slug: "documentation", + color: "#blue", + }, + }, + ]); + + const result = await service.search("test", mockWorkspaceId); + + expect(result.data).toHaveLength(1); + expect(result.data[0].title).toBe("Test Entry"); + expect(result.data[0].rank).toBe(0.5); + expect(result.data[0].headline).toBe("This is test content"); + expect(result.data[0].tags).toHaveLength(1); + expect(result.pagination.total).toBe(1); + expect(result.query).toBe("test"); + }); + + it("should sanitize search query removing special characters", async () => { + prismaService.$queryRaw + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ count: BigInt(0) }]); + prismaService.knowledgeEntryTag.findMany.mockResolvedValue([]); + + await service.search("test & query | !special:chars*", mockWorkspaceId); + + // Should have been called with sanitized query + expect(prismaService.$queryRaw).toHaveBeenCalled(); + }); + + it("should apply status filter when provided", async () => { + prismaService.$queryRaw + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ count: BigInt(0) }]); + prismaService.knowledgeEntryTag.findMany.mockResolvedValue([]); + + await service.search("test", mockWorkspaceId, { + status: EntryStatus.DRAFT, + }); + + expect(prismaService.$queryRaw).toHaveBeenCalled(); + }); + + it("should handle pagination correctly", async () => { + prismaService.$queryRaw + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ count: BigInt(50) }]); + prismaService.knowledgeEntryTag.findMany.mockResolvedValue([]); + + const result = await service.search("test", mockWorkspaceId, { + page: 2, + limit: 10, + }); + + expect(result.pagination.page).toBe(2); + expect(result.pagination.limit).toBe(10); + expect(result.pagination.total).toBe(50); + expect(result.pagination.totalPages).toBe(5); + }); + }); + + describe("searchByTags", () => { + it("should return empty results for empty tags array", async () => { + const result = await service.searchByTags([], mockWorkspaceId); + + expect(result.data).toEqual([]); + expect(result.pagination.total).toBe(0); + }); + + it("should find entries with all specified tags", async () => { + const mockEntries = [ + { + id: "entry-1", + workspaceId: mockWorkspaceId, + slug: "tagged-entry", + title: "Tagged Entry", + content: "Content with tags", + contentHtml: "

Content with tags

", + summary: null, + status: EntryStatus.PUBLISHED, + visibility: "WORKSPACE", + createdAt: new Date(), + updatedAt: new Date(), + createdBy: "user-1", + updatedBy: "user-1", + tags: [ + { + tag: { + id: "tag-1", + name: "API", + slug: "api", + color: "#blue", + }, + }, + { + tag: { + id: "tag-2", + name: "Documentation", + slug: "documentation", + color: "#green", + }, + }, + ], + }, + ]; + + prismaService.knowledgeEntry.count.mockResolvedValue(1); + prismaService.knowledgeEntry.findMany.mockResolvedValue(mockEntries); + + const result = await service.searchByTags( + ["api", "documentation"], + mockWorkspaceId + ); + + expect(result.data).toHaveLength(1); + expect(result.data[0].title).toBe("Tagged Entry"); + expect(result.data[0].tags).toHaveLength(2); + expect(result.pagination.total).toBe(1); + }); + + it("should apply status filter when provided", async () => { + prismaService.knowledgeEntry.count.mockResolvedValue(0); + prismaService.knowledgeEntry.findMany.mockResolvedValue([]); + + await service.searchByTags(["api"], mockWorkspaceId, { + status: EntryStatus.DRAFT, + }); + + expect(prismaService.knowledgeEntry.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: EntryStatus.DRAFT, + }), + }) + ); + }); + + it("should handle pagination correctly", async () => { + prismaService.knowledgeEntry.count.mockResolvedValue(25); + prismaService.knowledgeEntry.findMany.mockResolvedValue([]); + + const result = await service.searchByTags(["api"], mockWorkspaceId, { + page: 2, + limit: 10, + }); + + expect(result.pagination.page).toBe(2); + expect(result.pagination.limit).toBe(10); + expect(result.pagination.total).toBe(25); + expect(result.pagination.totalPages).toBe(3); + }); + }); + + describe("recentEntries", () => { + it("should return recently modified entries", async () => { + const mockEntries = [ + { + id: "entry-1", + workspaceId: mockWorkspaceId, + slug: "recent-entry", + title: "Recent Entry", + content: "Recently updated content", + contentHtml: "

Recently updated content

", + summary: null, + status: EntryStatus.PUBLISHED, + visibility: "WORKSPACE", + createdAt: new Date(), + updatedAt: new Date(), + createdBy: "user-1", + updatedBy: "user-1", + tags: [], + }, + ]; + + prismaService.knowledgeEntry.findMany.mockResolvedValue(mockEntries); + + const result = await service.recentEntries(mockWorkspaceId); + + expect(result).toHaveLength(1); + expect(result[0].title).toBe("Recent Entry"); + expect(prismaService.knowledgeEntry.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + orderBy: { updatedAt: "desc" }, + take: 10, + }) + ); + }); + + it("should respect the limit parameter", async () => { + prismaService.knowledgeEntry.findMany.mockResolvedValue([]); + + await service.recentEntries(mockWorkspaceId, 5); + + expect(prismaService.knowledgeEntry.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + take: 5, + }) + ); + }); + + it("should apply status filter when provided", async () => { + prismaService.knowledgeEntry.findMany.mockResolvedValue([]); + + await service.recentEntries(mockWorkspaceId, 10, EntryStatus.DRAFT); + + expect(prismaService.knowledgeEntry.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: EntryStatus.DRAFT, + }), + }) + ); + }); + + it("should exclude archived entries by default", async () => { + prismaService.knowledgeEntry.findMany.mockResolvedValue([]); + + await service.recentEntries(mockWorkspaceId); + + expect(prismaService.knowledgeEntry.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: { not: EntryStatus.ARCHIVED }, + }), + }) + ); + }); + }); +}); diff --git a/apps/api/src/knowledge/services/search.service.ts b/apps/api/src/knowledge/services/search.service.ts new file mode 100644 index 0000000..18add2d --- /dev/null +++ b/apps/api/src/knowledge/services/search.service.ts @@ -0,0 +1,415 @@ +import { Injectable } from "@nestjs/common"; +import { EntryStatus, Prisma } from "@prisma/client"; +import { PrismaService } from "../../prisma/prisma.service"; +import type { + KnowledgeEntryWithTags, + PaginatedEntries, +} from "../entities/knowledge-entry.entity"; + +/** + * Search options for full-text search + */ +export interface SearchOptions { + status?: EntryStatus | undefined; + page?: number | undefined; + limit?: number | undefined; +} + +/** + * Search result with relevance ranking + */ +export interface SearchResult extends KnowledgeEntryWithTags { + rank: number; + headline?: string | undefined; +} + +/** + * Paginated search results + */ +export interface PaginatedSearchResults { + data: SearchResult[]; + pagination: { + page: number; + limit: number; + total: number; + totalPages: number; + }; + query: string; +} + +/** + * Raw search result from PostgreSQL query + */ +interface RawSearchResult { + id: string; + workspace_id: string; + slug: string; + title: string; + content: string; + content_html: string | null; + summary: string | null; + status: EntryStatus; + visibility: string; + created_at: Date; + updated_at: Date; + created_by: string; + updated_by: string; + rank: number; + headline: string | null; +} + +/** + * Service for searching knowledge entries using PostgreSQL full-text search + */ +@Injectable() +export class SearchService { + constructor(private readonly prisma: PrismaService) {} + + /** + * Full-text search on title and content using PostgreSQL ts_vector + * + * @param query - The search query string + * @param workspaceId - The workspace to search within + * @param options - Search options (status filter, pagination) + * @returns Paginated search results ranked by relevance + */ + async search( + query: string, + workspaceId: string, + options: SearchOptions = {} + ): Promise { + const page = options.page || 1; + const limit = options.limit || 20; + const offset = (page - 1) * limit; + + // Sanitize and prepare the search query + const sanitizedQuery = this.sanitizeSearchQuery(query); + + if (!sanitizedQuery) { + return { + data: [], + pagination: { + page, + limit, + total: 0, + totalPages: 0, + }, + query, + }; + } + + // Build status filter + const statusFilter = options.status + ? Prisma.sql`AND e.status = ${options.status}::text::"EntryStatus"` + : Prisma.sql`AND e.status != 'ARCHIVED'`; + + // PostgreSQL full-text search query + // Uses ts_rank for relevance scoring with weights: title (A=1.0), content (B=0.4) + const searchResults = await this.prisma.$queryRaw` + WITH search_query AS ( + SELECT plainto_tsquery('english', ${sanitizedQuery}) AS query + ) + SELECT + e.id, + e.workspace_id, + e.slug, + e.title, + e.content, + e.content_html, + e.summary, + e.status, + e.visibility, + e.created_at, + e.updated_at, + e.created_by, + e.updated_by, + ts_rank( + setweight(to_tsvector('english', e.title), 'A') || + setweight(to_tsvector('english', e.content), 'B'), + sq.query + ) AS rank, + ts_headline( + 'english', + e.content, + sq.query, + 'MaxWords=50, MinWords=25, StartSel=, StopSel=' + ) AS headline + FROM knowledge_entries e, search_query sq + WHERE e.workspace_id = ${workspaceId}::uuid + ${statusFilter} + AND ( + to_tsvector('english', e.title) @@ sq.query + OR to_tsvector('english', e.content) @@ sq.query + ) + ORDER BY rank DESC, e.updated_at DESC + LIMIT ${limit} + OFFSET ${offset} + `; + + // Get total count for pagination + const countResult = await this.prisma.$queryRaw<[{ count: bigint }]>` + SELECT COUNT(*) as count + FROM knowledge_entries e + WHERE e.workspace_id = ${workspaceId}::uuid + ${statusFilter} + AND ( + to_tsvector('english', e.title) @@ plainto_tsquery('english', ${sanitizedQuery}) + OR to_tsvector('english', e.content) @@ plainto_tsquery('english', ${sanitizedQuery}) + ) + `; + + const total = Number(countResult[0].count); + + // Fetch tags for the results + const entryIds = searchResults.map((r) => r.id); + const tagsMap = await this.fetchTagsForEntries(entryIds); + + // Transform results to the expected format + const data: SearchResult[] = searchResults.map((row) => ({ + id: row.id, + workspaceId: row.workspace_id, + slug: row.slug, + title: row.title, + content: row.content, + contentHtml: row.content_html, + summary: row.summary, + status: row.status, + visibility: row.visibility as "PRIVATE" | "WORKSPACE" | "PUBLIC", + createdAt: row.created_at, + updatedAt: row.updated_at, + createdBy: row.created_by, + updatedBy: row.updated_by, + rank: row.rank, + headline: row.headline ?? undefined, + tags: tagsMap.get(row.id) || [], + })); + + return { + data, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + query, + }; + } + + /** + * Search entries by tags (entries must have ALL specified tags) + * + * @param tags - Array of tag slugs to filter by + * @param workspaceId - The workspace to search within + * @param options - Search options (status filter, pagination) + * @returns Paginated entries that have all specified tags + */ + async searchByTags( + tags: string[], + workspaceId: string, + options: SearchOptions = {} + ): Promise { + const page = options.page || 1; + const limit = options.limit || 20; + const skip = (page - 1) * limit; + + if (!tags || tags.length === 0) { + return { + data: [], + pagination: { + page, + limit, + total: 0, + totalPages: 0, + }, + }; + } + + // Build where clause for entries that have ALL specified tags + const where: Prisma.KnowledgeEntryWhereInput = { + workspaceId, + status: options.status || { not: EntryStatus.ARCHIVED }, + AND: tags.map((tagSlug) => ({ + tags: { + some: { + tag: { + slug: tagSlug, + }, + }, + }, + })), + }; + + // Get total count + const total = await this.prisma.knowledgeEntry.count({ where }); + + // Get entries + const entries = await this.prisma.knowledgeEntry.findMany({ + where, + include: { + tags: { + include: { + tag: true, + }, + }, + }, + orderBy: { + updatedAt: "desc", + }, + skip, + take: limit, + }); + + // Transform to response format + const data: KnowledgeEntryWithTags[] = entries.map((entry) => ({ + id: entry.id, + workspaceId: entry.workspaceId, + slug: entry.slug, + title: entry.title, + content: entry.content, + contentHtml: entry.contentHtml, + summary: entry.summary, + status: entry.status, + visibility: entry.visibility, + createdAt: entry.createdAt, + updatedAt: entry.updatedAt, + createdBy: entry.createdBy, + updatedBy: entry.updatedBy, + tags: entry.tags.map((et) => ({ + id: et.tag.id, + name: et.tag.name, + slug: et.tag.slug, + color: et.tag.color, + })), + })); + + return { + data, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }; + } + + /** + * Get recently modified entries + * + * @param workspaceId - The workspace to query + * @param limit - Maximum number of entries to return (default: 10) + * @param status - Optional status filter + * @returns Array of recently modified entries + */ + async recentEntries( + workspaceId: string, + limit: number = 10, + status?: EntryStatus + ): Promise { + const where: Prisma.KnowledgeEntryWhereInput = { + workspaceId, + status: status || { not: EntryStatus.ARCHIVED }, + }; + + const entries = await this.prisma.knowledgeEntry.findMany({ + where, + include: { + tags: { + include: { + tag: true, + }, + }, + }, + orderBy: { + updatedAt: "desc", + }, + take: limit, + }); + + return entries.map((entry) => ({ + id: entry.id, + workspaceId: entry.workspaceId, + slug: entry.slug, + title: entry.title, + content: entry.content, + contentHtml: entry.contentHtml, + summary: entry.summary, + status: entry.status, + visibility: entry.visibility, + createdAt: entry.createdAt, + updatedAt: entry.updatedAt, + createdBy: entry.createdBy, + updatedBy: entry.updatedBy, + tags: entry.tags.map((et) => ({ + id: et.tag.id, + name: et.tag.name, + slug: et.tag.slug, + color: et.tag.color, + })), + })); + } + + /** + * Sanitize search query to prevent SQL injection and handle special characters + */ + private sanitizeSearchQuery(query: string): string { + if (!query || typeof query !== "string") { + return ""; + } + + // Trim and normalize whitespace + let sanitized = query.trim().replace(/\s+/g, " "); + + // Remove PostgreSQL full-text search operators that could cause issues + sanitized = sanitized.replace(/[&|!:*()]/g, " "); + + // Trim again after removing special chars + sanitized = sanitized.trim(); + + return sanitized; + } + + /** + * Fetch tags for a list of entry IDs + */ + private async fetchTagsForEntries( + entryIds: string[] + ): Promise< + Map< + string, + Array<{ id: string; name: string; slug: string; color: string | null }> + > + > { + if (entryIds.length === 0) { + return new Map(); + } + + const entryTags = await this.prisma.knowledgeEntryTag.findMany({ + where: { + entryId: { in: entryIds }, + }, + include: { + tag: true, + }, + }); + + const tagsMap = new Map< + string, + Array<{ id: string; name: string; slug: string; color: string | null }> + >(); + + for (const et of entryTags) { + const tags = tagsMap.get(et.entryId) || []; + tags.push({ + id: et.tag.id, + name: et.tag.name, + slug: et.tag.slug, + color: et.tag.color, + }); + tagsMap.set(et.entryId, tags); + } + + return tagsMap; + } +}