304 lines
8.9 KiB
TypeScript
304 lines
8.9 KiB
TypeScript
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<DocumentSearchTimeRange, number | null> = {
|
|
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<ReturnType<typeof createSupabaseRouteClient>>;
|
|
|
|
const fetchRecentResults = async (
|
|
supabase: RouteSupabaseClient,
|
|
userId: string,
|
|
workspaceId: string,
|
|
): Promise<DocumentSearchResult[]> => {
|
|
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<string, string>();
|
|
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);
|
|
}
|