From 6975127b96fc2839a62462114b5dff17d03b68c6 Mon Sep 17 00:00:00 2001 From: joel-joseph-george Date: Fri, 4 Sep 2026 16:12:47 +0530 Subject: [PATCH 1/4] feat: add search endpoint to filter bibles and languages by name, code, or abbreviation --- src/domains/bibles/bibles.repository.test.ts | 67 ++++++++++++ src/domains/bibles/bibles.repository.ts | 102 ++++++++++++++++++- src/domains/bibles/bibles.route.ts | 43 +++++++- src/domains/bibles/bibles.service.ts | 5 + src/domains/bibles/bibles.types.ts | 35 +++++++ 5 files changed, 248 insertions(+), 4 deletions(-) create mode 100644 src/domains/bibles/bibles.repository.test.ts diff --git a/src/domains/bibles/bibles.repository.test.ts b/src/domains/bibles/bibles.repository.test.ts new file mode 100644 index 00000000..56443498 --- /dev/null +++ b/src/domains/bibles/bibles.repository.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { searchSourceBibles } from './bibles.repository'; + +const { mockDb } = vi.hoisted(() => { + const mockDb = { select: vi.fn() }; + return { mockDb }; +}); + +vi.mock('@/db', () => ({ db: mockDb })); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('searchSourceBibles', () => { + it('returns grouped languages and bibles matching the search query', async () => { + const fakeRows = [ + { + bibleId: 1, + bibleName: 'Indian Revised Version Gujarati', + bibleAbbreviation: 'IRV-GUJ', + bibleProvider: 'dbl', + languageId: 10, + langName: 'Gujarati', + langCodeIso6393: 'guj', + }, + ]; + + const limitFn = vi.fn().mockResolvedValue(fakeRows); + const whereFn = vi.fn().mockReturnValue({ limit: limitFn }); + const innerJoinFn = vi.fn().mockReturnValue({ where: whereFn }); + const fromFn = vi.fn().mockReturnValue({ innerJoin: innerJoinFn }); + mockDb.select.mockReturnValue({ from: fromFn }); + + const result = await searchSourceBibles('guj'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.languages).toHaveLength(1); + expect(result.data.languages[0]).toEqual({ + id: 10, + langName: 'Gujarati', + langCodeIso6393: 'guj', + bibleCount: 1, + bibles: [ + { + id: 1, + name: 'Indian Revised Version Gujarati', + abbreviation: 'IRV-GUJ', + provider: 'dbl', + }, + ], + }); + expect(result.data.bibles).toHaveLength(1); + expect(result.data.bibles[0]).toEqual({ + id: 1, + name: 'Indian Revised Version Gujarati', + abbreviation: 'IRV-GUJ', + provider: 'dbl', + languageId: 10, + languageName: 'Gujarati', + languageCode: 'guj', + }); + } + }); +}); diff --git a/src/domains/bibles/bibles.repository.ts b/src/domains/bibles/bibles.repository.ts index 665b98a5..7b3385a4 100644 --- a/src/domains/bibles/bibles.repository.ts +++ b/src/domains/bibles/bibles.repository.ts @@ -1,14 +1,14 @@ -import { eq, sql } from 'drizzle-orm'; +import { eq, ilike, or, sql } from 'drizzle-orm'; import type { Result } from '@/lib/types'; import { db } from '@/db'; -import { bibles } from '@/db/schema'; +import { bibles, languages } from '@/db/schema'; import { handleConstraintError } from '@/lib/db-errors'; import { logger } from '@/lib/logger'; import { err, ErrorCode, ok } from '@/lib/types'; -import type { Bible, CreateBible, UpdateBible } from './bibles.types'; +import type { Bible, CreateBible, SourceSearchResponse, UpdateBible } from './bibles.types'; export async function getAll(): Promise> { try { @@ -74,6 +74,102 @@ export async function remove(id: number): Promise> { } } +export async function searchSourceBibles(query: string): Promise> { + try { + const cleanQuery = query.trim(); + const searchPattern = `%${cleanQuery}%`; + + const rows = await db + .select({ + bibleId: bibles.id, + bibleName: bibles.name, + bibleAbbreviation: bibles.abbreviation, + bibleProvider: bibles.provider, + languageId: languages.id, + langName: languages.langName, + langCodeIso6393: languages.langCodeIso6393, + }) + .from(bibles) + .innerJoin(languages, eq(bibles.languageId, languages.id)) + .where( + cleanQuery.length > 0 + ? or( + ilike(languages.langName, searchPattern), + ilike(languages.langCodeIso6393, searchPattern), + ilike(bibles.name, searchPattern), + ilike(bibles.abbreviation, searchPattern) + ) + : undefined + ) + .limit(100); + + const languageMap = new Map< + number, + { + id: number; + langName: string; + langCodeIso6393: string | null; + bibles: { id: number; name: string; abbreviation: string; provider: string }[]; + } + >(); + + const matchingBiblesList: { + id: number; + name: string; + abbreviation: string; + provider: string; + languageId: number; + languageName: string; + languageCode: string | null; + }[] = []; + + for (const row of rows) { + if (!languageMap.has(row.languageId)) { + languageMap.set(row.languageId, { + id: row.languageId, + langName: row.langName, + langCodeIso6393: row.langCodeIso6393, + bibles: [], + }); + } + + const langEntry = languageMap.get(row.languageId)!; + langEntry.bibles.push({ + id: row.bibleId, + name: row.bibleName, + abbreviation: row.bibleAbbreviation, + provider: row.bibleProvider, + }); + + matchingBiblesList.push({ + id: row.bibleId, + name: row.bibleName, + abbreviation: row.bibleAbbreviation, + provider: row.bibleProvider, + languageId: row.languageId, + languageName: row.langName, + languageCode: row.langCodeIso6393, + }); + } + + const languagesList = Array.from(languageMap.values()).map((lang) => ({ + id: lang.id, + langName: lang.langName, + langCodeIso6393: lang.langCodeIso6393, + bibleCount: lang.bibles.length, + bibles: lang.bibles, + })); + + return ok({ + languages: languagesList, + bibles: matchingBiblesList, + }); + } catch (error) { + logger.error({ cause: error, message: 'Failed to search source bibles', context: { query } }); + return err(ErrorCode.INTERNAL_ERROR); + } +} + // ─── DBL sync ────────────────────────────────────────────────────────────── const UPSERT_CHUNK_SIZE = 100; diff --git a/src/domains/bibles/bibles.route.ts b/src/domains/bibles/bibles.route.ts index 7cce0964..a1ba65a0 100644 --- a/src/domains/bibles/bibles.route.ts +++ b/src/domains/bibles/bibles.route.ts @@ -10,10 +10,51 @@ import { authenticateUser, requireSuperAdmin } from '@/middlewares/role-auth'; import { server } from '@/server/server'; import * as bibleService from './bibles.service'; -import { bibleResponseSchema } from './bibles.types'; +import { bibleResponseSchema, sourceSearchResponseSchema } from './bibles.types'; const idParam = z.object({ id: z.coerce.number().int().positive() }); +// ─── GET /bibles/search ──────────────────────────────────────────────────────── + +const searchBiblesRoute = createRoute({ + tags: ['Bibles'], + method: 'get', + path: '/bibles/search', + middleware: [authenticateUser] as const, + request: { + query: z.object({ + q: z.string().optional().default(''), + }), + }, + responses: { + [HttpStatusCodes.OK]: jsonContent( + sourceSearchResponseSchema.openapi('SourceBibleSearchResults'), + 'Search results containing languages and bibles' + ), + [HttpStatusCodes.UNAUTHORIZED]: jsonContent( + createMessageObjectSchema('Unauthorized'), + 'Authentication required' + ), + [HttpStatusCodes.FORBIDDEN]: jsonContent( + createMessageObjectSchema('Forbidden'), + 'User account is inactive' + ), + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: jsonContent( + createMessageObjectSchema(HttpStatusPhrases.INTERNAL_SERVER_ERROR), + 'Internal server error' + ), + }, + summary: 'Search source bibles and languages', + description: 'Search languages and bibles by name, code, or abbreviation', +}); + +server.openapi(searchBiblesRoute, async (c) => { + const { q } = c.req.valid('query'); + const result = await bibleService.searchSourceBibles(q); + if (result.ok) return c.json(result.data, HttpStatusCodes.OK); + return c.json({ message: result.error.message }, getHttpStatus(result.error) as never); +}); + // ─── GET /bibles ────────────────────────────────────────────────────────────── const listBiblesRoute = createRoute({ diff --git a/src/domains/bibles/bibles.service.ts b/src/domains/bibles/bibles.service.ts index 21fead5d..9756a399 100644 --- a/src/domains/bibles/bibles.service.ts +++ b/src/domains/bibles/bibles.service.ts @@ -12,11 +12,16 @@ function toBibleResponse(bible: Bible): BibleResponse { name: bible.name, abbreviation: bible.abbreviation, languageId: bible.languageId, + provider: bible.provider, }; } // ─── Service functions ──────────────────────────────────────────────────────── +export async function searchSourceBibles(query: string) { + return repo.searchSourceBibles(query); +} + export async function getAllBibles() { const result = await repo.getAll(); if (!result.ok) return result; diff --git a/src/domains/bibles/bibles.types.ts b/src/domains/bibles/bibles.types.ts index 9ffd792b..41fd9c59 100644 --- a/src/domains/bibles/bibles.types.ts +++ b/src/domains/bibles/bibles.types.ts @@ -13,6 +13,41 @@ export const bibleResponseSchema = z.object({ name: z.string(), abbreviation: z.string(), languageId: z.number().int(), + provider: z.string(), }); export type BibleResponse = z.infer; + +export const sourceSearchLanguageItemSchema = z.object({ + id: z.number().int(), + langName: z.string(), + langCodeIso6393: z.string().nullable(), + bibleCount: z.number().int(), + bibles: z.array( + z.object({ + id: z.number().int(), + name: z.string(), + abbreviation: z.string(), + provider: z.string(), + }) + ), +}); + +export const sourceSearchBibleItemSchema = z.object({ + id: z.number().int(), + name: z.string(), + abbreviation: z.string(), + provider: z.string(), + languageId: z.number().int(), + languageName: z.string(), + languageCode: z.string().nullable(), +}); + +export const sourceSearchResponseSchema = z.object({ + languages: z.array(sourceSearchLanguageItemSchema), + bibles: z.array(sourceSearchBibleItemSchema), +}); + +export type SourceSearchResponse = z.infer; +export type SourceSearchLanguageItem = z.infer; +export type SourceSearchBibleItem = z.infer; From 003df42d3acbf0a2c78d2ee3da4d70aa05adc13a Mon Sep 17 00:00:00 2001 From: joel-joseph-george Date: Wed, 9 Sep 2026 10:06:55 +0530 Subject: [PATCH 2/4] fix: sanitize search input for SQL injection and add results sorting to bibles repository --- src/domains/bibles/bibles.repository.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/domains/bibles/bibles.repository.ts b/src/domains/bibles/bibles.repository.ts index 7b3385a4..d034f68e 100644 --- a/src/domains/bibles/bibles.repository.ts +++ b/src/domains/bibles/bibles.repository.ts @@ -77,7 +77,8 @@ export async function remove(id: number): Promise> { export async function searchSourceBibles(query: string): Promise> { try { const cleanQuery = query.trim(); - const searchPattern = `%${cleanQuery}%`; + const escapedQuery = cleanQuery.replace(/[\\%_]/g, '\\$&'); + const searchPattern = `%${escapedQuery}%`; const rows = await db .select({ @@ -94,13 +95,14 @@ export async function searchSourceBibles(query: string): Promise 0 ? or( - ilike(languages.langName, searchPattern), - ilike(languages.langCodeIso6393, searchPattern), - ilike(bibles.name, searchPattern), - ilike(bibles.abbreviation, searchPattern) - ) + ilike(languages.langName, searchPattern), + ilike(languages.langCodeIso6393, searchPattern), + ilike(bibles.name, searchPattern), + ilike(bibles.abbreviation, searchPattern) + ) : undefined ) + .orderBy(languages.langName, bibles.name) .limit(100); const languageMap = new Map< From bc75864a9758b9f4718947ee11f57fe1efe4f7f5 Mon Sep 17 00:00:00 2001 From: joel-joseph-george Date: Wed, 9 Sep 2026 10:10:29 +0530 Subject: [PATCH 3/4] style: fix indentation in bibles repository search query builder --- src/domains/bibles/bibles.repository.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/domains/bibles/bibles.repository.ts b/src/domains/bibles/bibles.repository.ts index d034f68e..07d90c40 100644 --- a/src/domains/bibles/bibles.repository.ts +++ b/src/domains/bibles/bibles.repository.ts @@ -95,11 +95,11 @@ export async function searchSourceBibles(query: string): Promise 0 ? or( - ilike(languages.langName, searchPattern), - ilike(languages.langCodeIso6393, searchPattern), - ilike(bibles.name, searchPattern), - ilike(bibles.abbreviation, searchPattern) - ) + ilike(languages.langName, searchPattern), + ilike(languages.langCodeIso6393, searchPattern), + ilike(bibles.name, searchPattern), + ilike(bibles.abbreviation, searchPattern) + ) : undefined ) .orderBy(languages.langName, bibles.name) From a82923081ca7f4f9888e64b2f7056d1f03b58dca Mon Sep 17 00:00:00 2001 From: joel-joseph-george Date: Wed, 9 Sep 2026 10:17:39 +0530 Subject: [PATCH 4/4] fix(tests): correct query builder order in searchSourceBibles test --- src/domains/bibles/bibles.repository.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/domains/bibles/bibles.repository.test.ts b/src/domains/bibles/bibles.repository.test.ts index 56443498..a4ff1d5d 100644 --- a/src/domains/bibles/bibles.repository.test.ts +++ b/src/domains/bibles/bibles.repository.test.ts @@ -28,7 +28,8 @@ describe('searchSourceBibles', () => { ]; const limitFn = vi.fn().mockResolvedValue(fakeRows); - const whereFn = vi.fn().mockReturnValue({ limit: limitFn }); + const orderByFn = vi.fn().mockReturnValue({ limit: limitFn }); + const whereFn = vi.fn().mockReturnValue({ orderBy: orderByFn }); const innerJoinFn = vi.fn().mockReturnValue({ where: whereFn }); const fromFn = vi.fn().mockReturnValue({ innerJoin: innerJoinFn }); mockDb.select.mockReturnValue({ from: fromFn });