Files
mnote/wolai-frontend/convex/comments.ts
T

479 lines
16 KiB
TypeScript
Raw Normal View History

2026-02-01 08:47:40 +08:00
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<string> {
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<SharePermission | null> {
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<SharePermission | null> {
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<string>();
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<string, any>();
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<string>();
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<string, any>();
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 };
},
});