diff --git a/packages/search/jest.config.js b/packages/search/jest.config.js new file mode 100644 index 0000000..35b10cc --- /dev/null +++ b/packages/search/jest.config.js @@ -0,0 +1,6 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/*.test.ts'], + rootDir: 'src', +}; diff --git a/packages/search/package.json b/packages/search/package.json new file mode 100644 index 0000000..eea1086 --- /dev/null +++ b/packages/search/package.json @@ -0,0 +1,27 @@ +{ + "name": "@tower/search", + "version": "0.0.1", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "test": "jest" + }, + "dependencies": { + "meilisearch": "^0.44.0" + }, + "devDependencies": { + "@types/jest": "^29.0.0", + "@types/node": "^22.0.0", + "jest": "^29.0.0", + "ts-jest": "^29.0.0", + "typescript": "^5.7.0" + } +} diff --git a/packages/search/src/index.test.ts b/packages/search/src/index.test.ts new file mode 100644 index 0000000..7b2546d --- /dev/null +++ b/packages/search/src/index.test.ts @@ -0,0 +1,66 @@ +import { + MESSAGES_INDEX, + configureIndex, + indexMessage, + deleteMessage, + MeiliDocument, +} from './index'; + +function makeMockClient() { + const mockUpdateSettings = jest.fn().mockResolvedValue({}); + const mockAddDocuments = jest.fn().mockResolvedValue({}); + const mockDeleteDocument = jest.fn().mockResolvedValue({}); + const mockIndex = jest.fn().mockReturnValue({ + updateSettings: mockUpdateSettings, + addDocuments: mockAddDocuments, + deleteDocument: mockDeleteDocument, + }); + return { client: { index: mockIndex } as any, mockIndex, mockUpdateSettings, mockAddDocuments, mockDeleteDocument }; +} + +describe('MESSAGES_INDEX', () => { + it('is the string tower-messages', () => { + expect(MESSAGES_INDEX).toBe('tower-messages'); + }); +}); + +describe('configureIndex', () => { + it('sets searchable, filterable, and sortable attributes on the correct index', async () => { + const { client, mockIndex, mockUpdateSettings } = makeMockClient(); + await configureIndex(client); + expect(mockIndex).toHaveBeenCalledWith('tower-messages'); + expect(mockUpdateSettings).toHaveBeenCalledWith({ + searchableAttributes: ['content', 'senderName', 'sourceGroupName'], + filterableAttributes: ['sourceGroupId', 'tags', 'platform'], + sortableAttributes: ['approvedAt'], + }); + }); +}); + +describe('indexMessage', () => { + it('adds the document to the messages index', async () => { + const { client, mockIndex, mockAddDocuments } = makeMockClient(); + const doc: MeiliDocument = { + id: 'msg-1', + content: 'Hello community', + senderName: 'Alice', + sourceGroupId: 'grp-1', + sourceGroupName: 'UP Parivar', + tags: ['#important'], + platform: 'whatsapp', + approvedAt: 1716825600000, + }; + await indexMessage(client, doc); + expect(mockIndex).toHaveBeenCalledWith('tower-messages'); + expect(mockAddDocuments).toHaveBeenCalledWith([doc]); + }); +}); + +describe('deleteMessage', () => { + it('deletes document by id from the messages index', async () => { + const { client, mockIndex, mockDeleteDocument } = makeMockClient(); + await deleteMessage(client, 'msg-1'); + expect(mockIndex).toHaveBeenCalledWith('tower-messages'); + expect(mockDeleteDocument).toHaveBeenCalledWith('msg-1'); + }); +}); diff --git a/packages/search/src/index.ts b/packages/search/src/index.ts new file mode 100644 index 0000000..6ae224e --- /dev/null +++ b/packages/search/src/index.ts @@ -0,0 +1,36 @@ +import { MeiliSearch } from 'meilisearch'; + +export { MeiliSearch } from 'meilisearch'; + +export interface MeiliDocument { + id: string; // DB Message.id + content: string; + senderName: string; // empty string when null + sourceGroupId: string; + sourceGroupName: string; + tags: string[]; + platform: string; + approvedAt: number; // Unix ms — Meilisearch sorts numbers, not ISO strings +} + +export const MESSAGES_INDEX = 'tower-messages'; + +export function createMeiliClient(url: string, masterKey: string): MeiliSearch { + return new MeiliSearch({ host: url, apiKey: masterKey }); +} + +export async function configureIndex(client: MeiliSearch): Promise { + await client.index(MESSAGES_INDEX).updateSettings({ + searchableAttributes: ['content', 'senderName', 'sourceGroupName'], + filterableAttributes: ['sourceGroupId', 'tags', 'platform'], + sortableAttributes: ['approvedAt'], + }); +} + +export async function indexMessage(client: MeiliSearch, doc: MeiliDocument): Promise { + await client.index(MESSAGES_INDEX).addDocuments([doc]); +} + +export async function deleteMessage(client: MeiliSearch, id: string): Promise { + await client.index(MESSAGES_INDEX).deleteDocument(id); +} diff --git a/packages/search/tsconfig.json b/packages/search/tsconfig.json new file mode 100644 index 0000000..792172f --- /dev/null +++ b/packages/search/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +}