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 { enqueueIngestMindmapJob } from "./_utils/ingestJobs"; const defaultMindmapData = { data: { text: "中心主题" }, children: [], }; async function requireUserId(ctx: any): Promise { const userId = await getAuthUserId(ctx); if (userId === null) { throw new Error("未登录"); } return userId; } function normalizeMindmapId(docId: string, mindmapId: string): string { const raw = String(mindmapId ?? "").trim(); if (raw) return raw; // 兜底:不允许空 mindmapId(否则无法索引) return `legacy-${docId}`; } async function requireOwnedDocument(ctx: any, userId: string, docId: string) { const doc = await ctx.db .query("documents") .withIndex("by_document_id", (q: any) => q.eq("id", docId)) .first(); if (!doc) { throw new Error("页面不存在"); } if (doc.user_id !== userId) { throw new Error("无权限"); } return doc; } export const get = query({ args: { docId: v.string(), mindmapId: v.string() }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); const doc = await requireOwnedDocument(ctx, userId, args.docId); const mindmapId = normalizeMindmapId(args.docId, args.mindmapId); const row = await ctx.db .query("mindmaps") .withIndex("by_doc_mindmap", (q) => q.eq("document_id", args.docId).eq("mindmap_id", mindmapId)) .first(); // 兼容旧逻辑:不存在或已删除时仍返回默认导图,避免前端卡死。 if (!row || row.deleted_at != null) { return { ok: true, data: defaultMindmapData, meta: { workspace_id: doc.workspace_id, document_id: args.docId, mindmap_id: mindmapId, exists: false, deleted_at: row?.deleted_at ?? null, }, }; } return { ok: true, data: row.data ?? defaultMindmapData, meta: { workspace_id: row.workspace_id, document_id: row.document_id, mindmap_id: row.mindmap_id, exists: true, deleted_at: row.deleted_at, created_at: row.created_at, updated_at: row.updated_at, }, }; }, }); export const getForIngest = internalQuery({ args: { userId: v.string(), docId: v.string(), mindmapId: v.string() }, handler: async (ctx, args) => { const doc = await requireOwnedDocument(ctx, args.userId, args.docId); const mindmapId = normalizeMindmapId(args.docId, args.mindmapId); const row = await ctx.db .query("mindmaps") .withIndex("by_doc_mindmap", (q) => q.eq("document_id", args.docId).eq("mindmap_id", mindmapId)) .first(); if (!row || row.deleted_at != null) { return { ok: true, data: defaultMindmapData, meta: { workspace_id: doc.workspace_id, document_id: args.docId, mindmap_id: mindmapId, exists: false, deleted_at: row?.deleted_at ?? null, }, }; } return { ok: true, data: row.data ?? defaultMindmapData, meta: { workspace_id: row.workspace_id, document_id: row.document_id, mindmap_id: row.mindmap_id, exists: true, deleted_at: row.deleted_at, created_at: row.created_at, updated_at: row.updated_at, }, }; }, }); export const put = mutation({ args: { docId: v.string(), mindmapId: v.string(), data: v.any(), createOnly: v.optional(v.boolean()), }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); const doc = await requireOwnedDocument(ctx, userId, args.docId); const mindmapId = normalizeMindmapId(args.docId, args.mindmapId); const existing = await ctx.db .query("mindmaps") .withIndex("by_doc_mindmap", (q) => q.eq("document_id", args.docId).eq("mindmap_id", mindmapId)) .first(); const ts = nowIso(); const payload = args.data ?? defaultMindmapData; if (existing && existing.deleted_at == null && args.createOnly) { return { ok: true, created: false, skipped: true, updated_at: existing.updated_at ?? null }; } if (existing) { await ctx.db.patch(existing._id, { data: payload, updated_at: ts, deleted_at: null, deleted_by: null, }); await enqueueIngestMindmapJob(ctx, { userId, docId: args.docId, mindmapId, debounceMs: 1500 }); return { ok: true, created: false, skipped: false, updated_at: ts }; } await ctx.db.insert("mindmaps", { id: `${args.docId}:${mindmapId}`, user_id: userId, workspace_id: doc.workspace_id, document_id: args.docId, mindmap_id: mindmapId, data: payload, created_at: ts, updated_at: ts, deleted_at: null, deleted_by: null, }); await enqueueIngestMindmapJob(ctx, { userId, docId: args.docId, mindmapId, debounceMs: 1500 }); return { ok: true, created: true, skipped: false, updated_at: ts }; }, }); export const softDelete = mutation({ args: { docId: v.string(), mindmapId: v.string() }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); await requireOwnedDocument(ctx, userId, args.docId); const mindmapId = normalizeMindmapId(args.docId, args.mindmapId); const existing = await ctx.db .query("mindmaps") .withIndex("by_doc_mindmap", (q) => q.eq("document_id", args.docId).eq("mindmap_id", mindmapId)) .first(); if (!existing) { // 兼容:不存在也视为成功 return { ok: true, moved: 0 }; } const ts = nowIso(); await ctx.db.patch(existing._id, { deleted_at: ts, deleted_by: userId, updated_at: ts }); return { ok: true, moved: 1, deleted_at: ts }; }, }); export const restore = mutation({ args: { docId: v.string(), mindmapId: v.string() }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); await requireOwnedDocument(ctx, userId, args.docId); const mindmapId = normalizeMindmapId(args.docId, args.mindmapId); const existing = await ctx.db .query("mindmaps") .withIndex("by_doc_mindmap", (q) => q.eq("document_id", args.docId).eq("mindmap_id", mindmapId)) .first(); if (!existing) { throw new Error("未找到可操作的记录"); } const ts = nowIso(); await ctx.db.patch(existing._id, { deleted_at: null, deleted_by: null, updated_at: ts }); return { ok: true }; }, }); export const purge = mutation({ args: { docId: v.string(), mindmapId: v.string() }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); await requireOwnedDocument(ctx, userId, args.docId); const mindmapId = normalizeMindmapId(args.docId, args.mindmapId); const existing = await ctx.db .query("mindmaps") .withIndex("by_doc_mindmap", (q) => q.eq("document_id", args.docId).eq("mindmap_id", mindmapId)) .first(); if (!existing) { throw new Error("未找到可操作的记录"); } await ctx.db.delete(existing._id); return { ok: true }; }, }); export const listByWorkspace = query({ args: { workspaceId: v.string(), includeDeleted: v.optional(v.boolean()) }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); // 说明:阶段 6 先按 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 rows = await ctx.db .query("mindmaps") .withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId)) .collect(); const includeDeleted = Boolean(args.includeDeleted); return rows .filter((r) => r.user_id === userId) .filter((r) => (includeDeleted ? true : r.deleted_at == null)) .sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? "")) .map((r) => ({ id: r.id, workspace_id: r.workspace_id, document_id: r.document_id, mindmap_id: r.mindmap_id, data: r.data ?? defaultMindmapData, created_at: r.created_at, updated_at: r.updated_at, deleted_at: r.deleted_at, deleted_by: r.deleted_by, })); }, }); export const copyByDocument = mutation({ args: { sourceDocId: v.string(), targetDocId: v.string() }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); const sourceDoc = await requireOwnedDocument(ctx, userId, args.sourceDocId); const targetDoc = await requireOwnedDocument(ctx, userId, args.targetDocId); // 说明:同一工作空间复制最常见;若跨工作空间复制,这里仍允许,但 mindmaps 会落到目标页面的 workspace_id。 const sourceRows = await ctx.db .query("mindmaps") .withIndex("by_workspace", (q) => q.eq("workspace_id", sourceDoc.workspace_id)) .collect(); const sourceMindmaps = sourceRows .filter((r) => r.user_id === userId) .filter((r) => r.document_id === args.sourceDocId) .filter((r) => r.deleted_at == null); let copied = 0; const ts = nowIso(); for (const r of sourceMindmaps) { const existing = await ctx.db .query("mindmaps") .withIndex("by_doc_mindmap", (q) => q.eq("document_id", args.targetDocId).eq("mindmap_id", r.mindmap_id)) .first(); if (existing) continue; await ctx.db.insert("mindmaps", { id: `${args.targetDocId}:${r.mindmap_id}`, user_id: userId, workspace_id: targetDoc.workspace_id, document_id: args.targetDocId, mindmap_id: r.mindmap_id, data: r.data ?? defaultMindmapData, created_at: ts, updated_at: ts, deleted_at: null, deleted_by: null, }); copied += 1; } return { ok: true, copied }; }, }); export const emptyTrashByWorkspace = mutation({ args: { workspaceId: v.string() }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); 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 rows = await ctx.db .query("mindmaps") .withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId)) .collect(); const toDelete = rows.filter((r) => r.user_id === userId && r.deleted_at != null); for (const r of toDelete) { await ctx.db.delete(r._id); } return { ok: true, deletedCount: toDelete.length }; }, });