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:
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user