diff --git a/packages/backend/src/services/search.test.ts b/packages/backend/src/services/search.test.ts new file mode 100644 index 0000000..729ed69 --- /dev/null +++ b/packages/backend/src/services/search.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { makeTestDb, createUserDirect, createLessonOwnedBy } from '../tests/dbHelper.js'; +import { createCard } from './cards.js'; +import { searchAll } from './search.js'; + +let env: ReturnType; +beforeEach(() => { env = makeTestDb(); }); + +describe('search', () => { + it('returns empty for short queries', async () => { + const u = await createUserDirect(env.db, { email: 'u@example.com' }); + const r = await searchAll(env.db, u.id, '', 30); + expect(r.lessons).toHaveLength(0); + expect(r.cards).toHaveLength(0); + }); + + it('finds own lessons by name', async () => { + const u = await createUserDirect(env.db, { email: 'u@example.com', displayName: 'Me' }); + await createLessonOwnedBy(env.db, u.id, { name: 'Spaans basis' }); + const r = await searchAll(env.db, u.id, 'spaans', 30); + expect(r.lessons).toHaveLength(1); + expect(r.lessons[0]!.location).toBe('library'); + }); + + it('hides private lessons of other users', async () => { + const o = await createUserDirect(env.db, { email: 'o@example.com' }); + const u = await createUserDirect(env.db, { email: 'u@example.com' }); + await createLessonOwnedBy(env.db, o.id, { name: 'Geheim', visibility: 'private' }); + const r = await searchAll(env.db, u.id, 'geheim', 30); + expect(r.lessons).toHaveLength(0); + }); + + it('marks shared lessons from others as marketplace', async () => { + const o = await createUserDirect(env.db, { email: 'o@example.com', displayName: 'Owner' }); + const u = await createUserDirect(env.db, { email: 'u@example.com' }); + await createLessonOwnedBy(env.db, o.id, { name: 'Public', visibility: 'shared' }); + const r = await searchAll(env.db, u.id, 'public', 30); + expect(r.lessons).toHaveLength(1); + expect(r.lessons[0]!.location).toBe('marketplace'); + expect(r.lessons[0]!.ownerDisplayName).toBe('Owner'); + }); + + it('finds cards in readable lessons only', async () => { + const o = await createUserDirect(env.db, { email: 'o@example.com' }); + const u = await createUserDirect(env.db, { email: 'u@example.com' }); + const own = await createLessonOwnedBy(env.db, u.id, { name: 'Mine' }); + const other = await createLessonOwnedBy(env.db, o.id, { name: 'Hers', visibility: 'private' }); + await createCard(env.db, u.id, own.id, { question: 'hola', answer: 'hello' }); + await createCard(env.db, o.id, other.id, { question: 'hola', answer: 'hello' }); + const r = await searchAll(env.db, u.id, 'hola', 30); + expect(r.cards).toHaveLength(1); + expect(r.cards[0]!.lessonId).toBe(own.id); + }); + + it('search is case-insensitive', async () => { + const u = await createUserDirect(env.db, { email: 'u@example.com' }); + await createLessonOwnedBy(env.db, u.id, { name: 'Frans' }); + const r = await searchAll(env.db, u.id, 'FRA', 30); + expect(r.lessons).toHaveLength(1); + }); + + it('respects limit on each group', async () => { + const u = await createUserDirect(env.db, { email: 'u@example.com' }); + for (let i = 0; i < 5; i++) { + await createLessonOwnedBy(env.db, u.id, { name: `lesson${i}` }); + } + const r = await searchAll(env.db, u.id, 'lesson', 2); + expect(r.lessons).toHaveLength(2); + }); +}); diff --git a/packages/backend/src/services/search.ts b/packages/backend/src/services/search.ts new file mode 100644 index 0000000..6a45481 --- /dev/null +++ b/packages/backend/src/services/search.ts @@ -0,0 +1,153 @@ +import { and, eq, inArray, or, sql } from 'drizzle-orm'; +import type { Db } from '../db/client.js'; +import { cards, lessons, lessonSubscriptions, users } from '../db/schema.js'; + +export interface SearchLessonResult { + id: number; + name: string; + ownerDisplayName: string; + location: 'library' | 'marketplace'; + totalCards: number; + isCurated: boolean; +} + +export interface SearchCardResult { + id: number; + lessonId: number; + lessonName: string; + question: string; + snippet: string; +} + +export interface SearchResult { + lessons: SearchLessonResult[]; + cards: SearchCardResult[]; +} + +const MIN_QUERY_LEN = 2; +const SNIPPET_LEN = 80; + +function snippet(text: string, term: string): string { + const lower = text.toLowerCase(); + const idx = lower.indexOf(term.toLowerCase()); + if (idx < 0) return text.slice(0, SNIPPET_LEN); + const start = Math.max(0, idx - 20); + const end = Math.min(text.length, start + SNIPPET_LEN); + return (start > 0 ? '…' : '') + text.slice(start, end) + (end < text.length ? '…' : ''); +} + +export async function searchAll( + db: Db, userId: number, q: string, limit: number +): Promise { + const term = q.trim(); + if (term.length < MIN_QUERY_LEN) return { lessons: [], cards: [] }; + + const lower = term.toLowerCase(); + const pattern = `%${lower}%`; + + const ownerLessons = db.select().from(lessons).where(eq(lessons.ownerId, userId)).all(); + const subRoots = db.select({ id: lessonSubscriptions.lessonId }).from(lessonSubscriptions) + .where(eq(lessonSubscriptions.userId, userId)).all(); + + const allLessons = db.select().from(lessons).all(); + const byParent = new Map(); + for (const l of allLessons) { + const k = l.parentId ?? null; + if (!byParent.has(k)) byParent.set(k, []); + byParent.get(k)!.push(l); + } + const readableIds = new Set(); + for (const l of ownerLessons) readableIds.add(l.id); + const stack: number[] = []; + for (const sr of subRoots) stack.push(sr.id); + for (const l of allLessons) if (l.visibility === 'shared' && l.isCurated) stack.push(l.id); + while (stack.length) { + const cur = stack.pop()!; + if (readableIds.has(cur)) continue; + readableIds.add(cur); + for (const c of byParent.get(cur) ?? []) stack.push(c.id); + } + + const matchesLesson = (l: typeof allLessons[number]) => + l.name.toLowerCase().includes(lower) || (l.description ?? '').toLowerCase().includes(lower); + + const libraryMatches = allLessons.filter((l) => readableIds.has(l.id) && matchesLesson(l)); + + const sharedIds = new Set(allLessons.filter((l) => l.visibility === 'shared').map((l) => l.id)); + const marketplaceMatches = allLessons.filter((l) => + l.visibility === 'shared' + && l.ownerId !== userId + && !readableIds.has(l.id) + && (l.parentId === null || !sharedIds.has(l.parentId)) + && matchesLesson(l) + ); + + const ownerIds = Array.from(new Set([...libraryMatches, ...marketplaceMatches] + .map((l) => l.ownerId).filter((id): id is number => id !== null && id !== undefined))); + const ownerRows = ownerIds.length === 0 ? [] : + db.select({ id: users.id, displayName: users.displayName }).from(users) + .where(inArray(users.id, ownerIds)).all(); + const ownerMap = new Map(ownerRows.map((u) => [u.id, u.displayName])); + + const matchingLessonIds = [...libraryMatches, ...marketplaceMatches].map((l) => l.id); + const cardCounts = matchingLessonIds.length === 0 ? [] : + db.select({ lessonId: cards.lessonId, c: sql`count(*)`.as('c') }).from(cards) + .where(inArray(cards.lessonId, matchingLessonIds)) + .groupBy(cards.lessonId).all(); + const cardCountByLesson = new Map(cardCounts.map((r) => [r.lessonId, Number(r.c)])); + + const lessonResults: SearchLessonResult[] = [ + ...libraryMatches.map((l): SearchLessonResult => ({ + id: l.id, name: l.name, + ownerDisplayName: ownerMap.get(l.ownerId ?? -1) ?? '—', + location: 'library', + totalCards: cardCountByLesson.get(l.id) ?? 0, + isCurated: l.isCurated, + })), + ...marketplaceMatches.map((l): SearchLessonResult => ({ + id: l.id, name: l.name, + ownerDisplayName: ownerMap.get(l.ownerId ?? -1) ?? '—', + location: 'marketplace', + totalCards: cardCountByLesson.get(l.id) ?? 0, + isCurated: l.isCurated, + })), + ]; + lessonResults.sort((a, b) => + a.location === b.location ? a.name.localeCompare(b.name) : a.location === 'library' ? -1 : 1 + ); + + const readableIdsArr = Array.from(readableIds); + const cardMatches = readableIdsArr.length === 0 ? [] : + db.select({ + id: cards.id, lessonId: cards.lessonId, question: cards.question, + answer: cards.answer, hint: cards.hint, lessonName: lessons.name, + }).from(cards) + .innerJoin(lessons, eq(lessons.id, cards.lessonId)) + .where(and( + inArray(cards.lessonId, readableIdsArr), + or( + sql`lower(${cards.question}) like ${pattern}`, + sql`lower(${cards.answer}) like ${pattern}`, + sql`lower(${cards.hint}) like ${pattern}`, + )! + )) + .all(); + + const cardResults: SearchCardResult[] = cardMatches.map((c) => { + const matched = c.question.toLowerCase().includes(lower) + ? c.question + : c.answer.toLowerCase().includes(lower) + ? c.answer + : (c.hint ?? c.question); + return { + id: c.id, lessonId: c.lessonId, lessonName: c.lessonName, + question: c.question, snippet: snippet(matched, term), + }; + }); + cardResults.sort((a, b) => a.lessonName.localeCompare(b.lessonName) || a.question.localeCompare(b.question)); + + return { + lessons: lessonResults.slice(0, limit), + cards: cardResults.slice(0, limit), + }; +}