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";
|
2026-04-14 13:22:29 +08:00
|
|
|
import { getCanonicalDocumentByBusinessId, getCanonicalParentDocumentId } from "./_utils/documentRecord";
|
2026-02-01 08:47:40 +08:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 13:22:29 +08:00
|
|
|
parentId = await getCanonicalParentDocumentId(ctx, parentId);
|
2026-02-01 08:47:40 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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";
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-14 13:22:29 +08:00
|
|
|
parentId = await getCanonicalParentDocumentId(ctx, parentId);
|
2026-02-01 08:47:40 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
2026-04-14 13:22:29 +08:00
|
|
|
const doc = await getCanonicalDocumentByBusinessId<any>(ctx, args.documentId);
|
2026-02-01 08:47:40 +08:00
|
|
|
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("评论线程不存在");
|
|
|
|
|
|
2026-04-14 13:22:29 +08:00
|
|
|
const doc = await getCanonicalDocumentByBusinessId<any>(ctx, String(thread.document_id));
|
2026-02-01 08:47:40 +08:00
|
|
|
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);
|
|
|
|
|
|
2026-04-14 13:22:29 +08:00
|
|
|
const doc = await getCanonicalDocumentByBusinessId<any>(ctx, args.documentId);
|
2026-02-01 08:47:40 +08:00
|
|
|
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("评论线程不存在");
|
|
|
|
|
|
2026-04-14 13:22:29 +08:00
|
|
|
const doc = await getCanonicalDocumentByBusinessId<any>(ctx, String(thread.document_id));
|
2026-02-01 08:47:40 +08:00
|
|
|
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("评论线程不存在");
|
|
|
|
|
|
2026-04-14 13:22:29 +08:00
|
|
|
const doc = await getCanonicalDocumentByBusinessId<any>(ctx, String(thread.document_id));
|
2026-02-01 08:47:40 +08:00
|
|
|
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("评论线程不存在");
|
|
|
|
|
|
2026-04-14 13:22:29 +08:00
|
|
|
const doc = await getCanonicalDocumentByBusinessId<any>(ctx, String(message.document_id));
|
2026-02-01 08:47:40 +08:00
|
|
|
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("评论线程不存在");
|
|
|
|
|
|
2026-04-14 13:22:29 +08:00
|
|
|
const doc = await getCanonicalDocumentByBusinessId<any>(ctx, String(message.document_id));
|
2026-02-01 08:47:40 +08:00
|
|
|
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 };
|
|
|
|
|
},
|
|
|
|
|
});
|