import { randomUUID } from "node:crypto"; import { NextResponse } from "next/server"; import type { Json } from "@/types/supabase"; import { api } from "@/lib/convex/api"; import { isConvexEnabled } from "@/lib/convex/enabled"; import { getAuthedConvexClient } from "@/lib/convex/route"; import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content"; import { assertDocumentId, assertTitle, buildDocumentBridgeContext, buildDocumentCommandEnvelope, documentBridgeErrorResponse, } from "@/lib/documents/bridge"; import { recordBridgeCommandFailureArtifacts, } from "@/lib/documents/bridge-log"; import { copyMindmapFilesIfExists, ensureDocumentScaffold, } from "@/lib/documents/page-lifecycle-side-effects"; import { buildDocumentSavePayload } from "@/lib/documents/save-contract"; import { loadSidebarDataFromConvex } from "@/lib/server/sidebar-data"; import { executeRustBridgeMutationTransport, recordRustBridgeCommandArtifacts, resolveRustBridgeCommandPlan, } from "@/lib/documents/rust-runtime"; type TreeCommandAction = | "create" | "rename" | "move" | "archive" | "restore" | "purge" | "embed" | "copy"; type TreeCopyItem = { documentId: string; recursive: boolean; }; type TreeCommandPayload = { action?: TreeCommandAction; workspaceId?: string | null; documentId?: string | null; parentId?: string | null; targetParentId?: string | null; title?: string | null; accessScope?: "private" | "shared" | "public" | null; content?: unknown; sortOrder?: number | null; sourceId?: string | null; targetId?: string | null; items?: TreeCopyItem[] | null; }; type TreeDeltaDocument = { id: string; workspace_id: string; title: string | null; parent_id: string | null; sort_order: number | null; is_starred: boolean | null; access_scope: "private" | "shared" | "public"; is_template: boolean; created_at: string; updated_at: string | null; }; type TreeMutationResult = { context: Awaited>; envelope: ReturnType; plan: Awaited>; result: TResult; }; function trimOrNull(value: unknown) { if (typeof value !== "string") return null; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } function normalizeTitle(value: unknown) { const title = typeof value === "string" ? value.trim() : ""; return title || "无标题"; } function normalizeSortOrder(value: unknown) { return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0; } function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } async function recordTreeCommandSuccess(args: { context: Awaited>; envelope: ReturnType; client: Awaited>["client"]; plan: Awaited>; result: unknown; }) { try { await recordRustBridgeCommandArtifacts({ context: args.context, envelope: args.envelope, client: args.client, plan: args.plan, result: args.result, }); } catch (error) { console.warn("[tree.commands] Rust bridge success artifacts skipped:", error); } } async function loadTreeCommandSidebarSnapshot(args: { client: Awaited>["client"]; auth: Awaited>["auth"]; workspaceId: string | null; }) { if (!args.workspaceId) { return null; } try { const result = await loadSidebarDataFromConvex({ client: args.client, auth: { userId: args.auth.userId, email: args.auth.email, name: args.auth.name, }, fallbackName: args.auth.email ?? args.auth.name ?? "我的空间", requestedWorkspaceId: args.workspaceId, }); return result.sidebarInitialData ?? null; } catch (error) { console.warn("[tree.commands] sidebar snapshot skipped:", error); return null; } } function buildTreeMovePreflightDataFromSidebarSnapshot( sidebarSnapshot: unknown, ): Record | null { if (!isRecord(sidebarSnapshot) || !Array.isArray(sidebarSnapshot.documents)) { return null; } return { documents: sidebarSnapshot.documents, }; } async function resolveTreeMutationResult(args: { request: Request; workspaceId: string | null; commandName: string; payload: unknown; preflightData?: Record | null; pageId?: string | null; client: Awaited>["client"]; }) { const context = await buildDocumentBridgeContext({ request: args.request, workspaceId: args.workspaceId, }); const envelope = buildDocumentCommandEnvelope({ name: args.commandName, payload: args.payload, context, preflightData: args.preflightData ?? null, target: { workspaceId: args.workspaceId, pageId: args.pageId ?? undefined, }, reason: `tree-route ${args.commandName}`, refs: ["next-tree-route"], }); const plan = await resolveRustBridgeCommandPlan({ context, envelope, }); try { const result = await executeRustBridgeMutationTransport({ client: args.client, plan, }); return { context, envelope, plan, result, }; } catch (error) { await recordBridgeCommandFailureArtifacts({ context, envelope, client: args.client, error, }); throw error; } } export async function POST(request: Request) { if (!isConvexEnabled()) { return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 }); } try { const payload = (await request.json()) as TreeCommandPayload; switch (payload.action) { case "create": return await handleCreate(request, payload); case "move": return await handleMove(request, payload); case "rename": return await handleRename(request, payload); case "archive": return await handleArchive(request, payload); case "restore": return await handleRestore(request, payload); case "purge": return await handlePurge(request, payload); case "embed": return await handleEmbed(request, payload); case "copy": return await handleCopy(request, payload); default: return NextResponse.json({ error: "不支持的 tree action" }, { status: 400 }); } } catch (error) { return documentBridgeErrorResponse(error); } } async function handleCreate(request: Request, payload: TreeCommandPayload) { const { auth, client } = await getAuthedConvexClient(); const parentId = trimOrNull(payload.parentId); const workspaceBootstrap = await client.mutation(api.workspaces.ensureDefaultWorkspace, { fallbackName: auth.email ?? auth.name ?? "我的空间", workspaceIdIfCreate: randomUUID(), }); let workspaceId: string | null = trimOrNull(payload.workspaceId); let accessScope: "private" | "shared" | "public" = "private"; if (parentId) { const parentDoc = await client.query(api.documents.getMeta, { id: parentId }); if (!parentDoc) { return NextResponse.json({ error: "父页面不存在或无权限" }, { status: 404 }); } workspaceId = trimOrNull(parentDoc.workspace_id); accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope; } else if (!workspaceId) { workspaceId = trimOrNull(workspaceBootstrap.activeWorkspaceId); } if (!workspaceId) { return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 }); } const documentId = trimOrNull(payload.documentId) ?? randomUUID(); const title = normalizeTitle(payload.title); const mutation = await resolveTreeMutationResult<{ id: string; document?: TreeDeltaDocument | null; title: string | null; parent_id: string | null; sort_order: number | null; workspace_id: string; access_scope: "private" | "shared" | "public"; is_template: boolean; created_at: string; updated_at: string; }>({ request, workspaceId, commandName: "tree.node.create", payload: { documentId, workspaceId, parentId, title, accessScope: trimOrNull(payload.accessScope) ?? accessScope, content: Array.isArray(payload.content) ? payload.content : [], }, pageId: documentId, client, }); const { context, envelope, result } = mutation; await ensureDocumentScaffold(result.id, result.title ?? title); await recordTreeCommandSuccess({ context, envelope, client, plan: mutation.plan, result, }); return NextResponse.json({ requestId: context.requestId, traceId: context.traceId, result: { action: "create", workspaceId, documentId: result.id, parentId: result.parent_id ?? parentId, title: result.title ?? title, sortOrder: result.sort_order ?? null, updatedAt: result.updated_at ?? null, execution: result, }, }); } async function handleMove(request: Request, payload: TreeCommandPayload) { const { auth, client } = await getAuthedConvexClient(); const documentId = assertDocumentId(payload.documentId ?? null); const sourceDoc = await client.query(api.documents.getMeta, { id: documentId }); if (!sourceDoc) { return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 }); } const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id); const parentId = trimOrNull(payload.parentId); const sortOrder = normalizeSortOrder(payload.sortOrder); const sidebarSnapshot = await loadTreeCommandSidebarSnapshot({ client, auth, workspaceId, }); const movePreflightData = buildTreeMovePreflightDataFromSidebarSnapshot(sidebarSnapshot); const mutation = await resolveTreeMutationResult<{ ok?: boolean; parent_id?: string | null; sort_order?: number | null; workspace_id?: string | null; updated_at?: string | null; }>({ request, workspaceId, commandName: "tree.subtree.move", payload: { documentId, parentId, sortOrder, }, preflightData: movePreflightData, pageId: documentId, client, }); const { context, envelope, result } = mutation; await recordTreeCommandSuccess({ context, envelope, client, plan: mutation.plan, result, }); return NextResponse.json({ requestId: context.requestId, traceId: context.traceId, result: { action: "move", workspaceId: trimOrNull(result?.workspace_id) ?? workspaceId, documentId, parentId: trimOrNull(result?.parent_id) ?? parentId, sortOrder: typeof result?.sort_order === "number" && Number.isFinite(result.sort_order) ? result.sort_order : sortOrder, updatedAt: trimOrNull(result?.updated_at) ?? null, execution: { ...(result ?? null), parent_id: trimOrNull(result?.parent_id) ?? parentId, sort_order: typeof result?.sort_order === "number" && Number.isFinite(result.sort_order) ? result.sort_order : sortOrder, workspace_id: trimOrNull(result?.workspace_id) ?? workspaceId, }, }, }); } async function handleRename(request: Request, payload: TreeCommandPayload) { const { client } = await getAuthedConvexClient(); const documentId = assertDocumentId(payload.documentId ?? null); const sourceDoc = await client.query(api.documents.getMeta, { id: documentId }); if (!sourceDoc) { return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 }); } const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id); const title = assertTitle(payload.title ?? null); const mutation = await resolveTreeMutationResult<{ ok?: boolean; document?: TreeDeltaDocument | null; updated_at?: string | null; }>({ request, workspaceId, commandName: "tree.node.rename", payload: { documentId, workspaceId, title, }, pageId: documentId, client, }); const { context, envelope, result } = mutation; await recordTreeCommandSuccess({ context, envelope, client, plan: mutation.plan, result, }); return NextResponse.json({ requestId: context.requestId, traceId: context.traceId, result: { action: "rename", workspaceId, documentId, title, updatedAt: result?.updated_at ?? null, execution: result ?? null, }, }); } async function handleArchive(request: Request, payload: TreeCommandPayload) { const { client } = await getAuthedConvexClient(); const documentId = assertDocumentId(payload.documentId ?? null); const sourceDoc = await client.query(api.documents.getMeta, { id: documentId }); if (!sourceDoc) { return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 }); } const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id); const mutation = await resolveTreeMutationResult<{ ok?: boolean; document?: TreeDeltaDocument | null; updated_at?: string | null; }>({ request, workspaceId, commandName: "tree.node.archive", payload: { documentId, workspaceId, }, pageId: documentId, client, }); const { context, envelope, result } = mutation; await recordTreeCommandSuccess({ context, envelope, client, plan: mutation.plan, result, }); return NextResponse.json({ requestId: context.requestId, traceId: context.traceId, result: { action: "archive", workspaceId, documentId, updatedAt: result?.updated_at ?? null, execution: result ?? null, }, }); } async function handleRestore(request: Request, payload: TreeCommandPayload) { const { client } = await getAuthedConvexClient(); const documentId = assertDocumentId(payload.documentId ?? null); const sourceDoc = await client.query(api.documents.getMeta, { id: documentId }); if (!sourceDoc) { return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 }); } const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id); const mutation = await resolveTreeMutationResult<{ ok?: boolean; document?: TreeDeltaDocument | null; updated_at?: string | null; }>({ request, workspaceId, commandName: "tree.node.restore", payload: { documentId, workspaceId, }, pageId: documentId, client, }); const { context, envelope, result } = mutation; await recordTreeCommandSuccess({ context, envelope, client, plan: mutation.plan, result, }); return NextResponse.json({ requestId: context.requestId, traceId: context.traceId, result: { action: "restore", workspaceId, documentId, updatedAt: result?.updated_at ?? null, execution: result ?? null, }, }); } async function handlePurge(request: Request, payload: TreeCommandPayload) { const { client } = await getAuthedConvexClient(); const documentId = assertDocumentId(payload.documentId ?? null); const sourceDoc = await client.query(api.documents.getMeta, { id: documentId }); if (!sourceDoc) { return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 }); } const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id); const mutation = await resolveTreeMutationResult<{ ok?: boolean; purged?: boolean; purged_at?: string | null; }>({ request, workspaceId, commandName: "tree.node.purge", payload: { documentId, }, pageId: documentId, client, }); const { context, envelope, result } = mutation; await recordTreeCommandSuccess({ context, envelope, client, plan: mutation.plan, result, }); return NextResponse.json({ requestId: context.requestId, traceId: context.traceId, result: { action: "purge", workspaceId, documentId, purged: result?.purged ?? true, updatedAt: result?.purged_at ?? null, execution: result ?? null, }, }); } async function handleEmbed(request: Request, payload: TreeCommandPayload) { const { client } = await getAuthedConvexClient(); const sourceId = assertDocumentId(payload.sourceId ?? null); const targetId = assertDocumentId(payload.targetId ?? null); const sourceDoc = await client.query(api.documents.getMeta, { id: sourceId }); if (!sourceDoc) { return NextResponse.json({ error: "原始页面不存在或无权限" }, { status: 404 }); } const targetContent = await client.query(api.documents.getContent, { id: targetId }); if (!targetContent) { return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 }); } const targetMeta = await client.query(api.documents.getMeta, { id: targetId }); const currentBlocks = extractBlocksFromContent(targetContent.content); const anchorId = trimOrNull( (targetMeta as { embed_default_block_id?: string | null } | null)?.embed_default_block_id, ); const anchorIndex = anchorId ? currentBlocks.findIndex( (block) => typeof block === "object" && block !== null && String((block as { id?: string }).id ?? "") === anchorId, ) : -1; const insertIndex = anchorIndex >= 0 ? anchorIndex + 1 : currentBlocks.length; const nextBlocks: Json[] = [ ...currentBlocks.slice(0, insertIndex), { id: randomUUID(), type: "pageReference", props: { pageId: sourceId, title: sourceDoc.title ?? "无标题", }, }, ...currentBlocks.slice(insertIndex), ]; const nextContent = composeContentWithBlocks(targetContent.content, nextBlocks); const workspaceId = trimOrNull(sourceDoc.workspace_id) ?? trimOrNull((targetMeta as { workspace_id?: string | null } | null)?.workspace_id); const mutation = await resolveTreeMutationResult<{ revision?: number | null; conflict_detection_key?: string | null; }>({ request, workspaceId, commandName: "tree.node.embed", payload: { ...buildDocumentSavePayload({ documentId: targetId, workspaceId, revision: typeof targetContent.revision === "number" && Number.isInteger(targetContent.revision) ? targetContent.revision : null, content: nextContent, conflictDetectionKey: typeof targetContent.conflict_detection_key === "string" ? targetContent.conflict_detection_key : null, blockCount: nextBlocks.length, }), sourceDocumentId: sourceId, targetDocumentId: targetId, anchorBlockId: anchorId, }, pageId: targetId, client, }); const { context, envelope, result } = mutation; await recordTreeCommandSuccess({ context, envelope, client, plan: mutation.plan, result, }); return NextResponse.json({ requestId: context.requestId, traceId: context.traceId, result: { action: "embed", workspaceId, documentId: targetId, sourceDocumentId: sourceId, targetDocumentId: targetId, execution: result ?? null, }, }); } async function handleCopy(request: Request, payload: TreeCommandPayload) { const { client } = await getAuthedConvexClient(); const normalizedItems = (payload.items ?? []) .filter((item) => item?.documentId) .map((item) => ({ documentId: assertDocumentId(item.documentId), recursive: Boolean(item.recursive), })); if (normalizedItems.length === 0) { return NextResponse.json({ error: "items 为空" }, { status: 400 }); } const targetParentId = trimOrNull(payload.targetParentId); let workspaceId: string | null = null; if (targetParentId) { const targetDoc = await client.query(api.documents.getMeta, { id: targetParentId }); if (!targetDoc) { return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 }); } workspaceId = trimOrNull(targetDoc.workspace_id); } else { const firstDoc = await client.query(api.documents.getMeta, { id: normalizedItems[0]?.documentId ?? "", }); if (!firstDoc) { return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 }); } workspaceId = trimOrNull(firstDoc.workspace_id); } if (!workspaceId) { return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 }); } const mutation = await resolveTreeMutationResult<{ items: Array<{ oldId: string; newId: string; title?: string | null; document?: TreeDeltaDocument | null; }>; }>({ request, workspaceId, commandName: "tree.subtree.copy", payload: { workspaceId, targetParentId, items: normalizedItems, }, pageId: targetParentId, client, }); const { context, envelope, result } = mutation; await Promise.all( (result.items ?? []).map(async (item) => { await ensureDocumentScaffold(item.newId, item.title ?? "无标题"); await copyMindmapFilesIfExists(item.oldId, item.newId); }), ); await recordTreeCommandSuccess({ context, envelope, client, plan: mutation.plan, result, }); return NextResponse.json({ requestId: context.requestId, traceId: context.traceId, result: { action: "copy", workspaceId, targetParentId, items: (result.items ?? []).map((item) => ({ oldId: item.oldId, newId: item.newId, })), execution: result ?? null, }, }); } export const runtime = "nodejs";