import { internalMutation, internalQuery, mutation, query } from "./_generated/server"; import { v } from "convex/values"; import { requireUserId } from "./_utils/auth"; import { nowIso } from "./_utils/time"; import { enqueueIngestMindmapJob } from "./_utils/ingestJobs"; import { internal } from "./_generated/api"; import { requireCanonicalOwnedDocument } from "./_utils/documentRecord"; const defaultMindmapData = { data: { text: "中心主题" }, children: [], }; function resolveGraceSeconds(): number { const raw = process.env.DELETE_GRACE_SECONDS ?? process.env.NEXT_PUBLIC_DELETE_GRACE_SECONDS ?? "600"; const parsed = Number(raw); if (!Number.isFinite(parsed) || parsed <= 0) { return 600; } return Math.floor(parsed); } 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) { return await requireCanonicalOwnedDocument(ctx, docId, userId); } 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, workspace_id: doc.workspace_id, document_id: args.docId, mindmap_id: mindmapId, 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, workspace_id: doc.workspace_id, document_id: args.docId, mindmap_id: mindmapId, 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, workspace_id: doc.workspace_id, document_id: args.docId, mindmap_id: mindmapId, 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, workspace_id: null, document_id: args.docId, mindmap_id: mindmapId, deleted_at: null, }; } if (existing.deleted_at != null) { // 已在垃圾桶:保持幂等,避免重复删除导致 updated_at 抖动/重复调度 return { ok: true, moved: 0, workspace_id: existing.workspace_id, document_id: args.docId, mindmap_id: mindmapId, deleted_at: existing.deleted_at, }; } const ts = nowIso(); await ctx.db.patch(existing._id, { deleted_at: ts, deleted_by: userId, updated_at: ts }); // 10 分钟宽限(可恢复);到期自动清理(彻底删除)。 // 说明:这里用 scheduler.runAfter 做“按条目调度”,无需全局 cron。 const graceMs = (resolveGraceSeconds() + 5) * 1000; await ctx.scheduler.runAfter(graceMs, internal.mindmaps.purgeIfExpired, { docId: args.docId, mindmapId, deletedAt: ts, }); return { ok: true, moved: 1, workspace_id: existing.workspace_id, document_id: args.docId, mindmap_id: mindmapId, deleted_at: ts, }; }, }); export const purgeIfExpired = internalMutation({ args: { docId: v.string(), mindmapId: v.string(), deletedAt: v.string() }, handler: async (ctx, args) => { 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, deleted: false, reason: "not_found" as const }; } if (existing.deleted_at == null) { return { ok: true, deleted: false, reason: "restored" as const }; } if (existing.deleted_at !== args.deletedAt) { // 删除时间不一致:说明已被恢复/重新删除过,旧调度作废 return { ok: true, deleted: false, reason: "changed" as const }; } const graceSeconds = resolveGraceSeconds(); const deletedTs = Date.parse(existing.deleted_at); if (Number.isFinite(deletedTs)) { const elapsedMs = Date.now() - deletedTs; const graceMs = graceSeconds * 1000; if (elapsedMs < graceMs) { // 兜底:调度延迟/时钟偏差时,重新排一次剩余时间 const remainingMs = Math.max(1000, graceMs - elapsedMs + 5000); await ctx.scheduler.runAfter(remainingMs, internal.mindmaps.purgeIfExpired, { docId: args.docId, mindmapId, deletedAt: args.deletedAt, }); return { ok: true, deleted: false, rescheduled: true, remainingMs }; } } await ctx.db.delete(existing._id); return { ok: true, deleted: true }; }, }); 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, workspace_id: existing.workspace_id, document_id: args.docId, mindmap_id: mindmapId, updated_at: ts, }; }, }); 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, workspace_id: existing.workspace_id, document_id: args.docId, mindmap_id: mindmapId, }; }, }); 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 }; }, });