feat(knowledge): add search service
This commit is contained in:
@@ -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";
|
||||
|
||||
81
apps/api/src/knowledge/dto/search-query.dto.ts
Normal file
81
apps/api/src/knowledge/dto/search-query.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
197
apps/api/src/knowledge/search.controller.spec.ts
Normal file
197
apps/api/src/knowledge/search.controller.spec.ts
Normal file
@@ -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>(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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
88
apps/api/src/knowledge/search.controller.ts
Normal file
88
apps/api/src/knowledge/search.controller.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
318
apps/api/src/knowledge/services/search.service.spec.ts
Normal file
318
apps/api/src/knowledge/services/search.service.spec.ts
Normal file
@@ -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>(SearchService);
|
||||
prismaService = module.get<PrismaService>(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: "<p>This is test content</p>",
|
||||
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 <mark>test</mark> 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 <mark>test</mark> 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: "<p>Content with tags</p>",
|
||||
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: "<p>Recently updated content</p>",
|
||||
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 },
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
415
apps/api/src/knowledge/services/search.service.ts
Normal file
415
apps/api/src/knowledge/services/search.service.ts
Normal file
@@ -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<PaginatedSearchResults> {
|
||||
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<RawSearchResult[]>`
|
||||
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=<mark>, StopSel=</mark>'
|
||||
) 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<PaginatedEntries> {
|
||||
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<KnowledgeEntryWithTags[]> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user