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

@@ -9,6 +9,12 @@ export class SearchQueryDto {
@IsString({ message: "q (query) must be a string" })
q!: string;
@IsOptional()
@Transform(({ value }) => (typeof value === "string" ? value.split(",") : (value as string[])))
@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;

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 () => {

View File

@@ -31,6 +31,7 @@ export class SearchController {
* Requires: Any workspace member
*
* @query q - The search query string (required)
* @query tags - Comma-separated tag slugs to filter by (optional, entries must have ALL tags)
* @query status - Filter by entry status (optional)
* @query page - Page number (default: 1)
* @query limit - Results per page (default: 20, max: 100)
@@ -45,6 +46,7 @@ export class SearchController {
status: query.status,
page: query.page,
limit: query.limit,
tags: query.tags,
});
}

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");

View File

@@ -12,6 +12,7 @@ export interface SearchOptions {
status?: EntryStatus | undefined;
page?: number | undefined;
limit?: number | undefined;
tags?: string[] | undefined;
}
/**
@@ -102,7 +103,7 @@ export class SearchService {
}
// Check cache first
const filters = { status: options.status, page, limit };
const filters = { status: options.status, page, limit, tags: options.tags };
const cached = await this.cache.getSearch<PaginatedSearchResults>(
workspaceId,
sanitizedQuery,
@@ -117,6 +118,23 @@ export class SearchService {
? Prisma.sql`AND e.status = ${options.status}::text::"EntryStatus"`
: Prisma.sql`AND e.status != 'ARCHIVED'`;
// Build tag filter
// If tags are provided, join with knowledge_entry_tags and filter by tag slugs
const tags = options.tags ?? [];
const hasTags = tags.length > 0;
const tagFilter = hasTags
? Prisma.sql`
AND e.id IN (
SELECT et.entry_id
FROM knowledge_entry_tags et
INNER JOIN knowledge_tags t ON et.tag_id = t.id
WHERE t.slug = ANY(${tags}::text[])
GROUP BY et.entry_id
HAVING COUNT(DISTINCT t.slug) = ${tags.length}
)
`
: Prisma.sql``;
// PostgreSQL full-text search query
// Uses precomputed search_vector column (with weights: A=title, B=summary, C=content)
// Maintained automatically by database trigger
@@ -149,6 +167,7 @@ export class SearchService {
WHERE e.workspace_id = ${workspaceId}::uuid
${statusFilter}
AND e.search_vector @@ sq.query
${tagFilter}
ORDER BY rank DESC, e.updated_at DESC
LIMIT ${limit}
OFFSET ${offset}
@@ -161,6 +180,7 @@ export class SearchService {
WHERE e.workspace_id = ${workspaceId}::uuid
${statusFilter}
AND e.search_vector @@ plainto_tsquery('english', ${sanitizedQuery})
${tagFilter}
`;
const total = Number(countResult[0].count);