diff --git a/apps/web/app/api/search/route.ts b/apps/web/app/api/search/route.ts index a9e12ed..3d0034d 100644 --- a/apps/web/app/api/search/route.ts +++ b/apps/web/app/api/search/route.ts @@ -3,48 +3,59 @@ import { withAuth } from '@/lib/auth'; import { createPocketBaseClient } from '@/lib/pocketbase'; // GET /api/search — Cross-entity full-text search +// +// Implementation note: the underlying data layer (`lib/database.ts`) uses a +// JavaScript filter parser that only supports `=, !=, <=, >=, <, >` — it does +// NOT understand PocketBase's `~` (contains) or `||` (or) operators. To make +// search actually return results we fetch each collection's full list and +// filter in-process with a case-insensitive substring match on the searchable +// fields. This is fine at the current data scale and avoids the silent +// zero-result bug. export const GET = withAuth(async (request: NextRequest, _user) => { const { searchParams } = new URL(request.url); - const query = searchParams.get('q') || ''; - const types = searchParams.get('types')?.split(',') || ['tasks', 'habits', 'projects', 'notes', 'reports']; - const limit = parseInt(searchParams.get('limit') || '10'); + const query = (searchParams.get('q') || '').trim(); + const types = ( + searchParams.get('types')?.split(',') || ['tasks', 'habits', 'projects', 'notes', 'reports'] + ).filter((t) => + ['tasks', 'habits', 'projects', 'notes', 'reports'].includes(t) + ); + const limit = Math.max(1, Math.min(50, parseInt(searchParams.get('limit') || '10'))); - if (!query.trim()) { + if (!query) { return NextResponse.json({ results: [] }); } - // Escape double quotes in query to prevent filter injection - const safeQuery = query.replace(/"/g, '\\"'); - + const needle = query.toLowerCase(); const pb = createPocketBaseClient(); const results: Array<{ type: string; items: unknown[] }> = []; + type Searchable = Record & { id: string }; + const matches = (record: Searchable, fields: string[]): boolean => { + for (const f of fields) { + const value = record[f]; + if (typeof value === 'string' && value.toLowerCase().includes(needle)) { + return true; + } + } + return false; + }; + + const searchableFields: Record = { + tasks: ['title', 'description'], + habits: ['name', 'description'], + projects: ['name', 'description'], + notes: ['title', 'content'], + reports: ['title', 'content'], + }; + for (const type of types) { try { - let filter = ''; - - switch (type) { - case 'tasks': - filter = `title ~ "${safeQuery}" || description ~ "${safeQuery}"`; - break; - case 'habits': - filter = `name ~ "${safeQuery}" || description ~ "${safeQuery}"`; - break; - case 'projects': - filter = `name ~ "${safeQuery}" || description ~ "${safeQuery}"`; - break; - case 'notes': - filter = `title ~ "${safeQuery}" || content ~ "${safeQuery}"`; - break; - case 'reports': - filter = `title ~ "${safeQuery}" || content ~ "${safeQuery}"`; - break; - default: - continue; - } - - const items = await pb.collection(type).getList(1, limit, { filter }); - results.push({ type, items: items.items }); + const items = (await pb.collection(type).getFullList()) as Searchable[]; + const filtered = items + .filter((record) => matches(record, searchableFields[type] || [])) + .slice(0, limit) + .map((record) => ({ id: record.id, title: getTitle(record, type) })); + results.push({ type, items: filtered }); } catch { // Skip collections that fail (e.g. missing or inaccessible) } @@ -52,3 +63,9 @@ export const GET = withAuth(async (request: NextRequest, _user) => { return NextResponse.json({ results }); }); + +function getTitle(record: Record, type: string): string { + const title = record.title ?? record.name; + if (typeof title === 'string' && title.length > 0) return title; + return `Untitled ${type.slice(0, -1)}`; +}