feat: 完成 rust cutover phase 8 收口
This commit is contained in:
@@ -1,15 +1,19 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import {
|
||||
assertDocumentId,
|
||||
buildDocumentBridgeContext,
|
||||
buildDocumentQueryEnvelope,
|
||||
documentBridgeErrorResponse,
|
||||
} from "@/lib/documents/bridge";
|
||||
import {
|
||||
executeRustBridgeQueryTransport,
|
||||
resolveRustBridgeQueryPlan,
|
||||
} from "@/lib/documents/rust-runtime";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
@@ -23,9 +27,17 @@ export async function GET(request: Request) {
|
||||
name: "documents.content.get",
|
||||
payload: { documentId, workspaceId },
|
||||
});
|
||||
|
||||
const result = await client.query(api.documents.getContent, {
|
||||
id: documentId,
|
||||
const plan = await resolveRustBridgeQueryPlan({
|
||||
context: bridgeContext,
|
||||
envelope,
|
||||
});
|
||||
const result = await executeRustBridgeQueryTransport<{
|
||||
content?: unknown;
|
||||
revision?: number | null;
|
||||
conflict_detection_key?: string | null;
|
||||
} | null>({
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import path from "path";
|
||||
import { promises as fs } from "fs";
|
||||
import type { Json } from "@/types/supabase";
|
||||
import { makeUniqueTitle } from "@/lib/file-tree/naming";
|
||||
import { getDocumentsBaseDir } from "@/lib/server/local-paths";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import {
|
||||
assertDocumentId,
|
||||
buildDocumentBridgeContext,
|
||||
buildDocumentCommandEnvelope,
|
||||
documentBridgeErrorResponse,
|
||||
} from "@/lib/documents/bridge";
|
||||
import {
|
||||
executePageLifecycleBridgeCommand,
|
||||
type DocumentCopyTreePayload,
|
||||
} from "@/lib/documents/page-command-adapter";
|
||||
import {
|
||||
copyMindmapFilesIfExists,
|
||||
ensureDocumentScaffold,
|
||||
} from "@/lib/documents/page-lifecycle-side-effects";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -20,525 +29,98 @@ type CopyTreePayload = {
|
||||
targetParentId: string | null;
|
||||
};
|
||||
|
||||
type DocRow = {
|
||||
id: string;
|
||||
title: string | null;
|
||||
parent_id: string | null;
|
||||
workspace_id: string;
|
||||
access_scope: "private" | "shared" | "public" | null;
|
||||
sort_order: number | null;
|
||||
created_at: string | null;
|
||||
content: Json | null;
|
||||
};
|
||||
|
||||
type AssetRow = {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
document_id: string;
|
||||
asset_type: string | null;
|
||||
file_name: string | null;
|
||||
file_size: number | null;
|
||||
mime_type: string | null;
|
||||
file_url: string | null;
|
||||
thumbnail_url: string | null;
|
||||
bucket: string | null;
|
||||
storage_path: string | null;
|
||||
};
|
||||
|
||||
const documentsBaseDir = getDocumentsBaseDir();
|
||||
const DEFAULT_DOC_BUCKET = process.env.NEXT_PUBLIC_SUPABASE_DOC_BUCKET ?? "documents";
|
||||
|
||||
async function ensureDocumentScaffold(id: string, title: string | null) {
|
||||
const folder = path.join(documentsBaseDir, id);
|
||||
const indexFile = path.join(folder, "index.md");
|
||||
await fs.mkdir(folder, { recursive: true });
|
||||
try {
|
||||
await fs.access(indexFile);
|
||||
} catch {
|
||||
const safeTitle = title && title.trim() ? title.trim() : "无标题";
|
||||
const content = `# ${safeTitle}\n`;
|
||||
await fs.writeFile(indexFile, content, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
async function copyMindmapIfExists(sourceId: string, targetId: string) {
|
||||
const srcDir = path.join(documentsBaseDir, sourceId);
|
||||
const destDir = path.join(documentsBaseDir, targetId);
|
||||
try {
|
||||
const entries = await fs.readdir(srcDir);
|
||||
const mindmapFiles = entries.filter((name) => name === "mindmap.json" || /^mindmap-.+\\.json$/i.test(name));
|
||||
if (mindmapFiles.length === 0) return;
|
||||
await fs.mkdir(destDir, { recursive: true });
|
||||
await Promise.all(
|
||||
mindmapFiles.map(async (name) => {
|
||||
try {
|
||||
const buf = await fs.readFile(path.join(srcDir, name));
|
||||
await fs.writeFile(path.join(destDir, name), buf);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// 源不存在则忽略
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTitle(title: string | null): string {
|
||||
const safe = title?.trim();
|
||||
return safe && safe.length > 0 ? safe : "无标题";
|
||||
}
|
||||
|
||||
function parseStoragePath(fileUrl: string): { bucket: string; path: string } | null {
|
||||
try {
|
||||
const url = new URL(fileUrl);
|
||||
const segments = url.pathname.split("/").filter(Boolean);
|
||||
const objectIdx = segments.findIndex((seg) => seg === "object");
|
||||
if (objectIdx === -1 || objectIdx + 2 >= segments.length) return null;
|
||||
if (segments[objectIdx + 1] === "public") {
|
||||
const bucket = segments[objectIdx + 2];
|
||||
const p = segments.slice(objectIdx + 3).join("/");
|
||||
return p ? { bucket, path: p } : null;
|
||||
}
|
||||
if (segments[objectIdx + 1] === "sign") {
|
||||
const bucket = segments[objectIdx + 2];
|
||||
const p = segments.slice(objectIdx + 3).join("/");
|
||||
return p ? { bucket, path: p } : null;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getChildrenSorted(childrenByParent: Map<string | null, DocRow[]>, parentId: string | null): DocRow[] {
|
||||
const list = childrenByParent.get(parentId) ?? [];
|
||||
return [...list].sort((a, b) => {
|
||||
const orderA = a.sort_order ?? Number.MAX_SAFE_INTEGER;
|
||||
const orderB = b.sort_order ?? Number.MAX_SAFE_INTEGER;
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
const timeA = new Date(a.created_at ?? 0).getTime();
|
||||
const timeB = new Date(b.created_at ?? 0).getTime();
|
||||
return timeA - timeB;
|
||||
});
|
||||
}
|
||||
|
||||
function replaceAssetRefsInContent(content: Json | null, assetMap: Map<string, { id: string; signedUrl: string }>, newDocId: string): Json | null {
|
||||
if (!content) return content;
|
||||
|
||||
const transform = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(transform);
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return value;
|
||||
}
|
||||
|
||||
const obj = value as Record<string, unknown>;
|
||||
const next: Record<string, unknown> = {};
|
||||
Object.keys(obj).forEach((key) => {
|
||||
next[key] = transform(obj[key]);
|
||||
});
|
||||
|
||||
if (next.type === "media" && next.props && typeof next.props === "object") {
|
||||
const props = next.props as Record<string, unknown>;
|
||||
const oldId = typeof props.assetId === "string" ? props.assetId : null;
|
||||
if (oldId && assetMap.has(oldId)) {
|
||||
const mapped = assetMap.get(oldId)!;
|
||||
props.assetId = mapped.id;
|
||||
props.fileUrl = mapped.signedUrl;
|
||||
props.thumbnailUrl = mapped.signedUrl;
|
||||
props.documentId = newDocId;
|
||||
next.props = props;
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
};
|
||||
|
||||
return transform(content) as Json;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const { client } = await getAuthedConvexClient();
|
||||
try {
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const payload = (await request.json()) as CopyTreePayload;
|
||||
const normalizedItems = (payload.items ?? [])
|
||||
.filter((item) => item?.documentId)
|
||||
.map((item) => ({
|
||||
documentId: assertDocumentId(item.documentId),
|
||||
recursive: Boolean(item.recursive),
|
||||
}));
|
||||
|
||||
const payload = (await request.json()) as CopyTreePayload;
|
||||
if (!payload?.items?.length) {
|
||||
return NextResponse.json({ error: "缺少 items" }, { status: 400 });
|
||||
}
|
||||
|
||||
const normalizedItems = payload.items.filter((it) => it?.documentId);
|
||||
if (normalizedItems.length === 0) {
|
||||
return NextResponse.json({ error: "items 为空" }, { status: 400 });
|
||||
}
|
||||
|
||||
const targetParentId = payload.targetParentId ?? null;
|
||||
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 });
|
||||
if (normalizedItems.length === 0) {
|
||||
return NextResponse.json({ error: "items 为空" }, { status: 400 });
|
||||
}
|
||||
workspaceId = targetDoc.workspace_id;
|
||||
}
|
||||
|
||||
const sourceIds = Array.from(new Set(normalizedItems.map((it) => it.documentId)));
|
||||
const firstMeta = await client.query(api.documents.getMeta, { id: sourceIds[0] });
|
||||
if (!firstMeta) {
|
||||
return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
const normalizedTargetParentId = payload.targetParentId?.trim() || null;
|
||||
let workspaceId: string | null = null;
|
||||
|
||||
if (!workspaceId) {
|
||||
workspaceId = firstMeta.workspace_id;
|
||||
}
|
||||
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
||||
}
|
||||
|
||||
const wid = workspaceId;
|
||||
|
||||
const allDocs = await client.query(api.documents.listAllForCopy, {
|
||||
workspaceId: wid,
|
||||
});
|
||||
|
||||
const sourceById = new Map<string, DocRow>();
|
||||
(allDocs as unknown as DocRow[]).forEach((d) => sourceById.set(d.id, d));
|
||||
|
||||
const missing = sourceIds.find((id) => !sourceById.has(id));
|
||||
if (missing) {
|
||||
return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
|
||||
const childrenByParent = new Map<string | null, DocRow[]>();
|
||||
(allDocs as unknown as DocRow[]).forEach((doc) => {
|
||||
const list = childrenByParent.get(doc.parent_id) ?? [];
|
||||
list.push(doc);
|
||||
childrenByParent.set(doc.parent_id, list);
|
||||
});
|
||||
|
||||
const existingTitleSetByParent = new Map<string | null, Set<string>>();
|
||||
const seedTitleSet = (parent: string | null) => {
|
||||
if (existingTitleSetByParent.has(parent)) return;
|
||||
const titles = new Set<string>();
|
||||
(childrenByParent.get(parent) ?? []).forEach((d) => titles.add(normalizeTitle(d.title)));
|
||||
existingTitleSetByParent.set(parent, titles);
|
||||
};
|
||||
seedTitleSet(targetParentId);
|
||||
|
||||
const newIdByOldId = new Map<string, string>();
|
||||
const copyQueue: Array<{ old: DocRow; newParentId: string | null; parentKey: string | null }> = [];
|
||||
|
||||
const enqueueTree = (root: DocRow, newParent: string | null, recursive: boolean) => {
|
||||
const visit = (node: DocRow, parentNewId: string | null, parentKey: string | null) => {
|
||||
const newId =
|
||||
typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
newIdByOldId.set(node.id, newId);
|
||||
copyQueue.push({ old: node, newParentId: parentNewId, parentKey });
|
||||
if (!recursive) return;
|
||||
const children = getChildrenSorted(childrenByParent, node.id);
|
||||
children.forEach((child) => visit(child, newId, newId));
|
||||
};
|
||||
visit(root, newParent, newParent);
|
||||
};
|
||||
|
||||
normalizedItems.forEach((item) => {
|
||||
const doc = sourceById.get(item.documentId) ?? null;
|
||||
if (doc) {
|
||||
enqueueTree(doc, targetParentId, Boolean(item.recursive));
|
||||
if (normalizedTargetParentId) {
|
||||
const targetDoc = await client.query(api.documents.getMeta, { id: normalizedTargetParentId });
|
||||
if (!targetDoc) {
|
||||
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
workspaceId = targetDoc.workspace_id?.trim() || null;
|
||||
} else {
|
||||
const firstDoc = await client.query(api.documents.getMeta, { id: normalizedItems[0].documentId });
|
||||
if (!firstDoc) {
|
||||
return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
workspaceId = firstDoc.workspace_id?.trim() || null;
|
||||
}
|
||||
});
|
||||
|
||||
if (copyQueue.length === 0) {
|
||||
return NextResponse.json({ error: "没有可复制的页面" }, { status: 400 });
|
||||
}
|
||||
|
||||
const insertedDocs: Array<{ oldId: string; newId: string }> = [];
|
||||
|
||||
for (const item of copyQueue) {
|
||||
const newId = newIdByOldId.get(item.old.id)!;
|
||||
const parentId = item.newParentId;
|
||||
|
||||
if (!existingTitleSetByParent.has(parentId)) {
|
||||
seedTitleSet(parentId);
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
||||
}
|
||||
const titleSet = existingTitleSetByParent.get(parentId) ?? new Set<string>();
|
||||
existingTitleSetByParent.set(parentId, titleSet);
|
||||
|
||||
const newTitle = makeUniqueTitle(normalizeTitle(item.old.title), titleSet);
|
||||
titleSet.add(newTitle);
|
||||
|
||||
await client.mutation(api.documents.create, {
|
||||
id: newId,
|
||||
workspaceId: wid,
|
||||
parentId,
|
||||
accessScope: (item.old.access_scope ?? "private") as "private" | "shared" | "public",
|
||||
title: newTitle,
|
||||
content: item.old.content ?? [],
|
||||
const bridgeContext = await buildDocumentBridgeContext({
|
||||
request,
|
||||
workspaceId,
|
||||
});
|
||||
const result = await executePageLifecycleBridgeCommand<
|
||||
DocumentCopyTreePayload,
|
||||
{
|
||||
items: Array<{
|
||||
oldId: string;
|
||||
newId: string;
|
||||
title?: string | null;
|
||||
}>;
|
||||
}
|
||||
>({
|
||||
context: bridgeContext,
|
||||
envelope: buildDocumentCommandEnvelope({
|
||||
name: "documents.copy_tree",
|
||||
payload: {
|
||||
workspaceId,
|
||||
targetParentId: normalizedTargetParentId,
|
||||
items: normalizedItems,
|
||||
},
|
||||
context: bridgeContext,
|
||||
target: {
|
||||
workspaceId,
|
||||
pageId: normalizedTargetParentId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await ensureDocumentScaffold(newId, newTitle);
|
||||
await client.mutation(api.mindmaps.copyByDocument, {
|
||||
sourceDocId: item.old.id,
|
||||
targetDocId: newId,
|
||||
});
|
||||
insertedDocs.push({ oldId: item.old.id, newId });
|
||||
}
|
||||
await Promise.all(
|
||||
result.result.items.map(async (item) => {
|
||||
await ensureDocumentScaffold(item.newId, item.title ?? "无标题");
|
||||
await copyMindmapFilesIfExists(item.oldId, item.newId);
|
||||
}),
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
items: insertedDocs.map((d) => ({ oldId: d.oldId, newId: d.newId })),
|
||||
});
|
||||
return NextResponse.json({
|
||||
items: result.result.items.map((item) => ({
|
||||
oldId: item.oldId,
|
||||
newId: item.newId,
|
||||
})),
|
||||
meta: {
|
||||
requestId: result.requestId,
|
||||
traceId: result.traceId,
|
||||
commandId: result.commandId,
|
||||
commandName: result.commandName,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return documentBridgeErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
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 CopyTreePayload;
|
||||
if (!payload?.items?.length) {
|
||||
return NextResponse.json({ error: "缺少 items" }, { status: 400 });
|
||||
}
|
||||
|
||||
const normalizedItems = payload.items.filter((it) => it?.documentId);
|
||||
if (normalizedItems.length === 0) {
|
||||
return NextResponse.json({ error: "items 为空" }, { status: 400 });
|
||||
}
|
||||
|
||||
const targetParentId = payload.targetParentId ?? null;
|
||||
let workspaceId: string | null = null;
|
||||
let targetAccessScope: "private" | "shared" | "public" = "private";
|
||||
|
||||
if (targetParentId) {
|
||||
const { data: targetDoc, error } = await supabase
|
||||
.from("documents")
|
||||
.select("workspace_id,access_scope")
|
||||
.eq("id", targetParentId)
|
||||
.eq("user_id", session.user.id)
|
||||
.single();
|
||||
if (error || !targetDoc) {
|
||||
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
workspaceId = targetDoc.workspace_id;
|
||||
targetAccessScope = (targetDoc.access_scope ?? "private") as typeof targetAccessScope;
|
||||
}
|
||||
|
||||
const sourceIds = Array.from(new Set(normalizedItems.map((it) => it.documentId)));
|
||||
const { data: sourceDocs, error: sourceErr } = await supabase
|
||||
.from("documents")
|
||||
.select("id,title,parent_id,workspace_id,access_scope,sort_order,created_at,content")
|
||||
.in("id", sourceIds)
|
||||
.eq("user_id", session.user.id);
|
||||
if (sourceErr || !sourceDocs?.length) {
|
||||
return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
|
||||
const sourceById = new Map<string, DocRow>();
|
||||
(sourceDocs as DocRow[]).forEach((d) => sourceById.set(d.id, d));
|
||||
|
||||
if (!workspaceId) {
|
||||
workspaceId = sourceDocs[0].workspace_id;
|
||||
}
|
||||
|
||||
const { data: allDocs, error: allErr } = await supabase
|
||||
.from("documents")
|
||||
.select("id,title,parent_id,workspace_id,access_scope,sort_order,created_at,content")
|
||||
.eq("workspace_id", workspaceId)
|
||||
.eq("user_id", session.user.id)
|
||||
.order("sort_order", { ascending: true, nullsFirst: false })
|
||||
.order("created_at", { ascending: true });
|
||||
if (allErr) {
|
||||
return NextResponse.json({ error: allErr.message }, { status: 500 });
|
||||
}
|
||||
|
||||
const childrenByParent = new Map<string | null, DocRow[]>();
|
||||
(allDocs as DocRow[]).forEach((doc) => {
|
||||
const list = childrenByParent.get(doc.parent_id) ?? [];
|
||||
list.push(doc);
|
||||
childrenByParent.set(doc.parent_id, list);
|
||||
});
|
||||
|
||||
const existingTitleSetByParent = new Map<string | null, Set<string>>();
|
||||
const seedTitleSet = (parent: string | null) => {
|
||||
if (existingTitleSetByParent.has(parent)) return;
|
||||
const titles = new Set<string>();
|
||||
(childrenByParent.get(parent) ?? []).forEach((d) => titles.add(normalizeTitle(d.title)));
|
||||
existingTitleSetByParent.set(parent, titles);
|
||||
};
|
||||
seedTitleSet(targetParentId);
|
||||
|
||||
const newIdByOldId = new Map<string, string>();
|
||||
const copyQueue: Array<{ old: DocRow; newParentId: string | null; parentKey: string | null }> = [];
|
||||
|
||||
const enqueueTree = (root: DocRow, newParent: string | null, recursive: boolean) => {
|
||||
const visit = (node: DocRow, parentNewId: string | null, parentKey: string | null) => {
|
||||
const newId = typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
newIdByOldId.set(node.id, newId);
|
||||
copyQueue.push({ old: node, newParentId: parentNewId, parentKey });
|
||||
if (!recursive) return;
|
||||
const children = getChildrenSorted(childrenByParent, node.id);
|
||||
children.forEach((child) => visit(child, newId, newId));
|
||||
};
|
||||
visit(root, newParent, newParent);
|
||||
};
|
||||
|
||||
normalizedItems.forEach((item) => {
|
||||
const doc = sourceById.get(item.documentId) ?? (allDocs as DocRow[]).find((d) => d.id === item.documentId) ?? null;
|
||||
if (doc) {
|
||||
enqueueTree(doc, targetParentId, Boolean(item.recursive));
|
||||
}
|
||||
});
|
||||
|
||||
if (copyQueue.length === 0) {
|
||||
return NextResponse.json({ error: "没有可复制的页面" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 计算根级 sort_order 起点
|
||||
const siblingQuery = supabase
|
||||
.from("documents")
|
||||
.select("id", { head: true, count: "exact" })
|
||||
.eq("workspace_id", workspaceId);
|
||||
if (targetParentId) {
|
||||
siblingQuery.eq("parent_id", targetParentId);
|
||||
} else {
|
||||
siblingQuery.is("parent_id", null);
|
||||
}
|
||||
const { count: rawSiblingCount } = await siblingQuery;
|
||||
const siblingCount = rawSiblingCount ?? 0;
|
||||
const nextSortByParent = new Map<string | null, number>([[targetParentId, siblingCount]]);
|
||||
|
||||
const insertedDocs: Array<{ oldId: string; newId: string }> = [];
|
||||
|
||||
for (const item of copyQueue) {
|
||||
const newId = newIdByOldId.get(item.old.id)!;
|
||||
const parentId = item.newParentId;
|
||||
|
||||
if (!existingTitleSetByParent.has(parentId)) {
|
||||
if (newIdByOldId.has(parentId ?? "")) {
|
||||
existingTitleSetByParent.set(parentId, new Set());
|
||||
} else {
|
||||
seedTitleSet(parentId);
|
||||
}
|
||||
}
|
||||
const titleSet = existingTitleSetByParent.get(parentId) ?? new Set<string>();
|
||||
existingTitleSetByParent.set(parentId, titleSet);
|
||||
|
||||
const newTitle = makeUniqueTitle(normalizeTitle(item.old.title), titleSet);
|
||||
|
||||
const currentSort = nextSortByParent.get(parentId) ?? 0;
|
||||
nextSortByParent.set(parentId, currentSort + 1);
|
||||
|
||||
const { error: insertError } = await supabase.from("documents").insert({
|
||||
id: newId,
|
||||
user_id: session.user.id,
|
||||
workspace_id: workspaceId,
|
||||
parent_id: parentId,
|
||||
access_scope: (item.old.access_scope ?? targetAccessScope) as typeof targetAccessScope,
|
||||
title: newTitle,
|
||||
content: item.old.content ?? { blocks: [] },
|
||||
sort_order: currentSort,
|
||||
});
|
||||
|
||||
if (insertError) {
|
||||
return NextResponse.json({ error: insertError.message }, { status: 500 });
|
||||
}
|
||||
|
||||
await ensureDocumentScaffold(newId, newTitle);
|
||||
await copyMindmapIfExists(item.old.id, newId);
|
||||
insertedDocs.push({ oldId: item.old.id, newId });
|
||||
}
|
||||
|
||||
// 复制附件并回写 content 中的 assetId
|
||||
const oldDocIds = insertedDocs.map((d) => d.oldId);
|
||||
const { data: assets, error: assetErr } = await supabase
|
||||
.from("media_assets")
|
||||
.select("id,workspace_id,document_id,asset_type,file_name,file_size,mime_type,file_url,thumbnail_url,bucket,storage_path")
|
||||
.in("document_id", oldDocIds)
|
||||
.eq("workspace_id", workspaceId)
|
||||
.is("deleted_at", null);
|
||||
|
||||
if (assetErr) {
|
||||
return NextResponse.json({ error: assetErr.message }, { status: 500 });
|
||||
}
|
||||
|
||||
const assetMapByOldDoc = new Map<string, Map<string, { id: string; signedUrl: string }>>();
|
||||
for (const asset of (assets as AssetRow[] | null) ?? []) {
|
||||
const mappedDocId = newIdByOldId.get(asset.document_id);
|
||||
if (!mappedDocId) continue;
|
||||
|
||||
const sourceLocation =
|
||||
asset.storage_path
|
||||
? { bucket: asset.bucket ?? DEFAULT_DOC_BUCKET, path: asset.storage_path }
|
||||
: asset.file_url
|
||||
? parseStoragePath(asset.file_url)
|
||||
: null;
|
||||
if (!sourceLocation) continue;
|
||||
|
||||
const fileName = (asset.file_name ?? "附件").replace(/[\\/]/g, "_");
|
||||
const newAssetId =
|
||||
typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
const targetPath = `${workspaceId}/${mappedDocId}/${newAssetId}-${fileName}`;
|
||||
const bucket = sourceLocation.bucket || DEFAULT_DOC_BUCKET;
|
||||
|
||||
const map = assetMapByOldDoc.get(asset.document_id) ?? new Map<string, { id: string; signedUrl: string }>();
|
||||
assetMapByOldDoc.set(asset.document_id, map);
|
||||
|
||||
const copyRes = await supabase.storage.from(bucket).copy(sourceLocation.path, targetPath);
|
||||
if (copyRes.error) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { data: signed } = await supabase.storage.from(bucket).createSignedUrl(targetPath, 60 * 60 * 24 * 7);
|
||||
const signedUrl = signed?.signedUrl ?? "";
|
||||
|
||||
const { error: insertAssetErr } = await supabase.from("media_assets").insert({
|
||||
id: newAssetId,
|
||||
workspace_id: workspaceId,
|
||||
document_id: mappedDocId,
|
||||
asset_type: asset.asset_type ?? "file",
|
||||
file_name: asset.file_name ?? fileName,
|
||||
file_size: asset.file_size ?? null,
|
||||
mime_type: asset.mime_type ?? null,
|
||||
bucket,
|
||||
storage_path: targetPath,
|
||||
file_url: signedUrl,
|
||||
thumbnail_url: signedUrl,
|
||||
created_by: session.user.id,
|
||||
});
|
||||
if (!insertAssetErr) {
|
||||
map.set(asset.id, { id: newAssetId, signedUrl });
|
||||
}
|
||||
}
|
||||
|
||||
for (const pair of insertedDocs) {
|
||||
const map = assetMapByOldDoc.get(pair.oldId);
|
||||
if (!map || map.size === 0) continue;
|
||||
const source = (allDocs as DocRow[]).find((d) => d.id === pair.oldId);
|
||||
if (!source) continue;
|
||||
const newContent = replaceAssetRefsInContent(source.content, map, pair.newId);
|
||||
const { error: updateErr } = await supabase
|
||||
.from("documents")
|
||||
.update({ content: newContent })
|
||||
.eq("id", pair.newId)
|
||||
.eq("user_id", session.user.id);
|
||||
if (updateErr) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
items: insertedDocs.map((d) => ({ oldId: d.oldId, newId: d.newId })),
|
||||
});
|
||||
*/
|
||||
}
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -1,160 +1,13 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
function makeId(): string {
|
||||
return typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
type CreateChildPayload = {
|
||||
parentId: string | null;
|
||||
title?: string;
|
||||
blocks?: unknown;
|
||||
};
|
||||
import { executeDocumentCreateChildBridgeCommand } from "@/lib/documents/page-command-adapter";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
|
||||
const { parentId, title, blocks }: CreateChildPayload = await request.json();
|
||||
|
||||
if (typeof parentId === "undefined") {
|
||||
return NextResponse.json({ error: "缺少 parentId" }, { status: 400 });
|
||||
}
|
||||
|
||||
let workspaceId: string;
|
||||
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 = parentDoc.workspace_id;
|
||||
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
|
||||
} else {
|
||||
const ensured = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
|
||||
fallbackName: auth.name ?? auth.email ?? "我的空间",
|
||||
workspaceIdIfCreate: makeId(),
|
||||
});
|
||||
workspaceId = ensured.activeWorkspaceId;
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedTitle = title && title.trim().length > 0 ? title.trim() : "未命名页面";
|
||||
const contentPayload = Array.isArray(blocks) ? blocks : [];
|
||||
const pageId = makeId();
|
||||
|
||||
const created = await client.mutation(api.documents.create, {
|
||||
id: pageId,
|
||||
workspaceId,
|
||||
parentId: parentId ?? null,
|
||||
title: resolvedTitle,
|
||||
accessScope,
|
||||
content: contentPayload,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
pageId: created.id,
|
||||
title: created.title ?? resolvedTitle,
|
||||
});
|
||||
return executeDocumentCreateChildBridgeCommand(request);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录无法创建页面" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { parentId, title, blocks }: CreateChildPayload = await request.json();
|
||||
|
||||
if (typeof parentId === "undefined") {
|
||||
return NextResponse.json({ error: "缺少 parentId" }, { status: 400 });
|
||||
}
|
||||
|
||||
let workspaceId: string;
|
||||
let accessScope: "private" | "shared" | "public" = "private";
|
||||
|
||||
if (parentId) {
|
||||
const { data: parentDoc, error: parentError } = await supabase
|
||||
.from("documents")
|
||||
.select("workspace_id,access_scope")
|
||||
.eq("id", parentId)
|
||||
.single();
|
||||
|
||||
if (parentError || !parentDoc) {
|
||||
return NextResponse.json({ error: "父页面不存在" }, { status: 404 });
|
||||
}
|
||||
workspaceId = parentDoc.workspace_id;
|
||||
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
|
||||
} else {
|
||||
await ensureDefaultWorkspace(supabase, session.user.id, session.user.email ?? "我的空间");
|
||||
workspaceId = await resolveActiveWorkspaceId(supabase, session.user.id);
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const siblingQuery = supabase
|
||||
.from("documents")
|
||||
.select("id", { head: true, count: "exact" })
|
||||
.eq("workspace_id", workspaceId);
|
||||
|
||||
if (parentId) {
|
||||
siblingQuery.eq("parent_id", parentId);
|
||||
} else {
|
||||
siblingQuery.is("parent_id", null);
|
||||
}
|
||||
|
||||
const { count: rawSiblingCount, error: countError } = await siblingQuery;
|
||||
const siblingCount = rawSiblingCount ?? 0;
|
||||
|
||||
if (countError) {
|
||||
return NextResponse.json({ error: countError.message }, { status: 500 });
|
||||
}
|
||||
|
||||
const resolvedTitle =
|
||||
title && title.trim().length > 0 ? title.trim() : "未命名页面";
|
||||
|
||||
const contentPayload = Array.isArray(blocks) ? blocks : [];
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("documents")
|
||||
.insert({
|
||||
user_id: session.user.id,
|
||||
parent_id: parentId,
|
||||
workspace_id: workspaceId,
|
||||
access_scope: accessScope,
|
||||
title: resolvedTitle,
|
||||
content: contentPayload,
|
||||
sort_order: siblingCount,
|
||||
})
|
||||
.select("id,title")
|
||||
.single();
|
||||
|
||||
if (error || !data) {
|
||||
return NextResponse.json(
|
||||
{ error: error?.message ?? "创建子页面失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
pageId: data.id,
|
||||
title: data.title ?? resolvedTitle,
|
||||
});
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -1,45 +1,34 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import type { Json } from "@/types/supabase";
|
||||
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
|
||||
import path from "path";
|
||||
import { promises as fs } from "fs";
|
||||
import { getDocumentsBaseDir } from "@/lib/server/local-paths";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
|
||||
const documentsBaseDir = getDocumentsBaseDir();
|
||||
|
||||
async function ensureDocumentScaffold(id: string, title: string | null) {
|
||||
const folder = path.join(documentsBaseDir, id);
|
||||
const indexFile = path.join(folder, "index.md");
|
||||
await fs.mkdir(folder, { recursive: true });
|
||||
try {
|
||||
await fs.access(indexFile);
|
||||
} catch {
|
||||
const safeTitle = title && title.trim() ? title.trim() : "无标题";
|
||||
const content = `# ${safeTitle}\n`;
|
||||
await fs.writeFile(indexFile, content, "utf8");
|
||||
}
|
||||
}
|
||||
import {
|
||||
buildDocumentBridgeContext,
|
||||
buildDocumentCommandEnvelope,
|
||||
documentBridgeErrorResponse,
|
||||
} from "@/lib/documents/bridge";
|
||||
import {
|
||||
executePageLifecycleBridgeCommand,
|
||||
type DocumentCreatePayload,
|
||||
} from "@/lib/documents/page-command-adapter";
|
||||
import { ensureDocumentScaffold } from "@/lib/documents/page-lifecycle-side-effects";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
if (isConvexEnabled()) {
|
||||
return await handleCreateRequestConvex(request);
|
||||
}
|
||||
return await handleCreateRequest(request);
|
||||
return await handleCreateRequest();
|
||||
} catch (error) {
|
||||
console.error("创建页面失败", error);
|
||||
const message = error instanceof Error ? error.message : "创建页面失败,请稍后再试";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : "创建页面失败,请稍后再试";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateRequestConvex(request: Request) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
|
||||
const { parentId }: { parentId?: string | null } = await request.json();
|
||||
|
||||
const workspaceBootstrap = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
|
||||
@@ -48,7 +37,6 @@ async function handleCreateRequestConvex(request: Request) {
|
||||
});
|
||||
|
||||
let workspaceId: string | null = null;
|
||||
let parentContent: Json | null = null;
|
||||
let accessScope: "private" | "shared" | "public" = "private";
|
||||
|
||||
if (parentId) {
|
||||
@@ -62,8 +50,6 @@ async function handleCreateRequestConvex(request: Request) {
|
||||
|
||||
workspaceId = parentDoc.workspace_id;
|
||||
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
|
||||
const parentContentRes = await client.query(api.documents.getContent, { id: parentId });
|
||||
parentContent = (parentContentRes?.content as Json | null) ?? null;
|
||||
} else {
|
||||
workspaceId = workspaceBootstrap.activeWorkspaceId || null;
|
||||
}
|
||||
@@ -72,156 +58,62 @@ async function handleCreateRequestConvex(request: Request) {
|
||||
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const data = await client.mutation(api.documents.create, {
|
||||
id,
|
||||
workspaceId,
|
||||
parentId: parentId ?? null,
|
||||
title: "无标题",
|
||||
accessScope,
|
||||
content: [],
|
||||
});
|
||||
|
||||
// 为文件树创建本地目录和 index.md
|
||||
if (data?.id) {
|
||||
await ensureDocumentScaffold(data.id, data.title ?? "无标题");
|
||||
}
|
||||
|
||||
if (parentId && data) {
|
||||
const existingBlocks = extractBlocksFromContent(parentContent);
|
||||
const pageReferenceBlock = {
|
||||
id: randomUUID(),
|
||||
type: "pageReference",
|
||||
props: {
|
||||
pageId: data.id,
|
||||
title: data.title ?? "无标题",
|
||||
},
|
||||
};
|
||||
const nextBlocks = [...existingBlocks, pageReferenceBlock as Json];
|
||||
const payload = composeContentWithBlocks(parentContent, nextBlocks);
|
||||
|
||||
await client.mutation(api.documents.updateContent, {
|
||||
id: parentId,
|
||||
content: payload,
|
||||
try {
|
||||
const id = randomUUID();
|
||||
const normalizedWorkspaceId = workspaceId.trim();
|
||||
const bridgeContext = await buildDocumentBridgeContext({
|
||||
request,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
});
|
||||
const result = await executePageLifecycleBridgeCommand<
|
||||
DocumentCreatePayload,
|
||||
{
|
||||
id: string;
|
||||
title?: string | null;
|
||||
parent_id?: string | null;
|
||||
sort_order?: number | null;
|
||||
workspace_id?: string;
|
||||
access_scope?: "private" | "shared" | "public";
|
||||
is_template?: boolean;
|
||||
}
|
||||
>({
|
||||
context: bridgeContext,
|
||||
envelope: buildDocumentCommandEnvelope({
|
||||
name: "documents.create",
|
||||
payload: {
|
||||
documentId: id,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
parentId: parentId?.trim() || null,
|
||||
title: "无标题",
|
||||
accessScope,
|
||||
content: [],
|
||||
},
|
||||
context: bridgeContext,
|
||||
target: {
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
pageId: id,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
await ensureDocumentScaffold(result.result.id, result.result.title ?? "无标题");
|
||||
|
||||
return NextResponse.json({
|
||||
...result.result,
|
||||
meta: {
|
||||
requestId: result.requestId,
|
||||
traceId: result.traceId,
|
||||
commandId: result.commandId,
|
||||
commandName: result.commandName,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return documentBridgeErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateRequest(request: Request) {
|
||||
async function handleCreateRequest() {
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { parentId }: { parentId?: string | null } = await request.json();
|
||||
|
||||
await ensureDefaultWorkspace(supabase, session.user.id, session.user.email ?? "我的空间");
|
||||
|
||||
let workspaceId: string | null = null;
|
||||
let parentContent: Json | null = null;
|
||||
let accessScope: "private" | "shared" | "public" = "private";
|
||||
|
||||
if (parentId) {
|
||||
const { data: parentDoc, error: parentError } = await supabase
|
||||
.from("documents")
|
||||
.select("workspace_id,access_scope,content,user_id")
|
||||
.eq("id", parentId)
|
||||
.eq("user_id", session.user.id)
|
||||
.single();
|
||||
|
||||
if (parentError || !parentDoc) {
|
||||
return NextResponse.json({ error: "父页面不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
workspaceId = parentDoc.workspace_id;
|
||||
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
|
||||
parentContent = parentDoc.content;
|
||||
} else {
|
||||
workspaceId = await resolveActiveWorkspaceId(supabase, session.user.id);
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
||||
}
|
||||
|
||||
const siblingQuery = supabase
|
||||
.from("documents")
|
||||
.select("id", { head: true, count: "exact" })
|
||||
.eq("workspace_id", workspaceId);
|
||||
|
||||
if (parentId) {
|
||||
siblingQuery.eq("parent_id", parentId);
|
||||
} else {
|
||||
siblingQuery.is("parent_id", null);
|
||||
}
|
||||
|
||||
const { count: rawSiblingCount, error: countError } = await siblingQuery;
|
||||
const siblingCount = rawSiblingCount ?? 0;
|
||||
|
||||
if (countError) {
|
||||
return NextResponse.json({ error: countError.message }, { status: 500 });
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("documents")
|
||||
.insert({
|
||||
user_id: session.user.id,
|
||||
parent_id: parentId ?? null,
|
||||
workspace_id: workspaceId,
|
||||
title: "无标题",
|
||||
content: { blocks: [] },
|
||||
access_scope: accessScope,
|
||||
sort_order: siblingCount,
|
||||
})
|
||||
.select(
|
||||
"id,title,parent_id,sort_order,is_starred,created_at,updated_at,workspace_id,access_scope,is_template",
|
||||
)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
// 为文件树创建本地目录和 index.md
|
||||
if (data?.id) {
|
||||
await ensureDocumentScaffold(data.id, data.title ?? "无标题");
|
||||
}
|
||||
|
||||
if (parentId && data) {
|
||||
const existingBlocks = extractBlocksFromContent(parentContent);
|
||||
const pageReferenceBlock = {
|
||||
id: randomUUID(),
|
||||
type: "pageReference",
|
||||
props: {
|
||||
pageId: data.id,
|
||||
title: data.title ?? "无标题",
|
||||
},
|
||||
};
|
||||
const nextBlocks = [...existingBlocks, pageReferenceBlock as Json];
|
||||
const payload = composeContentWithBlocks(parentContent, nextBlocks);
|
||||
const timestamp = new Date().toISOString();
|
||||
const { error: parentUpdateError } = await supabase
|
||||
.from("documents")
|
||||
.update({ content: payload, updated_at: timestamp })
|
||||
.eq("id", parentId)
|
||||
.eq("user_id", session.user.id);
|
||||
|
||||
if (parentUpdateError) {
|
||||
return NextResponse.json({ error: parentUpdateError.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
*/
|
||||
}
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -1,52 +1,59 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import {
|
||||
assertDocumentId,
|
||||
buildDocumentBridgeContext,
|
||||
buildDocumentCommandEnvelope,
|
||||
documentBridgeErrorResponse,
|
||||
} from "@/lib/documents/bridge";
|
||||
import {
|
||||
executePageLifecycleBridgeCommand,
|
||||
type DocumentDeletePayload,
|
||||
} from "@/lib/documents/page-command-adapter";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const { documentId } = await request.json();
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
try {
|
||||
const body = (await request.json()) as { documentId?: string; workspaceId?: string | null };
|
||||
const normalizedDocumentId = assertDocumentId(body.documentId);
|
||||
const normalizedWorkspaceId = body.workspaceId?.trim() || null;
|
||||
const bridgeContext = await buildDocumentBridgeContext({
|
||||
request,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
});
|
||||
const result = await executePageLifecycleBridgeCommand<DocumentDeletePayload, { ok: boolean }>({
|
||||
context: bridgeContext,
|
||||
envelope: buildDocumentCommandEnvelope({
|
||||
name: "documents.delete",
|
||||
payload: {
|
||||
documentId: normalizedDocumentId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
},
|
||||
context: bridgeContext,
|
||||
target: {
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
pageId: normalizedDocumentId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
meta: {
|
||||
requestId: result.requestId,
|
||||
traceId: result.traceId,
|
||||
commandId: result.commandId,
|
||||
commandName: result.commandName,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return documentBridgeErrorResponse(error);
|
||||
}
|
||||
const { client } = await getAuthedConvexClient();
|
||||
await client.mutation(api.documents.softDelete, { id: documentId });
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { documentId } = await request.json();
|
||||
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from("documents")
|
||||
.update({
|
||||
deleted_at: new Date().toISOString(),
|
||||
deleted_by: session.user.id,
|
||||
})
|
||||
.eq("id", documentId)
|
||||
.eq("user_id", session.user.id);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
*/
|
||||
}
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -2,165 +2,96 @@ import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import path from "path";
|
||||
import { promises as fs } from "fs";
|
||||
import { getDocumentsBaseDir } from "@/lib/server/local-paths";
|
||||
import {
|
||||
assertDocumentId,
|
||||
buildDocumentBridgeContext,
|
||||
buildDocumentCommandEnvelope,
|
||||
documentBridgeErrorResponse,
|
||||
} from "@/lib/documents/bridge";
|
||||
import {
|
||||
executePageLifecycleBridgeCommand,
|
||||
type DocumentDuplicatePayload,
|
||||
} from "@/lib/documents/page-command-adapter";
|
||||
import {
|
||||
copyMindmapFilesIfExists,
|
||||
ensureDocumentScaffold,
|
||||
} from "@/lib/documents/page-lifecycle-side-effects";
|
||||
|
||||
interface DuplicatePayload {
|
||||
documentId: string;
|
||||
}
|
||||
|
||||
const documentsBaseDir = getDocumentsBaseDir();
|
||||
|
||||
async function ensureDocumentScaffold(id: string, title: string | null) {
|
||||
const folder = path.join(documentsBaseDir, id);
|
||||
const indexFile = path.join(folder, "index.md");
|
||||
await fs.mkdir(folder, { recursive: true });
|
||||
const safeTitle = title && title.trim() ? title.trim() : "无标题";
|
||||
const content = `# ${safeTitle}\n`;
|
||||
await fs.writeFile(indexFile, content, "utf8");
|
||||
}
|
||||
|
||||
async function copyMindmapIfExists(sourceId: string, targetId: string) {
|
||||
try {
|
||||
const srcDir = path.join(documentsBaseDir, sourceId);
|
||||
const destDir = path.join(documentsBaseDir, targetId);
|
||||
const entries = await fs.readdir(srcDir);
|
||||
const mindmapFiles = entries.filter((name) => name === "mindmap.json" || /^mindmap-.+\\.json$/i.test(name));
|
||||
if (mindmapFiles.length === 0) return;
|
||||
await fs.mkdir(destDir, { recursive: true });
|
||||
await Promise.all(
|
||||
mindmapFiles.map(async (name) => {
|
||||
try {
|
||||
const buf = await fs.readFile(path.join(srcDir, name));
|
||||
await fs.writeFile(path.join(destDir, name), buf);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// 如果源不存在则忽略
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const { client } = await getAuthedConvexClient();
|
||||
try {
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const { documentId }: DuplicatePayload = await request.json();
|
||||
const normalizedDocumentId = assertDocumentId(documentId);
|
||||
|
||||
const { documentId }: DuplicatePayload = await request.json();
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
const sourceDoc = await client.query(api.documents.getMeta, { id: normalizedDocumentId });
|
||||
if (!sourceDoc) {
|
||||
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
|
||||
}
|
||||
|
||||
const fallbackTitle =
|
||||
sourceDoc.title?.trim() && sourceDoc.title.trim().length > 0 ? sourceDoc.title.trim() : "无标题";
|
||||
const duplicatedTitle = `${fallbackTitle} 副本`;
|
||||
const newId =
|
||||
typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
const normalizedWorkspaceId = sourceDoc.workspace_id?.trim() || null;
|
||||
const bridgeContext = await buildDocumentBridgeContext({
|
||||
request,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
});
|
||||
const result = await executePageLifecycleBridgeCommand<
|
||||
DocumentDuplicatePayload,
|
||||
{
|
||||
id: string;
|
||||
title?: string | null;
|
||||
parent_id?: string | null;
|
||||
sort_order?: number | null;
|
||||
}
|
||||
>({
|
||||
context: bridgeContext,
|
||||
envelope: buildDocumentCommandEnvelope({
|
||||
name: "documents.duplicate",
|
||||
payload: {
|
||||
sourceDocumentId: normalizedDocumentId,
|
||||
newDocumentId: newId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
title: duplicatedTitle,
|
||||
},
|
||||
context: bridgeContext,
|
||||
target: {
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
pageId: newId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await ensureDocumentScaffold(result.result.id, result.result.title ?? duplicatedTitle);
|
||||
await copyMindmapFilesIfExists(normalizedDocumentId, result.result.id);
|
||||
|
||||
return NextResponse.json({
|
||||
id: result.result.id,
|
||||
title: result.result.title ?? duplicatedTitle,
|
||||
parent_id: result.result.parent_id ?? null,
|
||||
sort_order: result.result.sort_order ?? null,
|
||||
meta: {
|
||||
requestId: result.requestId,
|
||||
traceId: result.traceId,
|
||||
commandId: result.commandId,
|
||||
commandName: result.commandName,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return documentBridgeErrorResponse(error);
|
||||
}
|
||||
|
||||
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
||||
if (!sourceDoc) {
|
||||
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
|
||||
}
|
||||
|
||||
const fallbackTitle =
|
||||
sourceDoc.title?.trim() && sourceDoc.title.trim().length > 0 ? sourceDoc.title.trim() : "无标题";
|
||||
const duplicatedTitle = `${fallbackTitle} 副本`;
|
||||
|
||||
const newId = typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
const duplicated = await client.mutation(api.documents.duplicate, {
|
||||
sourceId: documentId,
|
||||
newId,
|
||||
title: duplicatedTitle,
|
||||
});
|
||||
|
||||
await ensureDocumentScaffold(duplicated.id, duplicated.title ?? duplicatedTitle);
|
||||
await client.mutation(api.mindmaps.copyByDocument, {
|
||||
sourceDocId: documentId,
|
||||
targetDocId: duplicated.id,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
id: duplicated.id,
|
||||
title: duplicated.title ?? duplicatedTitle,
|
||||
parent_id: duplicated.parent_id ?? null,
|
||||
sort_order: duplicated.sort_order ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { documentId }: DuplicatePayload = await request.json();
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { data: sourceDoc, error: sourceError } = await supabase
|
||||
.from("documents")
|
||||
.select("id,title,content,parent_id,workspace_id,access_scope")
|
||||
.eq("id", documentId)
|
||||
.eq("user_id", session.user.id)
|
||||
.single();
|
||||
|
||||
if (sourceError || !sourceDoc) {
|
||||
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
|
||||
}
|
||||
|
||||
const siblingQuery = supabase
|
||||
.from("documents")
|
||||
.select("id", { head: true, count: "exact" })
|
||||
.eq("workspace_id", sourceDoc.workspace_id);
|
||||
|
||||
if (sourceDoc.parent_id) {
|
||||
siblingQuery.eq("parent_id", sourceDoc.parent_id);
|
||||
} else {
|
||||
siblingQuery.is("parent_id", null);
|
||||
}
|
||||
|
||||
const { count: rawSiblingCount, error: countError } = await siblingQuery;
|
||||
const siblingCount = rawSiblingCount ?? 0;
|
||||
if (countError) {
|
||||
return NextResponse.json({ error: countError.message }, { status: 500 });
|
||||
}
|
||||
|
||||
const fallbackTitle =
|
||||
sourceDoc.title?.trim() && sourceDoc.title.trim().length > 0
|
||||
? sourceDoc.title.trim()
|
||||
: "无标题";
|
||||
const duplicatedTitle = `${fallbackTitle} 副本`;
|
||||
|
||||
const { data: duplicated, error: duplicateError } = await supabase
|
||||
.from("documents")
|
||||
.insert({
|
||||
user_id: session.user.id,
|
||||
workspace_id: sourceDoc.workspace_id,
|
||||
parent_id: sourceDoc.parent_id,
|
||||
access_scope: sourceDoc.access_scope ?? "private",
|
||||
title: duplicatedTitle,
|
||||
content: sourceDoc.content,
|
||||
sort_order: siblingCount,
|
||||
})
|
||||
.select("id,title,parent_id,sort_order")
|
||||
.single();
|
||||
|
||||
if (duplicateError || !duplicated) {
|
||||
return NextResponse.json(
|
||||
{ error: duplicateError?.message ?? "复制失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
await ensureDocumentScaffold(duplicated.id, duplicated.title ?? duplicatedTitle);
|
||||
await copyMindmapIfExists(sourceDoc.id, duplicated.id);
|
||||
|
||||
return NextResponse.json(duplicated);
|
||||
*/
|
||||
}
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -1,133 +1,11 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import type { Json } from "@/types/supabase";
|
||||
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
interface EmbedPayload {
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
}
|
||||
import { executeDocumentEmbedBridgeCommand } from "@/lib/documents/page-command-adapter";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
|
||||
const { sourceId, targetId }: EmbedPayload = await request.json();
|
||||
|
||||
if (!sourceId || !targetId) {
|
||||
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
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 = (targetMeta as any)?.embed_default_block_id as string | null | undefined;
|
||||
const anchorIndex =
|
||||
typeof anchorId === "string" && anchorId.trim()
|
||||
? currentBlocks.findIndex((b) => typeof b === "object" && b !== null && (b as any).id === anchorId)
|
||||
: -1;
|
||||
const insertIndex = anchorIndex >= 0 ? anchorIndex + 1 : currentBlocks.length;
|
||||
|
||||
const nextBlocks: Json[] = [
|
||||
...currentBlocks.slice(0, insertIndex),
|
||||
{
|
||||
id: randomUUID(),
|
||||
type: "pageReference",
|
||||
props: {
|
||||
pageId: sourceDoc.id,
|
||||
title: sourceDoc.title ?? "无标题",
|
||||
},
|
||||
},
|
||||
...currentBlocks.slice(insertIndex),
|
||||
];
|
||||
|
||||
const payload: Json = composeContentWithBlocks(targetContent.content, nextBlocks);
|
||||
|
||||
await client.mutation(api.documents.updateContent, {
|
||||
id: targetId,
|
||||
content: payload,
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
return executeDocumentEmbedBridgeCommand(request);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { sourceId, targetId }: EmbedPayload = await request.json();
|
||||
|
||||
if (!sourceId || !targetId) {
|
||||
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { data: sourceDoc, error: sourceError } = await supabase
|
||||
.from("documents")
|
||||
.select("id,title,user_id")
|
||||
.eq("id", sourceId)
|
||||
.eq("user_id", session.user.id)
|
||||
.single();
|
||||
|
||||
if (sourceError || !sourceDoc) {
|
||||
return NextResponse.json({ error: "原始页面不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
|
||||
const { data: targetDoc, error: targetError } = await supabase
|
||||
.from("documents")
|
||||
.select("id,content,user_id")
|
||||
.eq("id", targetId)
|
||||
.eq("user_id", session.user.id)
|
||||
.single();
|
||||
|
||||
if (targetError || !targetDoc) {
|
||||
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
|
||||
const currentBlocks = extractBlocksFromContent(targetDoc.content);
|
||||
const nextBlocks: Json[] = [
|
||||
...currentBlocks,
|
||||
{
|
||||
id: randomUUID(),
|
||||
type: "pageReference",
|
||||
props: {
|
||||
pageId: sourceDoc.id,
|
||||
title: sourceDoc.title ?? "无标题",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const payload: Json = composeContentWithBlocks(targetDoc.content, nextBlocks);
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from("documents")
|
||||
.update({ content: payload })
|
||||
.eq("id", targetDoc.id)
|
||||
.eq("user_id", session.user.id);
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json({ error: updateError.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -1,69 +1,13 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { executeDocumentEmptyTrashBridgeCommand } from "@/lib/documents/page-command-adapter";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
interface EmptyTrashPayload {
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const { workspaceId }: EmptyTrashPayload = await request.json();
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||
}
|
||||
const { client } = await getAuthedConvexClient();
|
||||
await client.mutation(api.documents.emptyTrashByWorkspace, { workspaceId });
|
||||
return NextResponse.json({ success: true });
|
||||
return executeDocumentEmptyTrashBridgeCommand(request);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { workspaceId }: EmptyTrashPayload = await request.json();
|
||||
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from("documents")
|
||||
.delete()
|
||||
.eq("workspace_id", workspaceId)
|
||||
.eq("user_id", session.user.id)
|
||||
.not("deleted_at", "is", null);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -1,55 +1,67 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import {
|
||||
assertDocumentId,
|
||||
buildDocumentBridgeContext,
|
||||
buildDocumentCommandEnvelope,
|
||||
documentBridgeErrorResponse,
|
||||
} from "@/lib/documents/bridge";
|
||||
import {
|
||||
executePageLifecycleBridgeCommand,
|
||||
type DocumentMovePayload,
|
||||
} from "@/lib/documents/page-command-adapter";
|
||||
|
||||
interface MovePayload {
|
||||
documentId: string;
|
||||
parentId?: string | null;
|
||||
position: number;
|
||||
workspaceId?: string | null;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const { documentId, parentId = null, position }: MovePayload = await request.json();
|
||||
const sortOrder = Number.isFinite(position) ? Math.floor(position) : 0;
|
||||
const { client } = await getAuthedConvexClient();
|
||||
await client.mutation(api.documents.move, {
|
||||
id: documentId,
|
||||
parentId,
|
||||
sortOrder,
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
try {
|
||||
const { documentId, parentId = null, position, workspaceId }: MovePayload = await request.json();
|
||||
const normalizedDocumentId = assertDocumentId(documentId);
|
||||
const normalizedWorkspaceId = workspaceId?.trim() || null;
|
||||
const sortOrder = Number.isFinite(position) ? Math.floor(position) : 0;
|
||||
const bridgeContext = await buildDocumentBridgeContext({
|
||||
request,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
});
|
||||
const result = await executePageLifecycleBridgeCommand<DocumentMovePayload, { ok: boolean }>({
|
||||
context: bridgeContext,
|
||||
envelope: buildDocumentCommandEnvelope({
|
||||
name: "documents.move",
|
||||
payload: {
|
||||
documentId: normalizedDocumentId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
parentId: parentId?.trim() || null,
|
||||
sortOrder,
|
||||
},
|
||||
context: bridgeContext,
|
||||
target: {
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
pageId: normalizedDocumentId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
meta: {
|
||||
requestId: result.requestId,
|
||||
traceId: result.traceId,
|
||||
commandId: result.commandId,
|
||||
commandName: result.commandName,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return documentBridgeErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { documentId, parentId = null, position }: MovePayload = await request.json();
|
||||
const sortOrder = Number.isFinite(position) ? Math.floor(position) : 0;
|
||||
|
||||
const { error } = await supabase
|
||||
.from("documents")
|
||||
.update({
|
||||
parent_id: parentId,
|
||||
sort_order: sortOrder,
|
||||
})
|
||||
.eq("id", documentId)
|
||||
.eq("user_id", session.user.id);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
*/
|
||||
}
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -1,49 +1,13 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { executeDocumentPurgeBridgeCommand } from "@/lib/documents/page-command-adapter";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const { documentId } = await request.json();
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
}
|
||||
const { client } = await getAuthedConvexClient();
|
||||
await client.mutation(api.documents.purge, { id: documentId });
|
||||
return NextResponse.json({ success: true });
|
||||
return executeDocumentPurgeBridgeCommand(request);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { documentId } = await request.json();
|
||||
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from("documents")
|
||||
.delete()
|
||||
.eq("id", documentId)
|
||||
.eq("user_id", session.user.id);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -1,54 +1,59 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import {
|
||||
assertDocumentId,
|
||||
buildDocumentBridgeContext,
|
||||
buildDocumentCommandEnvelope,
|
||||
documentBridgeErrorResponse,
|
||||
} from "@/lib/documents/bridge";
|
||||
import {
|
||||
executePageLifecycleBridgeCommand,
|
||||
type DocumentRestorePayload,
|
||||
} from "@/lib/documents/page-command-adapter";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const { documentId } = await request.json();
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
try {
|
||||
const body = (await request.json()) as { documentId?: string; workspaceId?: string | null };
|
||||
const normalizedDocumentId = assertDocumentId(body.documentId);
|
||||
const normalizedWorkspaceId = body.workspaceId?.trim() || null;
|
||||
const bridgeContext = await buildDocumentBridgeContext({
|
||||
request,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
});
|
||||
const result = await executePageLifecycleBridgeCommand<DocumentRestorePayload, { ok: boolean }>({
|
||||
context: bridgeContext,
|
||||
envelope: buildDocumentCommandEnvelope({
|
||||
name: "documents.restore",
|
||||
payload: {
|
||||
documentId: normalizedDocumentId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
},
|
||||
context: bridgeContext,
|
||||
target: {
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
pageId: normalizedDocumentId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
meta: {
|
||||
requestId: result.requestId,
|
||||
traceId: result.traceId,
|
||||
commandId: result.commandId,
|
||||
commandName: result.commandName,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return documentBridgeErrorResponse(error);
|
||||
}
|
||||
const { client } = await getAuthedConvexClient();
|
||||
await client.mutation(api.documents.restore, { id: documentId });
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { documentId } = await request.json();
|
||||
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from("documents")
|
||||
.update({
|
||||
deleted_at: null,
|
||||
deleted_by: null,
|
||||
parent_id: null,
|
||||
access_scope: "private",
|
||||
})
|
||||
.eq("id", documentId)
|
||||
.eq("user_id", session.user.id);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
*/
|
||||
}
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/convex/enabled", () => ({
|
||||
isConvexEnabled: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/page-command-adapter", () => ({
|
||||
executeDocumentCreateChildBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
|
||||
executeDocumentEmbedBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
|
||||
executeDocumentTemplateBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
|
||||
executeDocumentEmptyTrashBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
|
||||
executeDocumentPurgeBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
|
||||
}));
|
||||
|
||||
import { POST as postCreateChild } from "@/app/api/documents/create-child/route";
|
||||
import { POST as postEmbed } from "@/app/api/documents/embed/route";
|
||||
import { POST as postTemplate } from "@/app/api/documents/template/route";
|
||||
import { POST as postEmptyTrash } from "@/app/api/documents/empty-trash/route";
|
||||
import { POST as postPurge } from "@/app/api/documents/purge/route";
|
||||
import {
|
||||
executeDocumentCreateChildBridgeCommand,
|
||||
executeDocumentEmbedBridgeCommand,
|
||||
executeDocumentTemplateBridgeCommand,
|
||||
executeDocumentEmptyTrashBridgeCommand,
|
||||
executeDocumentPurgeBridgeCommand,
|
||||
} from "@/lib/documents/page-command-adapter";
|
||||
|
||||
describe("documents route adapters", () => {
|
||||
it("creates child route delegates to unified adapter", async () => {
|
||||
await postCreateChild(new Request("http://localhost/api/documents/create-child", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ parentId: null }),
|
||||
}));
|
||||
expect(executeDocumentCreateChildBridgeCommand).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("embed route delegates to unified adapter", async () => {
|
||||
await postEmbed(new Request("http://localhost/api/documents/embed", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ sourceId: "doc_1", targetId: "doc_2" }),
|
||||
}));
|
||||
expect(executeDocumentEmbedBridgeCommand).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("template route delegates to unified adapter", async () => {
|
||||
await postTemplate(new Request("http://localhost/api/documents/template", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ documentId: "doc_1", isTemplate: true }),
|
||||
}));
|
||||
expect(executeDocumentTemplateBridgeCommand).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("empty trash route delegates to unified adapter", async () => {
|
||||
await postEmptyTrash(new Request("http://localhost/api/documents/empty-trash", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ workspaceId: "ws_1" }),
|
||||
}));
|
||||
expect(executeDocumentEmptyTrashBridgeCommand).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("purge route delegates to unified adapter", async () => {
|
||||
await postPurge(new Request("http://localhost/api/documents/purge", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ documentId: "doc_1" }),
|
||||
}));
|
||||
expect(executeDocumentPurgeBridgeCommand).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -58,3 +58,5 @@ export async function POST(request: Request) {
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -1,26 +1,11 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
type TemplatePayload = {
|
||||
documentId: string;
|
||||
isTemplate: boolean;
|
||||
};
|
||||
import { executeDocumentTemplateBridgeCommand } from "@/lib/documents/page-command-adapter";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const { documentId, isTemplate }: TemplatePayload = await request.json();
|
||||
if (!documentId || typeof isTemplate !== "boolean") {
|
||||
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { client } = await getAuthedConvexClient();
|
||||
await client.mutation(api.documents.setTemplate, { id: documentId, isTemplate });
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
return executeDocumentTemplateBridgeCommand(request);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
|
||||
@@ -60,3 +60,5 @@ export async function POST(request: Request) {
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
Reference in New Issue
Block a user