import { internalQuery, mutation, query } from "./_generated/server"; import { v } from "convex/values"; import { getAuthUserId } from "@convex-dev/auth/server"; import { nowIso } from "./_utils/time"; import { collectSubtree } from "./_utils/documentTree"; import { enqueueIngestDocumentJob } from "./_utils/ingestJobs"; import { extractTextFromDocumentContent } from "./_utils/text"; const accessScope = v.union(v.literal("private"), v.literal("shared"), v.literal("public")); async function requireUserId(ctx: any): Promise { const userId = await getAuthUserId(ctx); if (userId === null) { throw new Error("未登录"); } return userId; } type SharePermission = "read" | "edit"; type SharePolicy = { permission: SharePermission; disableDownload: boolean; disableCopy: boolean }; async function requireWorkspaceMember(ctx: any, workspaceId: string, userId: string) { const member = await ctx.db .query("workspace_members") .withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", workspaceId).eq("user_id", userId)) .first(); if (!member) { throw new Error("无权限"); } return member; } async function resolveSharePolicy(ctx: any, doc: any, userId: string): Promise { const direct = await ctx.db .query("document_shares") .withIndex("by_doc_user", (q: any) => q.eq("document_id", doc.id).eq("shared_with_user_id", userId)) .first(); if (direct) { return { permission: direct.permission as SharePermission, disableDownload: Boolean((direct as any).disable_download), disableCopy: Boolean((direct as any).disable_copy), }; } let parentId: string | null = doc.parent_id ?? null; for (let depth = 0; depth < 60 && parentId; depth += 1) { const parentShare = await ctx.db .query("document_shares") .withIndex("by_doc_user", (q: any) => q.eq("document_id", parentId).eq("shared_with_user_id", userId)) .first(); if (parentShare && parentShare.include_descendants) { return { permission: parentShare.permission as SharePermission, disableDownload: Boolean((parentShare as any).disable_download), disableCopy: Boolean((parentShare as any).disable_copy), }; } const parent = await ctx.db .query("documents") .withIndex("by_document_id", (q: any) => q.eq("id", parentId)) .first(); parentId = parent?.parent_id ?? null; } return null; } async function resolveSharePermission(ctx: any, doc: any, userId: string): Promise { const policy = await resolveSharePolicy(ctx, doc, userId); return policy?.permission ?? null; } async function resolveGroupSharePolicy(ctx: any, doc: any, userId: string): Promise { const memberships = await ctx.db .query("group_members") .withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", doc.workspace_id).eq("user_id", userId)) .collect(); const groupIds = new Set(memberships.map((m: any) => String(m.group_id))); if (groupIds.size === 0) return null; const permissionFromGroup = async (documentId: string, groupId: string): Promise => { const override = await ctx.db .query("document_group_user_permissions") .withIndex("by_doc_group_user", (q: any) => q.eq("document_id", documentId).eq("group_id", groupId).eq("user_id", userId), ) .first(); return override?.permission === "edit" ? "edit" : "read"; }; let best: SharePermission | null = null; let disableDownload = false; let disableCopy = false; const checkDocId = async (documentId: string, requireIncludeDesc: boolean) => { const shares = await ctx.db .query("document_group_shares") .withIndex("by_document", (q: any) => q.eq("document_id", documentId)) .collect(); for (const s of shares) { const gid = String(s.group_id); if (!groupIds.has(gid)) continue; if (requireIncludeDesc && !s.include_descendants) continue; disableDownload = disableDownload || Boolean((s as any).disable_download); disableCopy = disableCopy || Boolean((s as any).disable_copy); const perm = await permissionFromGroup(documentId, gid); if (perm === "edit") { best = "edit"; } else { best = best ?? "read"; } } }; // 当前页面是否被群组公开 await checkDocId(doc.id, false); // 沿父链查找“包含子页面”的群组公开 let parentId: string | null = doc.parent_id ?? null; for (let depth = 0; depth < 60 && parentId; depth += 1) { await checkDocId(parentId, true); const parent = await ctx.db .query("documents") .withIndex("by_document_id", (q: any) => q.eq("id", parentId)) .first(); parentId = parent?.parent_id ?? null; } if (!best) return null; return { permission: best, disableDownload, disableCopy }; } async function resolveGroupSharePermission(ctx: any, doc: any, userId: string): Promise { const policy = await resolveGroupSharePolicy(ctx, doc, userId); return policy?.permission ?? null; } async function purgeDocumentShareRelations(ctx: any, workspaceId: string, documentId: string) { // 说明:页面被“永久删除/清空回收站”后,需要级联清理共享关系; // 否则被分享者可能在“共享页面/公共页面”里看到幽灵条目(点开 404 / 无标题)。 const directShares = await ctx.db .query("document_shares") .withIndex("by_document", (q: any) => q.eq("document_id", documentId)) .collect(); for (const row of directShares) { await ctx.db.delete(row._id); } const groupShares = await ctx.db .query("document_group_shares") .withIndex("by_document", (q: any) => q.eq("document_id", documentId)) .collect(); const touchedGroupIds = new Set(); for (const row of groupShares) { touchedGroupIds.add(String(row.group_id)); await ctx.db.delete(row._id); } // 删除该文档在相关群组下的“用户可编辑覆盖权限” for (const groupId of touchedGroupIds) { const perms = await ctx.db .query("document_group_user_permissions") .withIndex("by_workspace_group", (q: any) => q.eq("workspace_id", workspaceId).eq("group_id", groupId)) .collect(); for (const p of perms) { if (String(p.document_id) !== documentId) continue; await ctx.db.delete(p._id); } } } export const getMeta = query({ args: { id: v.string() }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); const doc = await ctx.db .query("documents") .withIndex("by_document_id", (q) => q.eq("id", args.id)) .first(); if (!doc) return null; if (doc.deleted_at != null) return null; try { await requireWorkspaceMember(ctx, doc.workspace_id, userId); } catch { return null; } let canEdit = false; let disableDownload = false; let disableCopy = false; if (doc.user_id === userId) { canEdit = true; } else if (doc.access_scope === "public") { canEdit = false; } else { const policy = (await resolveSharePolicy(ctx, doc, userId)) ?? (await resolveGroupSharePolicy(ctx, doc, userId)); if (!policy) return null; canEdit = policy.permission === "edit"; disableDownload = policy.disableDownload; disableCopy = policy.disableCopy; } return { id: doc.id, user_id: doc.user_id, workspace_id: doc.workspace_id, access_scope: doc.access_scope, can_edit: canEdit, disable_download: disableDownload, disable_copy: disableCopy, title: doc.title ?? null, parent_id: doc.parent_id ?? null, created_at: doc.created_at, updated_at: doc.updated_at ?? null, wide_layout: doc.wide_layout ?? null, use_small_text: doc.use_small_text ?? null, show_heading_numbers: doc.show_heading_numbers ?? null, show_toc: doc.show_toc ?? null, show_structure: doc.show_structure ?? null, protect_editing: doc.protect_editing ?? null, show_word_count: doc.show_word_count ?? null, word_count: doc.word_count ?? null, character_count: doc.character_count ?? null, block_count: doc.block_count ?? null, }; }, }); export const getPermissionForUser = query({ args: { userId: v.string(), id: v.string() }, handler: async (ctx, args) => { const doc = await ctx.db .query("documents") .withIndex("by_document_id", (q) => q.eq("id", args.id)) .first(); if (!doc) return null; if (doc.deleted_at != null) return null; // workspace 权限 await requireWorkspaceMember(ctx, doc.workspace_id, args.userId); if (doc.user_id === args.userId) { return { permission: "edit" as const, disableDownload: false, disableCopy: false }; } if (doc.access_scope === "public") { return { permission: "read" as const, disableDownload: false, disableCopy: false }; } const policy = (await resolveSharePolicy(ctx, doc, args.userId)) ?? (await resolveGroupSharePolicy(ctx, doc, args.userId)); if (!policy) return null; return { permission: policy.permission === "edit" ? ("edit" as const) : ("read" as const), disableDownload: policy.disableDownload, disableCopy: policy.disableCopy, }; }, }); export const getMetaForIngest = internalQuery({ args: { userId: v.string(), id: v.string() }, handler: async (ctx, args) => { const doc = await ctx.db .query("documents") .withIndex("by_document_id", (q) => q.eq("id", args.id)) .first(); if (!doc) return null; if (doc.user_id !== args.userId) return null; return { id: doc.id, user_id: doc.user_id, workspace_id: doc.workspace_id, access_scope: doc.access_scope, title: doc.title ?? null, parent_id: doc.parent_id ?? null, created_at: doc.created_at, updated_at: doc.updated_at ?? null, wide_layout: doc.wide_layout ?? null, use_small_text: doc.use_small_text ?? null, show_heading_numbers: doc.show_heading_numbers ?? null, show_toc: doc.show_toc ?? null, show_structure: doc.show_structure ?? null, protect_editing: doc.protect_editing ?? null, show_word_count: doc.show_word_count ?? null, word_count: doc.word_count ?? null, character_count: doc.character_count ?? null, block_count: doc.block_count ?? null, }; }, }); export const getContent = query({ args: { id: v.string() }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); const doc = await ctx.db .query("documents") .withIndex("by_document_id", (q) => q.eq("id", args.id)) .first(); if (!doc) return null; if (doc.deleted_at != null) return null; try { await requireWorkspaceMember(ctx, doc.workspace_id, userId); } catch { return null; } if (doc.user_id === userId) { return { content: doc.content ?? null }; } if (doc.access_scope === "public") { return { content: doc.content ?? null }; } const perm = (await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId)); if (!perm) return null; return { content: doc.content ?? null }; }, }); export const getContentForIngest = internalQuery({ args: { userId: v.string(), id: v.string() }, handler: async (ctx, args) => { const doc = await ctx.db .query("documents") .withIndex("by_document_id", (q) => q.eq("id", args.id)) .first(); if (!doc) return null; if (doc.user_id !== args.userId) return null; return { content: doc.content ?? null }; }, }); export const listByWorkspace = query({ args: { workspaceId: v.string() }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); // workspace 权限 try { await requireWorkspaceMember(ctx, args.workspaceId, userId); } catch { return []; } const docs = await ctx.db .query("documents") .withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId)) .collect(); // 说明:这里仅用于侧边栏数据(不返回垃圾桶 deleted_at != null)。 const alive = docs.filter((d) => d.deleted_at == null); const shares = await ctx.db .query("document_shares") .withIndex("by_workspace_shared_with", (q: any) => q.eq("workspace_id", args.workspaceId).eq("shared_with_user_id", userId), ) .collect(); const directShares = new Map< string, { permission: SharePermission; includeDescendants: boolean } >(); for (const s of shares) { directShares.set(s.document_id, { permission: s.permission as SharePermission, includeDescendants: Boolean(s.include_descendants), }); } const groupMemberships = await ctx.db .query("group_members") .withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", args.workspaceId).eq("user_id", userId)) .collect(); const groupIds = new Set(groupMemberships.map((m: any) => String(m.group_id))); const directGroupShares = new Map(); if (groupIds.size > 0) { for (const gid of groupIds) { const rows = await ctx.db .query("document_group_shares") .withIndex("by_workspace_group", (q: any) => q.eq("workspace_id", args.workspaceId).eq("group_id", gid)) .collect(); for (const r of rows) { const existing = directGroupShares.get(r.document_id); if (!existing) { directGroupShares.set(r.document_id, { includeDescendants: Boolean(r.include_descendants) }); } else { existing.includeDescendants = existing.includeDescendants || Boolean(r.include_descendants); } } } } const parentById = new Map(); for (const d of alive) { parentById.set(d.id, d.parent_id ?? null); } const shareAccessCache = new Map(); const canAccessByShare = (docId: string): boolean => { const cached = shareAccessCache.get(docId); if (typeof cached === "boolean") return cached; if (directShares.has(docId)) { shareAccessCache.set(docId, true); return true; } let parentId = parentById.get(docId) ?? null; for (let depth = 0; depth < 60 && parentId; depth += 1) { const parentShare = directShares.get(parentId); if (parentShare && parentShare.includeDescendants) { shareAccessCache.set(docId, true); return true; } parentId = parentById.get(parentId) ?? null; } shareAccessCache.set(docId, false); return false; }; const groupAccessCache = new Map(); const canAccessByGroupShare = (docId: string): boolean => { const cached = groupAccessCache.get(docId); if (typeof cached === "boolean") return cached; if (directGroupShares.has(docId)) { groupAccessCache.set(docId, true); return true; } let parentId = parentById.get(docId) ?? null; for (let depth = 0; depth < 60 && parentId; depth += 1) { const parentShare = directGroupShares.get(parentId); if (parentShare && parentShare.includeDescendants) { groupAccessCache.set(docId, true); return true; } parentId = parentById.get(parentId) ?? null; } groupAccessCache.set(docId, false); return false; }; const stars = await ctx.db .query("document_stars") .withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", args.workspaceId).eq("user_id", userId)) .collect(); const starredDocIds = new Set(stars.map((s: any) => String(s.document_id))); return alive .filter((d) => { if (d.user_id === userId) return true; if (d.access_scope === "public") return true; return canAccessByShare(d.id) || canAccessByGroupShare(d.id); }) .map((d) => ({ access_scope: d.user_id === userId ? d.access_scope : d.access_scope === "public" ? "public" : "shared", id: d.id, workspace_id: d.workspace_id, title: d.title ?? "无标题", parent_id: d.parent_id ?? null, sort_order: d.sort_order ?? null, is_starred: Boolean(starredDocIds.has(d.id) || d.is_starred), is_template: d.is_template ?? false, created_at: d.created_at, updated_at: d.updated_at ?? null, })); }, }); export const listSearchDataByWorkspace = query({ args: { workspaceId: v.string() }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); // workspace 权限 try { await requireWorkspaceMember(ctx, args.workspaceId, userId); } catch { return []; } const docs = await ctx.db .query("documents") .withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId)) .collect(); const alive = docs.filter((d) => d.deleted_at == null); const shares = await ctx.db .query("document_shares") .withIndex("by_workspace_shared_with", (q: any) => q.eq("workspace_id", args.workspaceId).eq("shared_with_user_id", userId), ) .collect(); const directShares = new Map< string, { permission: SharePermission; includeDescendants: boolean } >(); for (const s of shares) { directShares.set(s.document_id, { permission: s.permission as SharePermission, includeDescendants: Boolean(s.include_descendants), }); } const groupMemberships = await ctx.db .query("group_members") .withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", args.workspaceId).eq("user_id", userId)) .collect(); const groupIds = new Set(groupMemberships.map((m: any) => String(m.group_id))); const directGroupShares = new Map(); if (groupIds.size > 0) { for (const gid of groupIds) { const rows = await ctx.db .query("document_group_shares") .withIndex("by_workspace_group", (q: any) => q.eq("workspace_id", args.workspaceId).eq("group_id", gid)) .collect(); for (const r of rows) { const existing = directGroupShares.get(r.document_id); if (!existing) { directGroupShares.set(r.document_id, { includeDescendants: Boolean(r.include_descendants) }); } else { existing.includeDescendants = existing.includeDescendants || Boolean(r.include_descendants); } } } } const parentById = new Map(); for (const d of alive) { parentById.set(d.id, d.parent_id ?? null); } const shareAccessCache = new Map(); const canAccessByShare = (docId: string): boolean => { const cached = shareAccessCache.get(docId); if (typeof cached === "boolean") return cached; if (directShares.has(docId)) { shareAccessCache.set(docId, true); return true; } let parentId = parentById.get(docId) ?? null; for (let depth = 0; depth < 60 && parentId; depth += 1) { const parentShare = directShares.get(parentId); if (parentShare && parentShare.includeDescendants) { shareAccessCache.set(docId, true); return true; } parentId = parentById.get(parentId) ?? null; } shareAccessCache.set(docId, false); return false; }; const groupAccessCache = new Map(); const canAccessByGroupShare = (docId: string): boolean => { const cached = groupAccessCache.get(docId); if (typeof cached === "boolean") return cached; if (directGroupShares.has(docId)) { groupAccessCache.set(docId, true); return true; } let parentId = parentById.get(docId) ?? null; for (let depth = 0; depth < 60 && parentId; depth += 1) { const parentShare = directGroupShares.get(parentId); if (parentShare && parentShare.includeDescendants) { groupAccessCache.set(docId, true); return true; } parentId = parentById.get(parentId) ?? null; } groupAccessCache.set(docId, false); return false; }; return alive .filter((d) => { if (d.user_id === userId) return true; if (d.access_scope === "public") return true; return canAccessByShare(d.id) || canAccessByGroupShare(d.id); }) .map((d) => ({ id: d.id, workspace_id: d.workspace_id, title: d.title ?? null, created_at: d.created_at ?? null, updated_at: d.updated_at ?? null, raw_text: typeof d.raw_text === "string" && d.raw_text.trim() ? d.raw_text : extractTextFromDocumentContent(d.content ?? null), })); }, }); export const listTrashedByWorkspace = query({ args: { workspaceId: v.string() }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); const docs = await ctx.db .query("documents") .withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId)) .collect(); return docs .filter((d) => d.user_id === userId) .filter((d) => d.deleted_at != null) .sort((a, b) => (b.deleted_at ?? "").localeCompare(a.deleted_at ?? "")) .slice(0, 100) .map((d) => ({ id: d.id, title: d.title ?? null, parent_id: d.parent_id ?? null, deleted_at: d.deleted_at!, access_scope: d.access_scope, })); }, }); export const create = mutation({ args: { id: v.string(), workspaceId: v.string(), parentId: v.union(v.string(), v.null()), title: v.optional(v.union(v.string(), v.null())), accessScope, content: v.optional(v.any()), }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); const siblings = await ctx.db .query("documents") .withIndex("by_workspace_parent", (q) => q.eq("workspace_id", args.workspaceId).eq("parent_id", args.parentId)) .collect(); const sortOrder = siblings.filter((d) => d.deleted_at == null).length; const ts = nowIso(); const title = (args.title ?? "无标题") || "无标题"; const content = typeof args.content === "undefined" ? [] : args.content; const rawText = extractTextFromDocumentContent(content); await ctx.db.insert("documents", { id: args.id, user_id: userId, workspace_id: args.workspaceId, parent_id: args.parentId, title, content, raw_text: rawText, access_scope: args.accessScope, sort_order: sortOrder, is_starred: false, is_template: false, wide_layout: false, use_small_text: false, show_heading_numbers: true, show_toc: false, show_structure: false, protect_editing: false, show_word_count: true, word_count: 0, character_count: 0, block_count: 0, created_at: ts, updated_at: ts, deleted_at: null, deleted_by: null, }); return { id: args.id, title, parent_id: args.parentId, sort_order: sortOrder, is_starred: false, created_at: ts, updated_at: ts, workspace_id: args.workspaceId, access_scope: args.accessScope, is_template: false, }; }, }); export const updateContent = mutation({ args: { id: v.string(), content: v.any() }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); const doc = await ctx.db .query("documents") .withIndex("by_document_id", (q) => q.eq("id", args.id)) .first(); if (!doc) throw new Error("页面不存在"); if (doc.deleted_at != null) throw new Error("页面不存在"); await requireWorkspaceMember(ctx, doc.workspace_id, userId); if (doc.user_id !== userId) { if (doc.access_scope === "public") throw new Error("无权限"); const perm = (await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId)); if (perm !== "edit") throw new Error("无权限"); } const ts = nowIso(); const rawText = extractTextFromDocumentContent(args.content); await ctx.db.patch(doc._id, { content: args.content, raw_text: rawText, updated_at: ts }); // 说明:在 Convex 模式下,把"自动入库/LightRAG 触发"迁到 Convex jobs/actions。 // 采用 debounce,避免频繁保存时触发过多任务。 await enqueueIngestDocumentJob(ctx, { userId: doc.user_id, documentId: args.id, debounceMs: 1500 }); return { ok: true, updated_at: ts }; }, }); export const updateTitle = mutation({ args: { id: v.string(), title: v.union(v.string(), v.null()) }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); const doc = await ctx.db .query("documents") .withIndex("by_document_id", (q) => q.eq("id", args.id)) .first(); if (!doc) throw new Error("页面不存在"); if (doc.deleted_at != null) throw new Error("页面不存在"); await requireWorkspaceMember(ctx, doc.workspace_id, userId); if (doc.user_id !== userId) { if (doc.access_scope === "public") throw new Error("无权限"); const perm = (await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId)); if (perm !== "edit") throw new Error("无权限"); } const ts = nowIso(); await ctx.db.patch(doc._id, { title: args.title, updated_at: ts }); return { ok: true, updated_at: ts }; }, }); export const move = mutation({ args: { id: v.string(), parentId: v.union(v.string(), v.null()), sortOrder: v.number(), }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); const doc = await ctx.db .query("documents") .withIndex("by_document_id", (q) => q.eq("id", args.id)) .first(); if (!doc) throw new Error("页面不存在"); if (doc.user_id !== userId) throw new Error("无权限"); // 说明:仅更新当前节点的 sort_order 会导致兄弟节点出现重复 sort_order, // 前端会按 sort_order + created_at 排序,结果常常表现为“拖拽无效/拖完又回弹”。 // 这里统一对目标父节点(以及跨父移动时的原父节点)做重新编号,保证 sort_order 唯一且连续。 const ts = nowIso(); const compareDocOrder = (a: any, b: any) => { const orderA = typeof a.sort_order === "number" ? a.sort_order : Number.MAX_SAFE_INTEGER; const orderB = typeof b.sort_order === "number" ? b.sort_order : Number.MAX_SAFE_INTEGER; if (orderA !== orderB) return orderA - orderB; // created_at 为 ISO 字符串,按字典序比较即可。 return String(a.created_at ?? "").localeCompare(String(b.created_at ?? "")); }; const clampIndex = (raw: number, max: number) => { const n = Number.isFinite(raw) ? Math.floor(raw) : 0; if (n < 0) return 0; if (n > max) return max; return n; }; const fetchSiblings = async (parentId: string | null) => { const siblings = await ctx.db .query("documents") .withIndex("by_workspace_parent", (q) => q.eq("workspace_id", doc.workspace_id).eq("parent_id", parentId)) .collect(); return siblings .filter((d) => d.user_id === userId) .filter((d) => d.deleted_at == null) .sort(compareDocOrder); }; const applyOrder = async (ordered: any[], parentId: string | null, movedId?: any) => { for (let i = 0; i < ordered.length; i += 1) { const item = ordered[i]; const nextSortOrder = i; const nextParentId = parentId; const patch: Record = {}; if ((item.parent_id ?? null) !== nextParentId) patch.parent_id = nextParentId; if ((item.sort_order ?? null) !== nextSortOrder) patch.sort_order = nextSortOrder; // 说明:只强制更新被移动节点的 updated_at,避免拖拽一次导致大量节点更新时间变化。 if (movedId && item._id === movedId) patch.updated_at = ts; if (Object.keys(patch).length > 0) { await ctx.db.patch(item._id, patch); } } }; const fromParentId = (doc.parent_id ?? null) as string | null; const toParentId = args.parentId; if (fromParentId === toParentId) { const siblings = await fetchSiblings(toParentId); const list = siblings.filter((d) => d.id !== doc.id); const position = clampIndex(args.sortOrder, list.length); list.splice(position, 0, doc); await applyOrder(list, toParentId, doc._id); return { ok: true, updated_at: ts }; } // 先重排原父节点,确保移动后原列表连续。 const oldSiblings = (await fetchSiblings(fromParentId)).filter((d) => d.id !== doc.id); await applyOrder(oldSiblings, fromParentId); // 再重排目标父节点,把节点插入到目标位置。 const newSiblings = (await fetchSiblings(toParentId)).filter((d) => d.id !== doc.id); const position = clampIndex(args.sortOrder, newSiblings.length); newSiblings.splice(position, 0, doc); await applyOrder(newSiblings, toParentId, doc._id); return { ok: true, updated_at: ts }; }, }); export const softDelete = mutation({ args: { id: v.string() }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); const doc = await ctx.db .query("documents") .withIndex("by_document_id", (q) => q.eq("id", args.id)) .first(); if (!doc) throw new Error("页面不存在"); if (doc.user_id !== userId) throw new Error("无权限"); const ts = nowIso(); // 级联软删除:删除父节点时,必须同步删除子节点,否则子节点会因为父节点缺失而“跑到根目录”。 const allInWorkspace = await ctx.db .query("documents") .withIndex("by_workspace", (q) => q.eq("workspace_id", doc.workspace_id)) .collect(); const owned = allInWorkspace.filter((d) => d.user_id === userId); const subtree = collectSubtree(owned, doc.id); let moved = 0; for (const item of subtree) { if (item.deleted_at != null) continue; await ctx.db.patch(item._id, { deleted_at: ts, deleted_by: userId, updated_at: ts }); // 说明:为了避免被分享者仍看到已删除页面(点开 404),软删除时也同步移除共享关系。 // 如需保留共享关系用于恢复后自动生效,可改为仅在 purge/emptyTrash 时清理。 await purgeDocumentShareRelations(ctx, doc.workspace_id, item.id); moved += 1; } return { ok: true, moved, deleted_at: ts }; }, }); export const restore = mutation({ args: { id: v.string() }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); const doc = await ctx.db .query("documents") .withIndex("by_document_id", (q) => q.eq("id", args.id)) .first(); if (!doc) throw new Error("页面不存在"); if (doc.user_id !== userId) throw new Error("无权限"); const ts = nowIso(); const restoreDeletedAt = doc.deleted_at; await ctx.db.patch(doc._id, { deleted_at: null, deleted_by: null, parent_id: null, access_scope: "private", updated_at: ts, }); // 级联恢复:仅恢复“随本次父节点删除而进入垃圾桶”的子节点,避免把之前单独删除的子页面一并恢复。 if (restoreDeletedAt != null) { const allInWorkspace = await ctx.db .query("documents") .withIndex("by_workspace", (q) => q.eq("workspace_id", doc.workspace_id)) .collect(); const owned = allInWorkspace.filter((d) => d.user_id === userId); const subtree = collectSubtree(owned, doc.id); for (const item of subtree) { if (item.id === doc.id) continue; if (item.deleted_at !== restoreDeletedAt) continue; await ctx.db.patch(item._id, { deleted_at: null, deleted_by: null, updated_at: ts }); } } return { ok: true }; }, }); export const purge = mutation({ args: { id: v.string() }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); const doc = await ctx.db .query("documents") .withIndex("by_document_id", (q) => q.eq("id", args.id)) .first(); if (!doc) throw new Error("页面不存在"); if (doc.user_id !== userId) throw new Error("无权限"); // 级联彻底删除:避免父节点被删后,子节点变成孤儿数据。 const allInWorkspace = await ctx.db .query("documents") .withIndex("by_workspace", (q) => q.eq("workspace_id", doc.workspace_id)) .collect(); const owned = allInWorkspace.filter((d) => d.user_id === userId); const subtree = collectSubtree(owned, doc.id); // 删除顺序对当前数据模型无强制要求,这里简单逐个删除即可。 for (const item of subtree) { await purgeDocumentShareRelations(ctx, doc.workspace_id, item.id); await ctx.db.delete(item._id); } return { ok: true, deletedCount: subtree.length }; }, }); export const emptyTrashByWorkspace = mutation({ args: { workspaceId: v.string() }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); // 说明:阶段 4/5 先用"membership 存在即可"的规则,避免引入复杂权限模型。 const membership = await ctx.db .query("workspace_members") .withIndex("by_workspace_user", (q) => q.eq("workspace_id", args.workspaceId).eq("user_id", userId)) .first(); if (!membership) { throw new Error("无权操作该工作空间"); } const docs = await ctx.db .query("documents") .withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId)) .collect(); const toDelete = docs.filter((d) => d.user_id === userId && d.deleted_at != null); for (const d of toDelete) { await purgeDocumentShareRelations(ctx, args.workspaceId, d.id); await ctx.db.delete(d._id); } return { ok: true, deletedCount: toDelete.length }; }, }); export const updateOptions = mutation({ args: { id: v.string(), options: v.object({ wideLayout: v.optional(v.boolean()), smallText: v.optional(v.boolean()), showHeadingNumbers: v.optional(v.boolean()), showToc: v.optional(v.boolean()), showStructure: v.optional(v.boolean()), protectEditing: v.optional(v.boolean()), showWordCount: v.optional(v.boolean()), }), }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); const doc = await ctx.db .query("documents") .withIndex("by_document_id", (q) => q.eq("id", args.id)) .first(); if (!doc) throw new Error("页面不存在"); if (doc.deleted_at != null) throw new Error("页面不存在"); await requireWorkspaceMember(ctx, doc.workspace_id, userId); if (doc.user_id !== userId) { if (doc.access_scope === "public") throw new Error("无权限"); const perm = (await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId)); if (perm !== "edit") throw new Error("无权限"); } const patch: Record = {}; if (typeof args.options.wideLayout === "boolean") patch.wide_layout = args.options.wideLayout; if (typeof args.options.smallText === "boolean") patch.use_small_text = args.options.smallText; if (typeof args.options.showHeadingNumbers === "boolean") patch.show_heading_numbers = args.options.showHeadingNumbers; if (typeof args.options.showToc === "boolean") patch.show_toc = args.options.showToc; if (typeof args.options.showStructure === "boolean") patch.show_structure = args.options.showStructure; if (typeof args.options.protectEditing === "boolean") patch.protect_editing = args.options.protectEditing; if (typeof args.options.showWordCount === "boolean") patch.show_word_count = args.options.showWordCount; if (Object.keys(patch).length === 0) { throw new Error("缺少可更新的选项"); } const ts = nowIso(); await ctx.db.patch(doc._id, { ...patch, updated_at: ts }); return { ok: true, updated_at: ts }; }, }); export const updateStats = mutation({ args: { id: v.string(), wordCount: v.number(), characterCount: v.number(), blockCount: v.number(), }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); const doc = await ctx.db .query("documents") .withIndex("by_document_id", (q) => q.eq("id", args.id)) .first(); if (!doc) throw new Error("页面不存在"); if (doc.deleted_at != null) throw new Error("页面不存在"); await requireWorkspaceMember(ctx, doc.workspace_id, userId); if (doc.user_id !== userId) { if (doc.access_scope === "public") throw new Error("无权限"); const perm = (await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId)); if (perm !== "edit") throw new Error("无权限"); } const ts = nowIso(); await ctx.db.patch(doc._id, { word_count: args.wordCount, character_count: args.characterCount, block_count: args.blockCount, updated_at: ts, }); return { ok: true, updated_at: ts }; }, }); export const duplicate = mutation({ args: { sourceId: v.string(), newId: v.string(), title: v.optional(v.union(v.string(), v.null())), }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); const source = await ctx.db .query("documents") .withIndex("by_document_id", (q) => q.eq("id", args.sourceId)) .first(); if (!source) throw new Error("页面不存在或无权限访问"); if (source.user_id !== userId) throw new Error("页面不存在或无权限访问"); const siblings = await ctx.db .query("documents") .withIndex("by_workspace_parent", (q) => q.eq("workspace_id", source.workspace_id).eq("parent_id", source.parent_id), ) .collect(); const sortOrder = siblings.filter((d) => d.deleted_at == null).length; const ts = nowIso(); const baseTitle = (source.title ?? "无标题").trim() ? (source.title ?? "无标题").trim() : "无标题"; const title = (args.title ?? `${baseTitle} 副本`) || `${baseTitle} 副本`; const rawText = extractTextFromDocumentContent(source.content ?? []); await ctx.db.insert("documents", { id: args.newId, user_id: userId, workspace_id: source.workspace_id, parent_id: source.parent_id, title, content: source.content ?? [], raw_text: rawText, access_scope: source.access_scope, sort_order: sortOrder, is_starred: false, is_template: false, wide_layout: source.wide_layout ?? false, use_small_text: source.use_small_text ?? false, show_heading_numbers: source.show_heading_numbers ?? true, show_toc: source.show_toc ?? false, show_structure: source.show_structure ?? false, protect_editing: source.protect_editing ?? false, show_word_count: source.show_word_count ?? true, word_count: source.word_count ?? 0, character_count: source.character_count ?? 0, block_count: source.block_count ?? 0, created_at: ts, updated_at: ts, deleted_at: null, deleted_by: null, }); return { id: args.newId, title, parent_id: source.parent_id ?? null, sort_order: sortOrder, workspace_id: source.workspace_id, access_scope: source.access_scope, created_at: ts, updated_at: ts, }; }, }); export const listAllForCopy = query({ args: { workspaceId: v.string() }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); const docs = await ctx.db .query("documents") .withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId)) .collect(); return docs .filter((d) => d.user_id === userId) .filter((d) => d.deleted_at == null) .map((d) => ({ id: d.id, title: d.title ?? null, parent_id: d.parent_id ?? null, workspace_id: d.workspace_id, access_scope: d.access_scope, sort_order: d.sort_order ?? null, created_at: d.created_at ?? null, content: d.content ?? null, })); }, });