feat(api): add SearchModule with GET /search endpoint backed by Meilisearch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 00:28:13 +05:30
parent 6f18433c67
commit 8ad5f737bd
8 changed files with 223 additions and 0 deletions
+1
View File
@@ -16,6 +16,7 @@
"@prisma/client": "^6.0.0",
"@tower/config": "workspace:*",
"@tower/logger": "workspace:*",
"@tower/search": "workspace:*",
"@tower/types": "workspace:*",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0"
+2
View File
@@ -2,12 +2,14 @@ import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { PrismaModule } from './prisma/prisma.module';
import { HealthModule } from './modules/health/health.module';
import { SearchModule } from './modules/search/search.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
PrismaModule,
HealthModule,
SearchModule,
],
})
export class AppModule {}
@@ -0,0 +1,43 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SearchController } from './search.controller';
import { SearchService } from './search.service';
const mockSearchService = { search: jest.fn() };
describe('SearchController', () => {
let controller: SearchController;
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
controllers: [SearchController],
providers: [{ provide: SearchService, useValue: mockSearchService }],
}).compile();
controller = module.get<SearchController>(SearchController);
});
it('calls service with all parsed params', async () => {
mockSearchService.search.mockResolvedValue({ hits: [], total: 0, page: 2, limit: 10, query: 'hello' });
await controller.search('hello', 'grp-1', 'important,event', '2', '10');
expect(mockSearchService.search).toHaveBeenCalledWith('hello', 'grp-1', ['important', 'event'], 2, 10);
});
it('defaults page to 1 and limit to 20 when not provided', async () => {
mockSearchService.search.mockResolvedValue({ hits: [], total: 0, page: 1, limit: 20, query: '' });
await controller.search('');
expect(mockSearchService.search).toHaveBeenCalledWith('', undefined, undefined, 1, 20);
});
it('returns the service result directly', async () => {
const expected = { hits: [{ id: 'msg-1' }], total: 1, page: 1, limit: 20, query: 'test' };
mockSearchService.search.mockResolvedValue(expected);
const result = await controller.search('test');
expect(result).toEqual(expected);
});
it('splits tags on comma and trims whitespace', async () => {
mockSearchService.search.mockResolvedValue({ hits: [], total: 0, page: 1, limit: 20, query: '' });
await controller.search('', undefined, ' important , event ');
expect(mockSearchService.search).toHaveBeenCalledWith('', undefined, ['important', 'event'], 1, 20);
});
});
@@ -0,0 +1,21 @@
import { Controller, Get, Query } from '@nestjs/common';
import { SearchService } from './search.service';
@Controller('search')
export class SearchController {
constructor(private readonly searchService: SearchService) {}
@Get()
search(
@Query('q') q = '',
@Query('groupId') groupId?: string,
@Query('tags') tags?: string,
@Query('page') page = '1',
@Query('limit') limit = '20',
) {
const tagList = tags
? tags.split(',').map((t) => t.trim()).filter(Boolean)
: undefined;
return this.searchService.search(q, groupId, tagList, Number(page), Number(limit));
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { SearchController } from './search.controller';
import { SearchService } from './search.service';
@Module({
imports: [ConfigModule],
controllers: [SearchController],
providers: [SearchService],
})
export class SearchModule {}
@@ -0,0 +1,88 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { SearchService } from './search.service';
import * as searchPkg from '@tower/search';
jest.mock('@tower/search', () => ({
createMeiliClient: jest.fn(),
configureIndex: jest.fn().mockResolvedValue(undefined),
MESSAGES_INDEX: 'tower-messages',
}));
describe('SearchService', () => {
let service: SearchService;
const mockSearch = jest.fn();
const mockIndex = jest.fn().mockReturnValue({ search: mockSearch });
const mockClient = { index: mockIndex };
beforeEach(async () => {
jest.clearAllMocks();
(searchPkg.createMeiliClient as jest.Mock).mockReturnValue(mockClient);
const module: TestingModule = await Test.createTestingModule({
providers: [
SearchService,
{ provide: ConfigService, useValue: { get: jest.fn().mockReturnValue('') } },
],
}).compile();
service = module.get<SearchService>(SearchService);
await service.onModuleInit();
});
it('calls configureIndex on init', () => {
expect(searchPkg.configureIndex).toHaveBeenCalledWith(mockClient);
});
it('returns hits and total', async () => {
mockSearch.mockResolvedValue({ hits: [{ id: 'msg-1', content: 'hello' }], totalHits: 1 });
const result = await service.search('hello');
expect(result.hits).toHaveLength(1);
expect(result.total).toBe(1);
expect(result.query).toBe('hello');
});
it('searches with no filter when no groupId or tags', async () => {
mockSearch.mockResolvedValue({ hits: [], totalHits: 0 });
await service.search('test');
expect(mockSearch).toHaveBeenCalledWith('test', expect.objectContaining({ filter: undefined }));
});
it('applies sourceGroupId filter', async () => {
mockSearch.mockResolvedValue({ hits: [], totalHits: 0 });
await service.search('hello', 'grp-1');
expect(mockSearch).toHaveBeenCalledWith(
'hello',
expect.objectContaining({ filter: 'sourceGroupId = "grp-1"' }),
);
});
it('applies tags filter', async () => {
mockSearch.mockResolvedValue({ hits: [], totalHits: 0 });
await service.search('hello', undefined, ['#important']);
expect(mockSearch).toHaveBeenCalledWith(
'hello',
expect.objectContaining({ filter: 'tags = "#important"' }),
);
});
it('combines groupId and tags filters with AND', async () => {
mockSearch.mockResolvedValue({ hits: [], totalHits: 0 });
await service.search('hello', 'grp-1', ['#important', '#event']);
expect(mockSearch).toHaveBeenCalledWith(
'hello',
expect.objectContaining({
filter: 'sourceGroupId = "grp-1" AND tags = "#important" AND tags = "#event"',
}),
);
});
it('defaults page to 1 and hitsPerPage to 20', async () => {
mockSearch.mockResolvedValue({ hits: [], totalHits: 0 });
await service.search('hello');
expect(mockSearch).toHaveBeenCalledWith(
'hello',
expect.objectContaining({ page: 1, hitsPerPage: 20 }),
);
});
});
@@ -0,0 +1,54 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
MeiliSearch,
MeiliDocument,
MESSAGES_INDEX,
createMeiliClient,
configureIndex,
} from '@tower/search';
@Injectable()
export class SearchService implements OnModuleInit {
private readonly client: MeiliSearch;
constructor(private readonly config: ConfigService) {
this.client = createMeiliClient(
this.config.get('MEILI_URL', 'http://localhost:7700'),
this.config.get('MEILI_MASTER_KEY', ''),
);
}
async onModuleInit(): Promise<void> {
await configureIndex(this.client).catch((err) =>
console.warn('Failed to configure Meilisearch index:', err),
);
}
async search(
query: string,
groupId?: string,
tags?: string[],
page = 1,
limit = 20,
): Promise<{ hits: MeiliDocument[]; total: number; page: number; limit: number; query: string }> {
const filters: string[] = [];
if (groupId) filters.push(`sourceGroupId = "${groupId}"`);
if (tags?.length) filters.push(...tags.map((t) => `tags = "${t}"`));
const result = await this.client.index(MESSAGES_INDEX).search(query, {
filter: filters.length ? filters.join(' AND ') : undefined,
page,
hitsPerPage: limit,
sort: ['approvedAt:desc'],
});
return {
hits: result.hits as MeiliDocument[],
total: result.totalHits ?? 0,
page,
limit,
query,
};
}
}
+3
View File
@@ -44,6 +44,9 @@ importers:
'@tower/logger':
specifier: workspace:*
version: link:../../packages/logger
'@tower/search':
specifier: workspace:*
version: link:../../packages/search
'@tower/types':
specifier: workspace:*
version: link:../../packages/types