import { mutation, query } from "./_generated/server"; import { v } from "convex/values"; import { nowIso } from "./_utils/time"; const accessScope = v.union(v.literal("private"), v.literal("shared"), v.literal("public")); export const getMeta = 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.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: { 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: { userId: v.string(), workspaceId: v.string() }, handler: async (ctx, args) => { const docs = await ctx.db .query("documents") .withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId)) .collect(); // 说明:阶段 4 先不做垃圾桶(deleted_at != null),因此这里直接过滤。 return docs .filter((d) => d.user_id === args.userId) .filter((d) => d.deleted_at == null) .map((d) => ({ access_scope: d.access_scope, 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: d.is_starred ?? null, is_template: d.is_template ?? false, created_at: d.created_at, updated_at: d.updated_at ?? null, })); }, }); export const listTrashedByWorkspace = query({ args: { userId: v.string(), workspaceId: v.string() }, handler: async (ctx, args) => { 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 === args.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: { userId: v.string(), 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 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; await ctx.db.insert("documents", { id: args.id, user_id: args.userId, workspace_id: args.workspaceId, parent_id: args.parentId, title, content, 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: { userId: v.string(), id: v.string(), content: v.any() }, handler: async (ctx, args) => { 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 !== args.userId) throw new Error("无权限"); const ts = nowIso(); await ctx.db.patch(doc._id, { content: args.content, updated_at: ts }); return { ok: true, updated_at: ts }; }, }); export const updateTitle = mutation({ args: { userId: v.string(), id: v.string(), title: v.union(v.string(), v.null()) }, handler: async (ctx, args) => { 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 !== args.userId) 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: { userId: v.string(), id: v.string(), parentId: v.union(v.string(), v.null()), sortOrder: v.number(), }, handler: async (ctx, args) => { 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 !== args.userId) throw new Error("无权限"); const ts = nowIso(); await ctx.db.patch(doc._id, { parent_id: args.parentId, sort_order: args.sortOrder, updated_at: ts, }); return { ok: true, updated_at: ts }; }, }); export const softDelete = mutation({ 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) throw new Error("页面不存在"); if (doc.user_id !== args.userId) throw new Error("无权限"); const ts = nowIso(); await ctx.db.patch(doc._id, { deleted_at: ts, deleted_by: args.userId, updated_at: ts }); return { ok: true }; }, }); export const restore = mutation({ 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) throw new Error("页面不存在"); if (doc.user_id !== args.userId) throw new Error("无权限"); const ts = nowIso(); await ctx.db.patch(doc._id, { deleted_at: null, deleted_by: null, parent_id: null, access_scope: "private", updated_at: ts, }); return { ok: true }; }, }); export const purge = mutation({ 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) throw new Error("页面不存在"); if (doc.user_id !== args.userId) throw new Error("无权限"); await ctx.db.delete(doc._id); return { ok: true }; }, }); export const emptyTrashByWorkspace = mutation({ args: { userId: v.string(), workspaceId: v.string() }, handler: async (ctx, args) => { // 说明:阶段 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", args.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 === args.userId && d.deleted_at != null); for (const d of toDelete) { await ctx.db.delete(d._id); } return { ok: true, deletedCount: toDelete.length }; }, }); export const updateOptions = mutation({ args: { userId: v.string(), 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 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 !== args.userId) 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: { userId: v.string(), id: v.string(), wordCount: v.number(), characterCount: v.number(), blockCount: v.number(), }, handler: async (ctx, args) => { 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 !== args.userId) 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: { userId: v.string(), sourceId: v.string(), newId: v.string(), title: v.optional(v.union(v.string(), v.null())), }, handler: async (ctx, args) => { 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 !== args.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} 副本`; await ctx.db.insert("documents", { id: args.newId, user_id: args.userId, workspace_id: source.workspace_id, parent_id: source.parent_id, title, content: source.content ?? [], 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: { userId: v.string(), workspaceId: v.string() }, handler: async (ctx, args) => { 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 === args.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, })); }, });