diff --git a/src/domains/bibles/bibles.repository.test.ts b/src/domains/bibles/bibles.repository.test.ts new file mode 100644 index 00000000..a4ff1d5d --- /dev/null +++ b/src/domains/bibles/bibles.repository.test.ts @@ -0,0 +1,68 @@ +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 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 }); + + 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..07d90c40 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,104 @@ export async function remove(id: number): Promise> { } } +export async function searchSourceBibles(query: string): Promise> { + try { + const cleanQuery = query.trim(); + const escapedQuery = cleanQuery.replace(/[\\%_]/g, '\\$&'); + const searchPattern = `%${escapedQuery}%`; + + 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 + ) + .orderBy(languages.langName, bibles.name) + .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;