feat(search): global search service with library/marketplace + cards
This commit is contained in:
70
packages/backend/src/services/search.test.ts
Normal file
70
packages/backend/src/services/search.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
153
packages/backend/src/services/search.ts
Normal file
153
packages/backend/src/services/search.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user