feat(search): global search service with library/marketplace + cards

This commit is contained in:
2026-05-21 06:55:35 +02:00
parent aab1b4fdc2
commit b1e5d5f276
2 changed files with 223 additions and 0 deletions

View File

@@ -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<typeof makeTestDb>;
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);
});
});

View File

@@ -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<SearchResult> {
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<number | null, typeof allLessons>();
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<number>();
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<number>`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),
};
}