feat(#66): implement tag filtering in search API endpoint

Add support for filtering search results by tags in the main search endpoint.

Changes:
- Add tags parameter to SearchQueryDto (comma-separated tag slugs)
- Implement tag filtering in SearchService.search() method
- Update SQL query to join with knowledge_entry_tags when tags provided
- Entries must have ALL specified tags (AND logic)
- Add tests for tag filtering (2 controller tests, 2 service tests)
- Update endpoint documentation
- Fix non-null assertion linting error

The search endpoint now supports:
- Full-text search with ranking (ts_rank)
- Snippet generation with highlighting (ts_headline)
- Status filtering
- Tag filtering (new)
- Pagination

Example: GET /api/knowledge/search?q=api&tags=documentation,tutorial

All tests pass (25 total), type checking passes, linting passes.

Fixes #66

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Jason Woltje
2026-02-02 14:33:31 -06:00
parent 24d59e7595
commit c3500783d1
121 changed files with 4123 additions and 58 deletions

View File

@@ -179,6 +179,71 @@ describe("SearchService", () => {
expect(result.pagination.total).toBe(50);
expect(result.pagination.totalPages).toBe(5);
});
it("should filter by tags when provided", async () => {
const mockSearchResults = [
{
id: "entry-1",
workspace_id: mockWorkspaceId,
slug: "tagged-entry",
title: "Tagged Entry",
content: "Content with search term",
content_html: "<p>Content with search term</p>",
summary: null,
status: EntryStatus.PUBLISHED,
visibility: "WORKSPACE",
created_at: new Date(),
updated_at: new Date(),
created_by: "user-1",
updated_by: "user-1",
rank: 0.8,
headline: "Content with <mark>search term</mark>",
},
];
prismaService.$queryRaw
.mockResolvedValueOnce(mockSearchResults)
.mockResolvedValueOnce([{ count: BigInt(1) }]);
prismaService.knowledgeEntryTag.findMany.mockResolvedValue([
{
entryId: "entry-1",
tag: {
id: "tag-1",
name: "API",
slug: "api",
color: "#blue",
},
},
]);
const result = await service.search("search term", mockWorkspaceId, {
tags: ["api", "documentation"],
});
expect(result.data).toHaveLength(1);
expect(result.data[0].title).toBe("Tagged Entry");
expect(result.data[0].tags).toHaveLength(1);
expect(prismaService.$queryRaw).toHaveBeenCalled();
});
it("should combine full-text search with tag filtering", async () => {
prismaService.$queryRaw
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ count: BigInt(0) }]);
prismaService.knowledgeEntryTag.findMany.mockResolvedValue([]);
await service.search("test query", mockWorkspaceId, {
tags: ["api"],
status: EntryStatus.PUBLISHED,
page: 1,
limit: 20,
});
// Verify the query was called (the actual SQL logic will be tested in integration tests)
expect(prismaService.$queryRaw).toHaveBeenCalled();
});
});
describe("searchByTags", () => {
@@ -229,10 +294,7 @@ describe("SearchService", () => {
prismaService.knowledgeEntry.count.mockResolvedValue(1);
prismaService.knowledgeEntry.findMany.mockResolvedValue(mockEntries);
const result = await service.searchByTags(
["api", "documentation"],
mockWorkspaceId
);
const result = await service.searchByTags(["api", "documentation"], mockWorkspaceId);
expect(result.data).toHaveLength(1);
expect(result.data[0].title).toBe("Tagged Entry");