import { mutation, query } from "./_generated/server"; import { v } from "convex/values"; import { getAuthUserId } from "@convex-dev/auth/server"; import { nowIso } from "./_utils/time"; async function requireUserId(ctx: any): Promise { const userId = await getAuthUserId(ctx); if (userId === null) { throw new Error("未登录"); } return userId; } 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; } type SharePermission = "read" | "edit"; async function resolveSharePermission(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 direct.permission as SharePermission; } 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 parentShare.permission as SharePermission; } 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 resolveGroupSharePermission(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(); if (!memberships.length) return null; const groupIds = new Set(memberships.map((m: any) => String(m.group_id))); const direct = await ctx.db .query("document_group_shares") .withIndex("by_document", (q: any) => q.eq("document_id", doc.id)) .collect(); for (const row of direct) { if (groupIds.has(String(row.group_id))) { return "read"; } } // 说明:群组共享的“包含子页面”逻辑在 documents.ts 内已实现;这里做一个最小的祖先扫描即可。 let parentId: string | null = doc.parent_id ?? null; for (let depth = 0; depth < 60 && parentId; depth += 1) { const parentShares = await ctx.db .query("document_group_shares") .withIndex("by_document", (q: any) => q.eq("document_id", parentId)) .collect(); for (const row of parentShares) { if (row.include_descendants && groupIds.has(String(row.group_id))) { return "read"; } } 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 requireCanViewDocument(ctx: any, doc: any, userId: string) { await requireWorkspaceMember(ctx, doc.workspace_id, userId); if (doc.user_id === userId) return; if (doc.access_scope === "public") return; const perm = (await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId)); if (!perm) { throw new Error("无权限"); } } async function requireCanModerateDocument(ctx: any, doc: any, userId: string) { await requireWorkspaceMember(ctx, doc.workspace_id, userId); if (doc.user_id === userId) return; 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 pickUserLite = (user: any) => { if (!user) return null; return { id: String(user._id), name: (user.name as string | undefined) ?? null, image: (user.image as string | undefined) ?? null, }; }; export const listThreadsByDocument = query({ args: { documentId: v.string(), includeResolved: 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: any) => q.eq("id", args.documentId)) .first(); if (!doc) throw new Error("页面不存在"); if (doc.deleted_at != null) throw new Error("页面不存在"); await requireCanViewDocument(ctx, doc, userId); const threads = await ctx.db .query("comment_threads") .withIndex("by_document", (q: any) => q.eq("document_id", args.documentId)) .collect(); const includeResolved = Boolean(args.includeResolved); const filtered = threads.filter((t: any) => includeResolved || t.resolved_at == null); filtered.sort((a: any, b: any) => String(b.last_activity_at).localeCompare(String(a.last_activity_at))); const userIds = new Set(); filtered.forEach((t: any) => { if (t.created_by) userIds.add(String(t.created_by)); if (t.last_comment_by) userIds.add(String(t.last_comment_by)); if (t.resolved_by) userIds.add(String(t.resolved_by)); }); const userById = new Map(); for (const uid of userIds) { const u = await ctx.db.get(uid as any); if (u) userById.set(uid, u); } return filtered.map((t: any) => ({ id: t.id, workspaceId: t.workspace_id, documentId: t.document_id, blockId: t.block_id ?? null, resolvedAt: t.resolved_at ?? null, resolvedBy: pickUserLite(userById.get(String(t.resolved_by ?? ""))), commentCount: Number(t.comment_count ?? 0), lastActivityAt: t.last_activity_at, lastCommentPreview: t.last_comment_preview ?? null, lastCommentBy: pickUserLite(userById.get(String(t.last_comment_by ?? ""))), createdAt: t.created_at, createdBy: pickUserLite(userById.get(String(t.created_by ?? ""))), })); }, }); export const listMessagesByThread = query({ args: { threadId: v.string() }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); const thread = await ctx.db .query("comment_threads") .withIndex("by_thread_id", (q: any) => q.eq("id", args.threadId)) .first(); if (!thread) throw new Error("评论线程不存在"); const doc = await ctx.db .query("documents") .withIndex("by_document_id", (q: any) => q.eq("id", thread.document_id)) .first(); if (!doc) throw new Error("页面不存在"); if (doc.deleted_at != null) throw new Error("页面不存在"); await requireCanViewDocument(ctx, doc, userId); const messages = await ctx.db .query("comment_messages") .withIndex("by_thread", (q: any) => q.eq("thread_id", args.threadId)) .collect(); messages.sort((a: any, b: any) => String(a.created_at).localeCompare(String(b.created_at))); const userIds = new Set(); messages.forEach((m: any) => { if (m.created_by) userIds.add(String(m.created_by)); if (m.deleted_by) userIds.add(String(m.deleted_by)); }); const userById = new Map(); for (const uid of userIds) { const u = await ctx.db.get(uid as any); if (u) userById.set(uid, u); } return messages.map((m: any) => ({ id: m.id, threadId: m.thread_id, documentId: m.document_id, blockId: m.block_id ?? null, parentId: m.parent_id ?? null, body: m.deleted_at ? "" : m.body, deletedAt: m.deleted_at ?? null, createdAt: m.created_at, createdBy: pickUserLite(userById.get(String(m.created_by ?? ""))), })); }, }); export const createThread = mutation({ args: { id: v.string(), documentId: v.string(), workspaceId: v.string(), blockId: v.optional(v.union(v.string(), v.null())), messageId: v.string(), body: v.string(), }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); const doc = await ctx.db .query("documents") .withIndex("by_document_id", (q: any) => q.eq("id", args.documentId)) .first(); if (!doc) throw new Error("页面不存在"); if (doc.deleted_at != null) throw new Error("页面不存在"); await requireCanViewDocument(ctx, doc, userId); if (String(doc.workspace_id) !== String(args.workspaceId)) { throw new Error("workspaceId 不匹配"); } const body = args.body.trim(); if (!body) throw new Error("评论内容不能为空"); if (body.length > 4000) throw new Error("评论内容过长(最大 4000 字符)"); const ts = nowIso(); await ctx.db.insert("comment_threads", { id: args.id, workspace_id: args.workspaceId, document_id: args.documentId, block_id: args.blockId ?? null, resolved_at: null, resolved_by: null, comment_count: 1, last_activity_at: ts, last_comment_preview: body.slice(0, 160), last_comment_by: userId, created_by: userId, created_at: ts, }); await ctx.db.insert("comment_messages", { id: args.messageId, thread_id: args.id, workspace_id: args.workspaceId, document_id: args.documentId, block_id: args.blockId ?? null, parent_id: null, body, created_by: userId, created_at: ts, deleted_at: null, deleted_by: null, }); return { ok: true }; }, }); export const reply = mutation({ args: { messageId: v.string(), threadId: v.string(), body: v.string(), }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); const thread = await ctx.db .query("comment_threads") .withIndex("by_thread_id", (q: any) => q.eq("id", args.threadId)) .first(); if (!thread) throw new Error("评论线程不存在"); const doc = await ctx.db .query("documents") .withIndex("by_document_id", (q: any) => q.eq("id", thread.document_id)) .first(); if (!doc) throw new Error("页面不存在"); if (doc.deleted_at != null) throw new Error("页面不存在"); await requireCanViewDocument(ctx, doc, userId); const body = args.body.trim(); if (!body) throw new Error("评论内容不能为空"); if (body.length > 4000) throw new Error("评论内容过长(最大 4000 字符)"); const ts = nowIso(); await ctx.db.insert("comment_messages", { id: args.messageId, thread_id: args.threadId, workspace_id: thread.workspace_id, document_id: thread.document_id, block_id: thread.block_id ?? null, parent_id: null, body, created_by: userId, created_at: ts, deleted_at: null, deleted_by: null, }); await ctx.db.patch(thread._id, { comment_count: Number(thread.comment_count ?? 0) + 1, last_activity_at: ts, last_comment_preview: body.slice(0, 160), last_comment_by: userId, }); return { ok: true }; }, }); export const setResolved = mutation({ args: { threadId: v.string(), resolved: v.boolean() }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); const thread = await ctx.db .query("comment_threads") .withIndex("by_thread_id", (q: any) => q.eq("id", args.threadId)) .first(); if (!thread) throw new Error("评论线程不存在"); const doc = await ctx.db .query("documents") .withIndex("by_document_id", (q: any) => q.eq("id", thread.document_id)) .first(); if (!doc) throw new Error("页面不存在"); if (doc.deleted_at != null) throw new Error("页面不存在"); // 说明:解决/取消解决属于“管理”行为,要求可编辑权限,避免只读用户随意改状态。 await requireCanModerateDocument(ctx, doc, userId); const ts = nowIso(); await ctx.db.patch(thread._id, { resolved_at: args.resolved ? ts : null, resolved_by: args.resolved ? userId : null, last_activity_at: ts, }); return { ok: true }; }, }); export const editMessage = mutation({ args: { messageId: v.string(), body: v.string() }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); const message = await ctx.db .query("comment_messages") .withIndex("by_message_id", (q: any) => q.eq("id", args.messageId)) .first(); if (!message) throw new Error("评论不存在"); if (message.deleted_at != null) throw new Error("该评论已删除"); if (String(message.created_by) !== String(userId)) throw new Error("无权限"); const thread = await ctx.db .query("comment_threads") .withIndex("by_thread_id", (q: any) => q.eq("id", message.thread_id)) .first(); if (!thread) throw new Error("评论线程不存在"); const doc = await ctx.db .query("documents") .withIndex("by_document_id", (q: any) => q.eq("id", message.document_id)) .first(); if (!doc) throw new Error("页面不存在"); if (doc.deleted_at != null) throw new Error("页面不存在"); await requireCanViewDocument(ctx, doc, userId); const body = args.body.trim(); if (!body) throw new Error("评论内容不能为空"); if (body.length > 4000) throw new Error("评论内容过长(最大 4000 字符)"); const ts = nowIso(); await ctx.db.patch(message._id, { body }); const messages = await ctx.db .query("comment_messages") .withIndex("by_thread", (q: any) => q.eq("thread_id", message.thread_id)) .collect(); messages.sort((a: any, b: any) => String(b.created_at).localeCompare(String(a.created_at))); const latestAlive = messages.find((m: any) => m.deleted_at == null) ?? null; await ctx.db.patch(thread._id, { last_activity_at: ts, ...(latestAlive && String(latestAlive.id) === String(message.id) ? { last_comment_preview: body.slice(0, 160), last_comment_by: userId } : {}), }); return { ok: true }; }, }); export const deleteMessage = mutation({ args: { messageId: v.string() }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); const message = await ctx.db .query("comment_messages") .withIndex("by_message_id", (q: any) => q.eq("id", args.messageId)) .first(); if (!message) throw new Error("评论不存在"); if (message.deleted_at != null) return { ok: true }; const thread = await ctx.db .query("comment_threads") .withIndex("by_thread_id", (q: any) => q.eq("id", message.thread_id)) .first(); if (!thread) throw new Error("评论线程不存在"); const doc = await ctx.db .query("documents") .withIndex("by_document_id", (q: any) => q.eq("id", message.document_id)) .first(); if (!doc) throw new Error("页面不存在"); if (doc.deleted_at != null) throw new Error("页面不存在"); if (String(message.created_by) === String(userId)) { await requireCanViewDocument(ctx, doc, userId); } else { await requireCanModerateDocument(ctx, doc, userId); } const ts = nowIso(); await ctx.db.patch(message._id, { deleted_at: ts, deleted_by: userId }); const messages = await ctx.db .query("comment_messages") .withIndex("by_thread", (q: any) => q.eq("thread_id", message.thread_id)) .collect(); messages.sort((a: any, b: any) => String(b.created_at).localeCompare(String(a.created_at))); const latestAlive = messages.find((m: any) => m.deleted_at == null) ?? null; await ctx.db.patch(thread._id, { last_activity_at: ts, last_comment_preview: latestAlive ? String(latestAlive.body ?? "").slice(0, 160) : null, last_comment_by: latestAlive ? String(latestAlive.created_by ?? "") : null, }); return { ok: true }; }, });