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

@@ -55,15 +55,11 @@ describe("SearchController", () => {
limit: 20,
});
expect(mockSearchService.search).toHaveBeenCalledWith(
"test",
mockWorkspaceId,
{
status: undefined,
page: 1,
limit: 20,
}
);
expect(mockSearchService.search).toHaveBeenCalledWith("test", mockWorkspaceId, {
status: undefined,
page: 1,
limit: 20,
});
expect(result).toEqual(mockResult);
});
@@ -79,15 +75,54 @@ describe("SearchController", () => {
status: EntryStatus.PUBLISHED,
});
expect(mockSearchService.search).toHaveBeenCalledWith(
"test",
mockWorkspaceId,
{
status: EntryStatus.PUBLISHED,
page: undefined,
limit: undefined,
}
);
expect(mockSearchService.search).toHaveBeenCalledWith("test", mockWorkspaceId, {
status: EntryStatus.PUBLISHED,
page: undefined,
limit: undefined,
});
});
it("should pass tags 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",
tags: ["api", "documentation"],
});
expect(mockSearchService.search).toHaveBeenCalledWith("test", mockWorkspaceId, {
status: undefined,
page: undefined,
limit: undefined,
tags: ["api", "documentation"],
});
});
it("should pass both status and tags filters 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,
tags: ["api"],
page: 2,
limit: 10,
});
expect(mockSearchService.search).toHaveBeenCalledWith("test", mockWorkspaceId, {
status: EntryStatus.PUBLISHED,
page: 2,
limit: 10,
tags: ["api"],
});
});
});
@@ -128,15 +163,11 @@ describe("SearchController", () => {
status: EntryStatus.DRAFT,
});
expect(mockSearchService.searchByTags).toHaveBeenCalledWith(
["api"],
mockWorkspaceId,
{
status: EntryStatus.DRAFT,
page: undefined,
limit: undefined,
}
);
expect(mockSearchService.searchByTags).toHaveBeenCalledWith(["api"], mockWorkspaceId, {
status: EntryStatus.DRAFT,
page: undefined,
limit: undefined,
});
});
});
@@ -156,11 +187,7 @@ describe("SearchController", () => {
limit: 10,
});
expect(mockSearchService.recentEntries).toHaveBeenCalledWith(
mockWorkspaceId,
10,
undefined
);
expect(mockSearchService.recentEntries).toHaveBeenCalledWith(mockWorkspaceId, 10, undefined);
expect(result).toEqual({
data: mockEntries,
count: 1,
@@ -172,11 +199,7 @@ describe("SearchController", () => {
await controller.recentEntries(mockWorkspaceId, {});
expect(mockSearchService.recentEntries).toHaveBeenCalledWith(
mockWorkspaceId,
10,
undefined
);
expect(mockSearchService.recentEntries).toHaveBeenCalledWith(mockWorkspaceId, 10, undefined);
});
it("should pass status filter to service", async () => {