From 8ad5f737bd052f20ef1832ea834299c4b8ece407 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Thu, 28 May 2026 00:28:13 +0530 Subject: [PATCH] feat(api): add SearchModule with GET /search endpoint backed by Meilisearch Co-Authored-By: Claude Sonnet 4.6 --- apps/api/package.json | 1 + apps/api/src/app.module.ts | 2 + .../modules/search/search.controller.spec.ts | 43 +++++++++ .../src/modules/search/search.controller.ts | 21 +++++ apps/api/src/modules/search/search.module.ts | 11 +++ .../src/modules/search/search.service.spec.ts | 88 +++++++++++++++++++ apps/api/src/modules/search/search.service.ts | 54 ++++++++++++ pnpm-lock.yaml | 3 + 8 files changed, 223 insertions(+) create mode 100644 apps/api/src/modules/search/search.controller.spec.ts create mode 100644 apps/api/src/modules/search/search.controller.ts create mode 100644 apps/api/src/modules/search/search.module.ts create mode 100644 apps/api/src/modules/search/search.service.spec.ts create mode 100644 apps/api/src/modules/search/search.service.ts diff --git a/apps/api/package.json b/apps/api/package.json index 70adb76..8269c03 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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" diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 0abdab7..80fa5b1 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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 {} diff --git a/apps/api/src/modules/search/search.controller.spec.ts b/apps/api/src/modules/search/search.controller.spec.ts new file mode 100644 index 0000000..9c7bf80 --- /dev/null +++ b/apps/api/src/modules/search/search.controller.spec.ts @@ -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); + }); + + 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); + }); +}); diff --git a/apps/api/src/modules/search/search.controller.ts b/apps/api/src/modules/search/search.controller.ts new file mode 100644 index 0000000..84f78fa --- /dev/null +++ b/apps/api/src/modules/search/search.controller.ts @@ -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)); + } +} diff --git a/apps/api/src/modules/search/search.module.ts b/apps/api/src/modules/search/search.module.ts new file mode 100644 index 0000000..793aebc --- /dev/null +++ b/apps/api/src/modules/search/search.module.ts @@ -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 {} diff --git a/apps/api/src/modules/search/search.service.spec.ts b/apps/api/src/modules/search/search.service.spec.ts new file mode 100644 index 0000000..69245e6 --- /dev/null +++ b/apps/api/src/modules/search/search.service.spec.ts @@ -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); + 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 }), + ); + }); +}); diff --git a/apps/api/src/modules/search/search.service.ts b/apps/api/src/modules/search/search.service.ts new file mode 100644 index 0000000..ac9bb42 --- /dev/null +++ b/apps/api/src/modules/search/search.service.ts @@ -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 { + 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, + }; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a0c769c..c2f33bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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