import { NextResponse } from "next/server"; import { createSupabaseRouteClient } from "@/lib/supabase/server"; import type { DocumentSearchFilters, DocumentSearchRequest, DocumentSearchResponse, DocumentSearchResult, DocumentSearchTimeRange, } from "@/types/search"; import { buildSnippet } from "@/lib/search/snippet"; const MAX_LIMIT = 50; const DEFAULT_FILTERS: DocumentSearchFilters = { titleOnly: false, exact: false, onlyCurrentPage: false, includeOcr: false, timeRange: "any", timeField: "updated", }; const TIME_RANGE_TO_MS: Record = { any: null, "7d": 1000 * 60 * 60 * 24 * 7, "30d": 1000 * 60 * 60 * 24 * 30, }; const TIME_FIELD_COLUMN = { updated: "updated_at", created: "created_at", } as const; const escapeLike = (value: string): string => value.replace(/[%_\\]/g, (match) => `\\${match}`); interface DocumentRow { id: string; title: string | null; raw_text: string | null; updated_at: string | null; created_at: string | null; } const buildIsoBoundary = (value: string, isEnd = false): string | null => { if (!value) { return null; } const normalized = value.trim(); if (!normalized) { return null; } const suffix = isEnd ? "T23:59:59.999Z" : "T00:00:00.000Z"; const date = new Date(`${normalized}${suffix}`); if (Number.isNaN(date.getTime())) { return null; } return date.toISOString(); }; const mapDocumentToResult = ( row: DocumentRow, keyword: string | null, forcedMatch?: DocumentSearchResult["matchField"], ): DocumentSearchResult => { const normalizedKeyword = keyword?.trim() ?? ""; const normalizedTitle = row.title ?? "无标题"; let matchField: DocumentSearchResult["matchField"] = "recent"; if (forcedMatch) { matchField = forcedMatch; } else if (normalizedKeyword) { const matchesTitle = normalizedTitle.toLowerCase().includes(normalizedKeyword.toLowerCase()); matchField = matchesTitle ? "title" : "content"; } return { id: row.id, title: normalizedTitle, snippet: buildSnippet(row.raw_text, normalizedKeyword || null), updatedAt: row.updated_at, createdAt: row.created_at, matchField, hasOcr: Boolean(row.raw_text), publicPath: `/documents/${row.id}`, score: matchField === "title" ? 2 : 1, }; }; type RouteSupabaseClient = Awaited>; const fetchRecentResults = async ( supabase: RouteSupabaseClient, userId: string, workspaceId: string, ): Promise => { const { data: recentRows, error: recentError } = await supabase .from("user_recent_pages") .select("document_id,last_accessed_at") .eq("user_id", userId) .eq("workspace_id", workspaceId) .order("last_accessed_at", { ascending: false }) .limit(10); if (recentError || !recentRows || recentRows.length === 0) { return []; } const documentIds = recentRows.map((row) => row.document_id); const { data: docRows, error: docsError } = await supabase .from("documents") .select("id,title,updated_at,created_at,raw_text") .in("id", documentIds) .is("deleted_at", null); if (docsError || !docRows) { return []; } const docMap = new Map(docRows.map((row) => [row.id, row])); return recentRows .map((row) => docMap.get(row.document_id)) .filter((row): row is DocumentRow => Boolean(row)) .map((row) => mapDocumentToResult(row, null, "recent")); }; const fetchOcrMatches = async ( supabase: RouteSupabaseClient, workspaceId: string, likePattern: string, limit: number, ) => { const { data, error } = await supabase .from("media_assets") .select("document_id,ocr_text") .eq("workspace_id", workspaceId) .not("ocr_text", "is", null) .ilike("ocr_text", likePattern) .limit(limit); if (error || !data) { return []; } return data; }; export async function POST(request: Request) { const supabase = await createSupabaseRouteClient(); const { data: { session }, } = await supabase.auth.getSession(); if (!session) { return NextResponse.json({ error: "未登录" }, { status: 401 }); } const payload = (await request.json()) as DocumentSearchRequest; const workspaceId = payload.workspaceId; if (!workspaceId) { return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 }); } const filters: DocumentSearchFilters = { ...DEFAULT_FILTERS, ...payload.filters, }; const { data: membership, error: membershipError } = await supabase .from("workspace_members") .select("workspace_id") .eq("workspace_id", workspaceId) .eq("user_id", session.user.id) .limit(1); if (membershipError) { return NextResponse.json({ error: membershipError.message }, { status: 500 }); } if (!membership || membership.length === 0) { return NextResponse.json({ error: "无权访问该工作空间" }, { status: 403 }); } if (filters.onlyCurrentPage && !payload.documentId) { filters.onlyCurrentPage = false; } const limit = Math.min(payload.limit ?? 30, MAX_LIMIT); const normalizedQuery = payload.query?.trim() ?? ""; const likePattern = filters.exact ? normalizedQuery : `%${escapeLike(normalizedQuery)}%`; const timeColumn = TIME_FIELD_COLUMN[filters.timeField ?? "updated"]; let builder = supabase .from("documents") .select("id,title,updated_at,created_at,raw_text,is_starred,access_scope") .eq("workspace_id", workspaceId) .is("deleted_at", null) .order(timeColumn, { ascending: false }) .limit(limit); if (filters.onlyCurrentPage && payload.documentId) { builder = builder.eq("id", payload.documentId); } const customFrom = filters.customRange?.from ? buildIsoBoundary(filters.customRange.from, false) : null; const customTo = filters.customRange?.to ? buildIsoBoundary(filters.customRange.to, true) : null; if (customFrom) { builder = builder.gte(timeColumn, customFrom); } if (customTo) { builder = builder.lte(timeColumn, customTo); } if (!customFrom && !customTo) { const now = Date.now(); const offset = TIME_RANGE_TO_MS[filters.timeRange]; if (offset) { const from = new Date(now - offset).toISOString(); builder = builder.gte(timeColumn, from); } } if (normalizedQuery) { if (filters.titleOnly) { builder = filters.exact ? builder.eq("title", normalizedQuery) : builder.ilike("title", likePattern); } else { const clauses = [ `title.${filters.exact ? `eq.${normalizedQuery}` : `ilike.${likePattern}`}`, ]; if (filters.includeOcr) { clauses.push(`raw_text.ilike.${likePattern}`); } builder = builder.or(clauses.join(",")); } } const shouldSearchOcr = Boolean(filters.includeOcr && normalizedQuery); const [{ data, error }, recentResults, ocrMatches] = await Promise.all([ builder, fetchRecentResults(supabase, session.user.id, workspaceId), shouldSearchOcr ? fetchOcrMatches(supabase, workspaceId, likePattern, limit) : Promise.resolve([]), ]); if (error) { return NextResponse.json({ error: error.message }, { status: 500 }); } let results: DocumentSearchResult[] = (data ?? []).map((row) => mapDocumentToResult(row, normalizedQuery || null), ); if (ocrMatches.length > 0 && normalizedQuery) { const snippetMap = new Map(); ocrMatches.forEach((row: { document_id: string; ocr_text: string | null }) => { if (!row.ocr_text) return; if (!snippetMap.has(row.document_id)) { snippetMap.set(row.document_id, row.ocr_text); } }); if (snippetMap.size > 0) { const resultMap = new Map(results.map((item) => [item.id, item])); const missingDocIds: string[] = []; snippetMap.forEach((text, docId) => { const snippet = buildSnippet(text, normalizedQuery || null); if (resultMap.has(docId)) { const existing = resultMap.get(docId)!; resultMap.set(docId, { ...existing, snippet: snippet || existing.snippet, hasOcr: true, matchField: "content", }); } else { missingDocIds.push(docId); } }); let extraResults: DocumentSearchResult[] = []; if (missingDocIds.length > 0) { const { data: extraDocs } = await supabase .from("documents") .select("id,title,updated_at,created_at,raw_text") .in("id", missingDocIds) .is("deleted_at", null); extraResults = extraDocs?.map((row) => { const snippetText = snippetMap.get(row.id) ?? row.raw_text ?? ""; return { ...mapDocumentToResult(row, normalizedQuery || null, "content"), snippet: buildSnippet(snippetText, normalizedQuery || null), hasOcr: true, }; }) ?? []; } results = [...resultMap.values(), ...extraResults]; } } const response: DocumentSearchResponse = { results, recent: recentResults, }; return NextResponse.json(response); }