Files
mnote/wolai-frontend/convex/documents.ts
T
lix-2026 b33ffb99e7 feat: 收口文档桥接与 OnlyOffice/Sidebar 回归
- 为 documents.save/meta/content、blocks.patch 与 sidebar.dataset.list 补齐 Rust 协议映射、共享契约与桥接执行器

- 对齐 BlockNote、Mindmap、OnlyOffice 的保存/路由元信息,并补真实浏览器回归脚本与 OnlyOffice 部署基线

- 忽略 Rust 本地构建产物与 Harness 调试状态文件,避免临时产物进入仓库历史
2026-04-15 03:06:29 +08:00

1397 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { internalQuery, mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { requireUserId } from "./_utils/auth";
import { nowIso } from "./_utils/time";
import { collectSubtree } from "./_utils/documentTree";
import { enqueueIngestDocumentJob } from "./_utils/ingestJobs";
import { extractTextFromDocumentContent } from "./_utils/text";
import { getCanonicalDocumentByBusinessId, getCanonicalParentDocumentId } from "./_utils/documentRecord";
const accessScope = v.union(v.literal("private"), v.literal("shared"), v.literal("public"));
type SharePermission = "read" | "edit";
type SharePolicy = { permission: SharePermission; disableDownload: boolean; disableCopy: boolean };
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;
}
async function resolveSharePolicy(ctx: any, doc: any, userId: string): Promise<SharePolicy | 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 {
permission: direct.permission as SharePermission,
disableDownload: Boolean((direct as any).disable_download),
disableCopy: Boolean((direct as any).disable_copy),
};
}
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 {
permission: parentShare.permission as SharePermission,
disableDownload: Boolean((parentShare as any).disable_download),
disableCopy: Boolean((parentShare as any).disable_copy),
};
}
parentId = await getCanonicalParentDocumentId(ctx, parentId);
}
return null;
}
async function resolveSharePermission(ctx: any, doc: any, userId: string): Promise<SharePermission | null> {
const policy = await resolveSharePolicy(ctx, doc, userId);
return policy?.permission ?? null;
}
async function resolveGroupSharePolicy(ctx: any, doc: any, userId: string): Promise<SharePolicy | 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();
const groupIds = new Set<string>(memberships.map((m: any) => String(m.group_id)));
if (groupIds.size === 0) return null;
const permissionFromGroup = async (documentId: string, groupId: string): Promise<SharePermission> => {
const override = await ctx.db
.query("document_group_user_permissions")
.withIndex("by_doc_group_user", (q: any) =>
q.eq("document_id", documentId).eq("group_id", groupId).eq("user_id", userId),
)
.first();
return override?.permission === "edit" ? "edit" : "read";
};
let best: SharePermission | null = null;
let disableDownload = false;
let disableCopy = false;
const checkDocId = async (documentId: string, requireIncludeDesc: boolean) => {
const shares = await ctx.db
.query("document_group_shares")
.withIndex("by_document", (q: any) => q.eq("document_id", documentId))
.collect();
for (const s of shares) {
const gid = String(s.group_id);
if (!groupIds.has(gid)) continue;
if (requireIncludeDesc && !s.include_descendants) continue;
disableDownload = disableDownload || Boolean((s as any).disable_download);
disableCopy = disableCopy || Boolean((s as any).disable_copy);
const perm = await permissionFromGroup(documentId, gid);
if (perm === "edit") {
best = "edit";
} else {
best = best ?? "read";
}
}
};
// 当前页面是否被群组公开
await checkDocId(doc.id, false);
// 沿父链查找“包含子页面”的群组公开
let parentId: string | null = doc.parent_id ?? null;
for (let depth = 0; depth < 60 && parentId; depth += 1) {
await checkDocId(parentId, true);
parentId = await getCanonicalParentDocumentId(ctx, parentId);
}
if (!best) return null;
return { permission: best, disableDownload, disableCopy };
}
async function resolveGroupSharePermission(ctx: any, doc: any, userId: string): Promise<SharePermission | null> {
const policy = await resolveGroupSharePolicy(ctx, doc, userId);
return policy?.permission ?? null;
}
async function purgeDocumentShareRelations(ctx: any, workspaceId: string, documentId: string) {
// 说明:页面被“永久删除/清空回收站”后,需要级联清理共享关系;
// 否则被分享者可能在“共享页面/公共页面”里看到幽灵条目(点开 404 / 无标题)。
const directShares = await ctx.db
.query("document_shares")
.withIndex("by_document", (q: any) => q.eq("document_id", documentId))
.collect();
for (const row of directShares) {
await ctx.db.delete(row._id);
}
const groupShares = await ctx.db
.query("document_group_shares")
.withIndex("by_document", (q: any) => q.eq("document_id", documentId))
.collect();
const touchedGroupIds = new Set<string>();
for (const row of groupShares) {
touchedGroupIds.add(String(row.group_id));
await ctx.db.delete(row._id);
}
// 删除该文档在相关群组下的“用户可编辑覆盖权限”
for (const groupId of touchedGroupIds) {
const perms = await ctx.db
.query("document_group_user_permissions")
.withIndex("by_workspace_group", (q: any) => q.eq("workspace_id", workspaceId).eq("group_id", groupId))
.collect();
for (const p of perms) {
if (String(p.document_id) !== documentId) continue;
await ctx.db.delete(p._id);
}
}
}
async function purgeDocumentRelatedData(ctx: any, workspaceId: string, documentIds: string[]) {
const uniqueDocIds = Array.from(new Set(documentIds.filter(Boolean)));
if (uniqueDocIds.length === 0) return;
// 1) 思维导图
for (const docId of uniqueDocIds) {
const rows = await ctx.db
.query("mindmaps")
.withIndex("by_doc_mindmap", (q: any) => q.eq("document_id", docId))
.collect();
for (const r of rows) {
await ctx.db.delete(r._id);
}
}
// 2) 附件(含底层 storage 文件)
const assets: any[] = [];
for (const docId of uniqueDocIds) {
const rows = await ctx.db
.query("media_assets")
.withIndex("by_document", (q: any) => q.eq("document_id", docId))
.collect();
assets.push(...rows);
}
if (assets.length) {
const assetIdsToDelete = new Set<string>(assets.map((a) => String(a.id)));
const byStorage = new Map<string, string[]>();
for (const a of assets) {
const sid = (a.storage_id as any) ?? null;
if (!sid) continue;
const list = byStorage.get(String(sid)) ?? [];
list.push(String(a.id));
byStorage.set(String(sid), list);
}
for (const [sid] of byStorage.entries()) {
const refs = await ctx.db
.query("media_assets")
.withIndex("by_storage_id", (q: any) => q.eq("storage_id", sid as any))
.collect();
// 说明:只要仍有“未清理”的引用(包括仍在垃圾桶但可恢复的记录),就不要删底层文件。
const otherAlive = refs.some((r: any) => !assetIdsToDelete.has(String(r.id)) && !r.purged_at);
if (!otherAlive) {
try {
await ctx.storage.delete(sid as any);
} catch {
// ignore
}
}
}
for (const a of assets) {
await ctx.db.delete(a._id);
}
}
// 3) 在线表格(含行数据)
for (const docId of uniqueDocIds) {
const tables = await ctx.db
.query("document_tables")
.withIndex("by_document", (q: any) => q.eq("document_id", docId))
.collect();
for (const table of tables) {
const rows = await ctx.db
.query("document_table_rows")
.withIndex("by_table", (q: any) => q.eq("table_id", table.id))
.collect();
for (const r of rows) {
await ctx.db.delete(r._id);
}
await ctx.db.delete(table._id);
}
}
// 4) 评论
for (const docId of uniqueDocIds) {
const msgs = await ctx.db
.query("comment_messages")
.withIndex("by_document", (q: any) => q.eq("document_id", docId))
.collect();
for (const m of msgs) {
await ctx.db.delete(m._id);
}
const threads = await ctx.db
.query("comment_threads")
.withIndex("by_document", (q: any) => q.eq("document_id", docId))
.collect();
for (const t of threads) {
await ctx.db.delete(t._id);
}
}
// 5) 页面引用/反链(source/target 任一命中即删除)
const deletedRefIds = new Set<string>();
for (const docId of uniqueDocIds) {
const bySource = await ctx.db
.query("page_references")
.withIndex("by_workspace_source", (q: any) => q.eq("workspace_id", workspaceId).eq("source_page_id", docId))
.collect();
for (const r of bySource) {
const id = String(r._id);
if (deletedRefIds.has(id)) continue;
deletedRefIds.add(id);
await ctx.db.delete(r._id);
}
const byTarget = await ctx.db
.query("page_references")
.withIndex("by_workspace_target", (q: any) => q.eq("workspace_id", workspaceId).eq("target_page_id", docId))
.collect();
for (const r of byTarget) {
const id = String(r._id);
if (deletedRefIds.has(id)) continue;
deletedRefIds.add(id);
await ctx.db.delete(r._id);
}
}
// 6) 收藏(避免残留用户数据)
for (const docId of uniqueDocIds) {
const stars = await ctx.db
.query("document_stars")
.withIndex("by_workspace_document_user", (q: any) => q.eq("workspace_id", workspaceId).eq("document_id", docId))
.collect();
for (const s of stars) {
await ctx.db.delete(s._id);
}
}
// 7) 最近访问(避免“最近/历史”里出现幽灵页面)
// 说明:user_recent_pages 当前缺少按 document_id 的索引,这里先全表扫描再过滤。
// 若未来数据量变大,再考虑加 index("by_document", ["document_id"]) 或按 workspace/user 拆分索引。
const recents = await ctx.db.query("user_recent_pages").collect();
if (recents.length) {
const docIdSet = new Set(uniqueDocIds);
for (const r of recents) {
if (!docIdSet.has(String((r as any).document_id ?? ""))) continue;
await ctx.db.delete(r._id);
}
}
// 兜底:如果未来新增了其它 “document_id 外键表”,这里可以继续补充;
// 当前先把最容易产生垃圾、且已出现历史堆积的表(media_assets/mindmaps/表格/评论/引用/收藏)清理掉。
// 同时,避免误删“非 document_id 维度”的用户行为表(如 jobs 等),后续如确认需要可再补齐。
}
export const getMeta = query({
args: { id: v.string() },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await getCanonicalDocumentByBusinessId(ctx, args.id);
if (!doc) return null;
if (doc.deleted_at != null) return null;
try {
await requireWorkspaceMember(ctx, doc.workspace_id, userId);
} catch {
return null;
}
let canEdit = false;
let disableDownload = false;
let disableCopy = false;
if (doc.user_id === userId) {
canEdit = true;
} else if (doc.access_scope === "public") {
canEdit = false;
} else {
const policy = (await resolveSharePolicy(ctx, doc, userId)) ?? (await resolveGroupSharePolicy(ctx, doc, userId));
if (!policy) return null;
canEdit = policy.permission === "edit";
disableDownload = policy.disableDownload;
disableCopy = policy.disableCopy;
}
return {
id: doc.id,
user_id: doc.user_id,
workspace_id: doc.workspace_id,
access_scope: doc.access_scope,
can_edit: canEdit,
disable_download: disableDownload,
disable_copy: disableCopy,
title: doc.title ?? null,
parent_id: doc.parent_id ?? null,
created_at: doc.created_at,
updated_at: doc.updated_at ?? null,
wide_layout: doc.wide_layout ?? null,
use_small_text: doc.use_small_text ?? null,
show_heading_numbers: doc.show_heading_numbers ?? null,
show_toc: doc.show_toc ?? null,
show_structure: doc.show_structure ?? null,
protect_editing: doc.protect_editing ?? null,
show_word_count: doc.show_word_count ?? null,
collapse_backlinks: (doc as any).collapse_backlinks ?? null,
page_font: (doc as any).page_font ?? null,
layout_density: (doc as any).layout_density ?? null,
hide_child_pages: (doc as any).hide_child_pages ?? null,
show_block_ref_count: (doc as any).show_block_ref_count ?? null,
embed_default_block_id: (doc as any).embed_default_block_id ?? null,
word_count: doc.word_count ?? null,
character_count: doc.character_count ?? null,
block_count: doc.block_count ?? null,
todo_total_count: (doc as any).todo_total_count ?? null,
todo_done_count: (doc as any).todo_done_count ?? null,
};
},
});
export const getPermissionForUser = query({
args: { userId: v.string(), id: v.string() },
handler: async (ctx, args) => {
const doc = await getCanonicalDocumentByBusinessId(ctx, args.id);
if (!doc) return null;
if (doc.deleted_at != null) return null;
// workspace 权限
await requireWorkspaceMember(ctx, doc.workspace_id, args.userId);
if (doc.user_id === args.userId) {
return { permission: "edit" as const, disableDownload: false, disableCopy: false };
}
if (doc.access_scope === "public") {
return { permission: "read" as const, disableDownload: false, disableCopy: false };
}
const policy = (await resolveSharePolicy(ctx, doc, args.userId)) ?? (await resolveGroupSharePolicy(ctx, doc, args.userId));
if (!policy) return null;
return {
permission: policy.permission === "edit" ? ("edit" as const) : ("read" as const),
disableDownload: policy.disableDownload,
disableCopy: policy.disableCopy,
};
},
});
export const getMetaForIngest = internalQuery({
args: { userId: v.string(), id: v.string() },
handler: async (ctx, args) => {
const doc = await getCanonicalDocumentByBusinessId(ctx, args.id);
if (!doc) return null;
if (doc.user_id !== args.userId) return null;
return {
id: doc.id,
user_id: doc.user_id,
workspace_id: doc.workspace_id,
access_scope: doc.access_scope,
title: doc.title ?? null,
parent_id: doc.parent_id ?? null,
created_at: doc.created_at,
updated_at: doc.updated_at ?? null,
wide_layout: doc.wide_layout ?? null,
use_small_text: doc.use_small_text ?? null,
show_heading_numbers: doc.show_heading_numbers ?? null,
show_toc: doc.show_toc ?? null,
show_structure: doc.show_structure ?? null,
protect_editing: doc.protect_editing ?? null,
show_word_count: doc.show_word_count ?? null,
word_count: doc.word_count ?? null,
character_count: doc.character_count ?? null,
block_count: doc.block_count ?? null,
};
},
});
export const getContent = query({
args: { id: v.string() },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await getCanonicalDocumentByBusinessId(ctx, args.id);
if (!doc) return null;
if (doc.deleted_at != null) return null;
try {
await requireWorkspaceMember(ctx, doc.workspace_id, userId);
} catch {
return null;
}
if (doc.user_id === userId) {
return {
content: doc.content ?? null,
revision: doc.content_revision ?? 0,
conflict_detection_key:
doc.content_conflict_key ??
`${args.id}:${doc.content_revision ?? 0}`,
};
}
if (doc.access_scope === "public") {
return {
content: doc.content ?? null,
revision: doc.content_revision ?? 0,
conflict_detection_key:
doc.content_conflict_key ??
`${args.id}:${doc.content_revision ?? 0}`,
};
}
const perm = (await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId));
if (!perm) return null;
return {
content: doc.content ?? null,
revision: doc.content_revision ?? 0,
conflict_detection_key:
doc.content_conflict_key ??
`${args.id}:${doc.content_revision ?? 0}`,
};
},
});
export const getContentForIngest = internalQuery({
args: { userId: v.string(), id: v.string() },
handler: async (ctx, args) => {
const doc = await getCanonicalDocumentByBusinessId(ctx, args.id);
if (!doc) return null;
if (doc.user_id !== args.userId) return null;
return {
content: doc.content ?? null,
revision: doc.content_revision ?? 0,
conflict_detection_key:
doc.content_conflict_key ??
`${args.id}:${doc.content_revision ?? 0}`,
};
},
});
export const listByWorkspace = query({
args: { workspaceId: v.string() },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
// workspace 权限
try {
await requireWorkspaceMember(ctx, args.workspaceId, userId);
} catch {
return [];
}
const docs = await ctx.db
.query("documents")
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
.collect();
// 说明:这里仅用于侧边栏数据(不返回垃圾桶 deleted_at != null)。
const alive = docs.filter((d) => d.deleted_at == null);
const shares = await ctx.db
.query("document_shares")
.withIndex("by_workspace_shared_with", (q: any) =>
q.eq("workspace_id", args.workspaceId).eq("shared_with_user_id", userId),
)
.collect();
const directShares = new Map<
string,
{ permission: SharePermission; includeDescendants: boolean }
>();
for (const s of shares) {
directShares.set(s.document_id, {
permission: s.permission as SharePermission,
includeDescendants: Boolean(s.include_descendants),
});
}
const groupMemberships = await ctx.db
.query("group_members")
.withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", args.workspaceId).eq("user_id", userId))
.collect();
const groupIds = new Set<string>(groupMemberships.map((m: any) => String(m.group_id)));
const directGroupShares = new Map<string, { includeDescendants: boolean }>();
if (groupIds.size > 0) {
for (const gid of groupIds) {
const rows = await ctx.db
.query("document_group_shares")
.withIndex("by_workspace_group", (q: any) => q.eq("workspace_id", args.workspaceId).eq("group_id", gid))
.collect();
for (const r of rows) {
const existing = directGroupShares.get(r.document_id);
if (!existing) {
directGroupShares.set(r.document_id, { includeDescendants: Boolean(r.include_descendants) });
} else {
existing.includeDescendants = existing.includeDescendants || Boolean(r.include_descendants);
}
}
}
}
const parentById = new Map<string, string | null>();
for (const d of alive) {
parentById.set(d.id, d.parent_id ?? null);
}
const shareAccessCache = new Map<string, boolean>();
const canAccessByShare = (docId: string): boolean => {
const cached = shareAccessCache.get(docId);
if (typeof cached === "boolean") return cached;
if (directShares.has(docId)) {
shareAccessCache.set(docId, true);
return true;
}
let parentId = parentById.get(docId) ?? null;
for (let depth = 0; depth < 60 && parentId; depth += 1) {
const parentShare = directShares.get(parentId);
if (parentShare && parentShare.includeDescendants) {
shareAccessCache.set(docId, true);
return true;
}
parentId = parentById.get(parentId) ?? null;
}
shareAccessCache.set(docId, false);
return false;
};
const groupAccessCache = new Map<string, boolean>();
const canAccessByGroupShare = (docId: string): boolean => {
const cached = groupAccessCache.get(docId);
if (typeof cached === "boolean") return cached;
if (directGroupShares.has(docId)) {
groupAccessCache.set(docId, true);
return true;
}
let parentId = parentById.get(docId) ?? null;
for (let depth = 0; depth < 60 && parentId; depth += 1) {
const parentShare = directGroupShares.get(parentId);
if (parentShare && parentShare.includeDescendants) {
groupAccessCache.set(docId, true);
return true;
}
parentId = parentById.get(parentId) ?? null;
}
groupAccessCache.set(docId, false);
return false;
};
const stars = await ctx.db
.query("document_stars")
.withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", args.workspaceId).eq("user_id", userId))
.collect();
const starredDocIds = new Set<string>(stars.map((s: any) => String(s.document_id)));
return alive
.filter((d) => {
if (d.user_id === userId) return true;
if (d.access_scope === "public") return true;
return canAccessByShare(d.id) || canAccessByGroupShare(d.id);
})
.map((d) => ({
access_scope:
d.user_id === userId ? d.access_scope : d.access_scope === "public" ? "public" : "shared",
id: d.id,
workspace_id: d.workspace_id,
title: d.title ?? "无标题",
parent_id: d.parent_id ?? null,
sort_order: d.sort_order ?? null,
is_starred: Boolean(starredDocIds.has(d.id) || d.is_starred),
is_template: d.is_template ?? false,
created_at: d.created_at,
updated_at: d.updated_at ?? null,
}));
},
});
export const listSearchDataByWorkspace = query({
args: { workspaceId: v.string() },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
// workspace 权限
try {
await requireWorkspaceMember(ctx, args.workspaceId, userId);
} catch {
return [];
}
const docs = await ctx.db
.query("documents")
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
.collect();
const alive = docs.filter((d) => d.deleted_at == null);
const shares = await ctx.db
.query("document_shares")
.withIndex("by_workspace_shared_with", (q: any) =>
q.eq("workspace_id", args.workspaceId).eq("shared_with_user_id", userId),
)
.collect();
const directShares = new Map<
string,
{ permission: SharePermission; includeDescendants: boolean }
>();
for (const s of shares) {
directShares.set(s.document_id, {
permission: s.permission as SharePermission,
includeDescendants: Boolean(s.include_descendants),
});
}
const groupMemberships = await ctx.db
.query("group_members")
.withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", args.workspaceId).eq("user_id", userId))
.collect();
const groupIds = new Set<string>(groupMemberships.map((m: any) => String(m.group_id)));
const directGroupShares = new Map<string, { includeDescendants: boolean }>();
if (groupIds.size > 0) {
for (const gid of groupIds) {
const rows = await ctx.db
.query("document_group_shares")
.withIndex("by_workspace_group", (q: any) => q.eq("workspace_id", args.workspaceId).eq("group_id", gid))
.collect();
for (const r of rows) {
const existing = directGroupShares.get(r.document_id);
if (!existing) {
directGroupShares.set(r.document_id, { includeDescendants: Boolean(r.include_descendants) });
} else {
existing.includeDescendants = existing.includeDescendants || Boolean(r.include_descendants);
}
}
}
}
const parentById = new Map<string, string | null>();
for (const d of alive) {
parentById.set(d.id, d.parent_id ?? null);
}
const shareAccessCache = new Map<string, boolean>();
const canAccessByShare = (docId: string): boolean => {
const cached = shareAccessCache.get(docId);
if (typeof cached === "boolean") return cached;
if (directShares.has(docId)) {
shareAccessCache.set(docId, true);
return true;
}
let parentId = parentById.get(docId) ?? null;
for (let depth = 0; depth < 60 && parentId; depth += 1) {
const parentShare = directShares.get(parentId);
if (parentShare && parentShare.includeDescendants) {
shareAccessCache.set(docId, true);
return true;
}
parentId = parentById.get(parentId) ?? null;
}
shareAccessCache.set(docId, false);
return false;
};
const groupAccessCache = new Map<string, boolean>();
const canAccessByGroupShare = (docId: string): boolean => {
const cached = groupAccessCache.get(docId);
if (typeof cached === "boolean") return cached;
if (directGroupShares.has(docId)) {
groupAccessCache.set(docId, true);
return true;
}
let parentId = parentById.get(docId) ?? null;
for (let depth = 0; depth < 60 && parentId; depth += 1) {
const parentShare = directGroupShares.get(parentId);
if (parentShare && parentShare.includeDescendants) {
groupAccessCache.set(docId, true);
return true;
}
parentId = parentById.get(parentId) ?? null;
}
groupAccessCache.set(docId, false);
return false;
};
return alive
.filter((d) => {
if (d.user_id === userId) return true;
if (d.access_scope === "public") return true;
return canAccessByShare(d.id) || canAccessByGroupShare(d.id);
})
.map((d) => ({
id: d.id,
workspace_id: d.workspace_id,
title: d.title ?? null,
created_at: d.created_at ?? null,
updated_at: d.updated_at ?? null,
raw_text:
typeof d.raw_text === "string" && d.raw_text.trim()
? d.raw_text
: extractTextFromDocumentContent(d.content ?? null),
}));
},
});
export const listTrashedByWorkspace = query({
args: { workspaceId: v.string() },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const docs = await ctx.db
.query("documents")
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
.collect();
return docs
.filter((d) => d.user_id === userId)
.filter((d) => d.deleted_at != null)
.sort((a, b) => (b.deleted_at ?? "").localeCompare(a.deleted_at ?? ""))
.slice(0, 100)
.map((d) => ({
id: d.id,
title: d.title ?? null,
parent_id: d.parent_id ?? null,
deleted_at: d.deleted_at!,
access_scope: d.access_scope,
}));
},
});
export const create = mutation({
args: {
id: v.string(),
workspaceId: v.string(),
parentId: v.union(v.string(), v.null()),
title: v.optional(v.union(v.string(), v.null())),
accessScope,
content: v.optional(v.any()),
},
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const existing = await getCanonicalDocumentByBusinessId(ctx, args.id);
if (existing && existing.deleted_at == null) {
throw new Error("页面已存在");
}
const siblings = await ctx.db
.query("documents")
.withIndex("by_workspace_parent", (q) => q.eq("workspace_id", args.workspaceId).eq("parent_id", args.parentId))
.collect();
const sortOrder = siblings.filter((d) => d.deleted_at == null).length;
const ts = nowIso();
const title = (args.title ?? "无标题") || "无标题";
const content = typeof args.content === "undefined" ? [] : args.content;
const rawText = extractTextFromDocumentContent(content);
await ctx.db.insert("documents", {
id: args.id,
user_id: userId,
workspace_id: args.workspaceId,
parent_id: args.parentId,
title,
content,
content_revision: 0,
content_conflict_key: `${args.id}:0`,
raw_text: rawText,
access_scope: args.accessScope,
sort_order: sortOrder,
is_starred: false,
is_template: false,
wide_layout: false,
use_small_text: false,
show_heading_numbers: true,
show_toc: false,
show_structure: false,
protect_editing: false,
show_word_count: true,
collapse_backlinks: false,
page_font: "default",
layout_density: "normal",
hide_child_pages: false,
show_block_ref_count: false,
embed_default_block_id: null,
word_count: 0,
character_count: 0,
block_count: 0,
todo_total_count: 0,
todo_done_count: 0,
created_at: ts,
updated_at: ts,
deleted_at: null,
deleted_by: null,
});
return {
id: args.id,
title,
parent_id: args.parentId,
sort_order: sortOrder,
is_starred: false,
created_at: ts,
updated_at: ts,
workspace_id: args.workspaceId,
access_scope: args.accessScope,
is_template: false,
};
},
});
export const updateContent = mutation({
args: {
id: v.string(),
content: v.any(),
expectedRevision: v.optional(v.union(v.number(), v.null())),
conflictDetectionKey: v.optional(v.union(v.string(), v.null())),
},
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await getCanonicalDocumentByBusinessId(ctx, args.id);
if (!doc) throw new Error("页面不存在");
if (doc.deleted_at != null) throw new Error("页面不存在");
await requireWorkspaceMember(ctx, doc.workspace_id, userId);
if (doc.user_id !== userId) {
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 currentRevision = doc.content_revision ?? 0;
const currentConflictDetectionKey =
doc.content_conflict_key ??
`${args.id}:${currentRevision}`;
if (
typeof args.expectedRevision === "number" &&
Number.isInteger(args.expectedRevision) &&
args.expectedRevision >= 0 &&
args.expectedRevision !== currentRevision
) {
throw new Error("正文内容已变更,请刷新后重试");
}
if (
typeof args.conflictDetectionKey === "string" &&
args.conflictDetectionKey.trim() &&
args.conflictDetectionKey.trim() !== currentConflictDetectionKey
) {
throw new Error("正文冲突检测失败,请刷新后重试");
}
const ts = nowIso();
const rawText = extractTextFromDocumentContent(args.content);
const nextRevision = currentRevision + 1;
const nextConflictDetectionKey = `${args.id}:${nextRevision}`;
await ctx.db.patch(doc._id, {
content: args.content,
content_revision: nextRevision,
content_conflict_key: nextConflictDetectionKey,
raw_text: rawText,
updated_at: ts,
});
// 说明:在 Convex 模式下,把"自动入库/LightRAG 触发"迁到 Convex jobs/actions。
// 采用 debounce,避免频繁保存时触发过多任务。
await enqueueIngestDocumentJob(ctx, { userId: doc.user_id, documentId: args.id, debounceMs: 1500 });
return {
ok: true,
updated_at: ts,
revision: nextRevision,
conflict_detection_key: nextConflictDetectionKey,
};
},
});
export const updateTitle = mutation({
args: { id: v.string(), title: v.union(v.string(), v.null()) },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await getCanonicalDocumentByBusinessId(ctx, args.id);
if (!doc) throw new Error("页面不存在");
if (doc.deleted_at != null) throw new Error("页面不存在");
await requireWorkspaceMember(ctx, doc.workspace_id, userId);
if (doc.user_id !== userId) {
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 ts = nowIso();
await ctx.db.patch(doc._id, { title: args.title, updated_at: ts });
return { ok: true, updated_at: ts };
},
});
export const setTemplate = mutation({
args: { id: v.string(), isTemplate: v.boolean() },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await getCanonicalDocumentByBusinessId(ctx, args.id);
if (!doc) throw new Error("页面不存在");
if (doc.deleted_at != null) throw new Error("页面不存在");
await requireWorkspaceMember(ctx, doc.workspace_id, userId);
if (doc.user_id !== userId) {
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 ts = nowIso();
await ctx.db.patch(doc._id, { is_template: args.isTemplate, updated_at: ts });
return { ok: true, updated_at: ts };
},
});
export const move = mutation({
args: {
id: v.string(),
parentId: v.union(v.string(), v.null()),
sortOrder: v.number(),
},
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await getCanonicalDocumentByBusinessId(ctx, args.id);
if (!doc) throw new Error("页面不存在");
if (doc.user_id !== userId) throw new Error("无权限");
// 说明:仅更新当前节点的 sort_order 会导致兄弟节点出现重复 sort_order
// 前端会按 sort_order + created_at 排序,结果常常表现为“拖拽无效/拖完又回弹”。
// 这里统一对目标父节点(以及跨父移动时的原父节点)做重新编号,保证 sort_order 唯一且连续。
const ts = nowIso();
const compareDocOrder = (a: any, b: any) => {
const orderA = typeof a.sort_order === "number" ? a.sort_order : Number.MAX_SAFE_INTEGER;
const orderB = typeof b.sort_order === "number" ? b.sort_order : Number.MAX_SAFE_INTEGER;
if (orderA !== orderB) return orderA - orderB;
// created_at 为 ISO 字符串,按字典序比较即可。
return String(a.created_at ?? "").localeCompare(String(b.created_at ?? ""));
};
const clampIndex = (raw: number, max: number) => {
const n = Number.isFinite(raw) ? Math.floor(raw) : 0;
if (n < 0) return 0;
if (n > max) return max;
return n;
};
const fetchSiblings = async (parentId: string | null) => {
const siblings = await ctx.db
.query("documents")
.withIndex("by_workspace_parent", (q) => q.eq("workspace_id", doc.workspace_id).eq("parent_id", parentId))
.collect();
return siblings
.filter((d) => d.user_id === userId)
.filter((d) => d.deleted_at == null)
.sort(compareDocOrder);
};
const applyOrder = async (ordered: any[], parentId: string | null, movedId?: any) => {
for (let i = 0; i < ordered.length; i += 1) {
const item = ordered[i];
const nextSortOrder = i;
const nextParentId = parentId;
const patch: Record<string, unknown> = {};
if ((item.parent_id ?? null) !== nextParentId) patch.parent_id = nextParentId;
if ((item.sort_order ?? null) !== nextSortOrder) patch.sort_order = nextSortOrder;
// 说明:只强制更新被移动节点的 updated_at,避免拖拽一次导致大量节点更新时间变化。
if (movedId && item._id === movedId) patch.updated_at = ts;
if (Object.keys(patch).length > 0) {
await ctx.db.patch(item._id, patch);
}
}
};
const fromParentId = (doc.parent_id ?? null) as string | null;
const toParentId = args.parentId;
if (fromParentId === toParentId) {
const siblings = await fetchSiblings(toParentId);
const list = siblings.filter((d) => d.id !== doc.id);
const position = clampIndex(args.sortOrder, list.length);
list.splice(position, 0, doc);
await applyOrder(list, toParentId, doc._id);
return { ok: true, updated_at: ts };
}
// 先重排原父节点,确保移动后原列表连续。
const oldSiblings = (await fetchSiblings(fromParentId)).filter((d) => d.id !== doc.id);
await applyOrder(oldSiblings, fromParentId);
// 再重排目标父节点,把节点插入到目标位置。
const newSiblings = (await fetchSiblings(toParentId)).filter((d) => d.id !== doc.id);
const position = clampIndex(args.sortOrder, newSiblings.length);
newSiblings.splice(position, 0, doc);
await applyOrder(newSiblings, toParentId, doc._id);
return { ok: true, updated_at: ts };
},
});
export const softDelete = mutation({
args: { id: v.string() },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await getCanonicalDocumentByBusinessId(ctx, args.id);
if (!doc) throw new Error("页面不存在");
if (doc.user_id !== userId) throw new Error("无权限");
const ts = nowIso();
// 级联软删除:删除父节点时,必须同步删除子节点,否则子节点会因为父节点缺失而“跑到根目录”。
const allInWorkspace = await ctx.db
.query("documents")
.withIndex("by_workspace", (q) => q.eq("workspace_id", doc.workspace_id))
.collect();
const owned = allInWorkspace.filter((d) => d.user_id === userId);
const subtree = collectSubtree(owned, doc.id);
let moved = 0;
for (const item of subtree) {
if (item.deleted_at != null) continue;
await ctx.db.patch(item._id, { deleted_at: ts, deleted_by: userId, updated_at: ts });
// 说明:为了避免被分享者仍看到已删除页面(点开 404),软删除时也同步移除共享关系。
// 如需保留共享关系用于恢复后自动生效,可改为仅在 purge/emptyTrash 时清理。
await purgeDocumentShareRelations(ctx, doc.workspace_id, item.id);
moved += 1;
}
return { ok: true, moved, deleted_at: ts };
},
});
export const restore = mutation({
args: { id: v.string() },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await getCanonicalDocumentByBusinessId(ctx, args.id);
if (!doc) throw new Error("页面不存在");
if (doc.user_id !== userId) throw new Error("无权限");
const ts = nowIso();
const restoreDeletedAt = doc.deleted_at;
await ctx.db.patch(doc._id, {
deleted_at: null,
deleted_by: null,
parent_id: null,
access_scope: "private",
updated_at: ts,
});
// 级联恢复:仅恢复“随本次父节点删除而进入垃圾桶”的子节点,避免把之前单独删除的子页面一并恢复。
if (restoreDeletedAt != null) {
const allInWorkspace = await ctx.db
.query("documents")
.withIndex("by_workspace", (q) => q.eq("workspace_id", doc.workspace_id))
.collect();
const owned = allInWorkspace.filter((d) => d.user_id === userId);
const subtree = collectSubtree(owned, doc.id);
for (const item of subtree) {
if (item.id === doc.id) continue;
if (item.deleted_at !== restoreDeletedAt) continue;
await ctx.db.patch(item._id, { deleted_at: null, deleted_by: null, updated_at: ts });
}
}
return { ok: true };
},
});
export const purge = mutation({
args: { id: v.string() },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await getCanonicalDocumentByBusinessId(ctx, args.id);
if (!doc) throw new Error("页面不存在");
if (doc.user_id !== userId) throw new Error("无权限");
// 级联彻底删除:避免父节点被删后,子节点变成孤儿数据。
const allInWorkspace = await ctx.db
.query("documents")
.withIndex("by_workspace", (q) => q.eq("workspace_id", doc.workspace_id))
.collect();
const owned = allInWorkspace.filter((d) => d.user_id === userId);
const subtree = collectSubtree(owned, doc.id);
// 删除顺序对当前数据模型无强制要求,这里简单逐个删除即可。
await purgeDocumentRelatedData(ctx, doc.workspace_id, subtree.map((d) => d.id));
for (const item of subtree) {
await purgeDocumentShareRelations(ctx, doc.workspace_id, item.id);
await ctx.db.delete(item._id);
}
return { ok: true, deletedCount: subtree.length };
},
});
export const emptyTrashByWorkspace = mutation({
args: { workspaceId: v.string() },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
// 说明:阶段 4/5 先用"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 docs = await ctx.db
.query("documents")
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
.collect();
const toDelete = docs.filter((d) => d.user_id === userId && d.deleted_at != null);
await purgeDocumentRelatedData(ctx, args.workspaceId, toDelete.map((d) => d.id));
for (const d of toDelete) {
await purgeDocumentShareRelations(ctx, args.workspaceId, d.id);
await ctx.db.delete(d._id);
}
return { ok: true, deletedCount: toDelete.length };
},
});
export const updateOptions = mutation({
args: {
id: v.string(),
options: v.object({
wideLayout: v.optional(v.boolean()),
smallText: v.optional(v.boolean()),
showHeadingNumbers: v.optional(v.boolean()),
showToc: v.optional(v.boolean()),
showStructure: v.optional(v.boolean()),
protectEditing: v.optional(v.boolean()),
showWordCount: v.optional(v.boolean()),
collapseBacklinks: v.optional(v.boolean()),
pageFont: v.optional(v.union(v.literal("default"), v.literal("song"), v.literal("kai"))),
layoutDensity: v.optional(v.union(v.literal("compact"), v.literal("normal"), v.literal("spacious"))),
hideChildPages: v.optional(v.boolean()),
showBlockRefCount: v.optional(v.boolean()),
embedDefaultBlockId: v.optional(v.union(v.string(), v.null())),
}),
},
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await getCanonicalDocumentByBusinessId(ctx, args.id);
if (!doc) throw new Error("页面不存在");
if (doc.deleted_at != null) throw new Error("页面不存在");
await requireWorkspaceMember(ctx, doc.workspace_id, userId);
if (doc.user_id !== userId) {
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 patch: Record<string, unknown> = {};
if (typeof args.options.wideLayout === "boolean") patch.wide_layout = args.options.wideLayout;
if (typeof args.options.smallText === "boolean") patch.use_small_text = args.options.smallText;
if (typeof args.options.showHeadingNumbers === "boolean")
patch.show_heading_numbers = args.options.showHeadingNumbers;
if (typeof args.options.showToc === "boolean") patch.show_toc = args.options.showToc;
if (typeof args.options.showStructure === "boolean") patch.show_structure = args.options.showStructure;
if (typeof args.options.protectEditing === "boolean") patch.protect_editing = args.options.protectEditing;
if (typeof args.options.showWordCount === "boolean") patch.show_word_count = args.options.showWordCount;
if (typeof args.options.collapseBacklinks === "boolean") patch.collapse_backlinks = args.options.collapseBacklinks;
if (typeof (args.options as any).pageFont === "string") patch.page_font = (args.options as any).pageFont;
if (typeof (args.options as any).layoutDensity === "string") patch.layout_density = (args.options as any).layoutDensity;
if (typeof (args.options as any).hideChildPages === "boolean") patch.hide_child_pages = (args.options as any).hideChildPages;
if (typeof (args.options as any).showBlockRefCount === "boolean")
patch.show_block_ref_count = (args.options as any).showBlockRefCount;
if (typeof (args.options as any).embedDefaultBlockId === "string" || (args.options as any).embedDefaultBlockId === null) {
patch.embed_default_block_id = (args.options as any).embedDefaultBlockId;
}
if (Object.keys(patch).length === 0) {
throw new Error("缺少可更新的选项");
}
const ts = nowIso();
await ctx.db.patch(doc._id, { ...patch, updated_at: ts });
return { ok: true, updated_at: ts };
},
});
export const updateStats = mutation({
args: {
id: v.string(),
wordCount: v.number(),
characterCount: v.number(),
blockCount: v.number(),
todoTotal: v.optional(v.number()),
todoDone: v.optional(v.number()),
},
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await getCanonicalDocumentByBusinessId(ctx, args.id);
if (!doc) throw new Error("页面不存在");
if (doc.deleted_at != null) throw new Error("页面不存在");
await requireWorkspaceMember(ctx, doc.workspace_id, userId);
if (doc.user_id !== userId) {
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 ts = nowIso();
await ctx.db.patch(doc._id, {
word_count: args.wordCount,
character_count: args.characterCount,
block_count: args.blockCount,
todo_total_count: typeof args.todoTotal === "number" ? args.todoTotal : (doc as any).todo_total_count ?? 0,
todo_done_count: typeof args.todoDone === "number" ? args.todoDone : (doc as any).todo_done_count ?? 0,
updated_at: ts,
});
return { ok: true, updated_at: ts };
},
});
export const duplicate = mutation({
args: {
sourceId: v.string(),
newId: v.string(),
title: v.optional(v.union(v.string(), v.null())),
},
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const source = await getCanonicalDocumentByBusinessId(ctx, args.sourceId);
if (!source) throw new Error("页面不存在或无权限访问");
if (source.user_id !== userId) throw new Error("页面不存在或无权限访问");
const existingTarget = await getCanonicalDocumentByBusinessId(ctx, args.newId);
if (existingTarget && existingTarget.deleted_at == null) {
throw new Error("目标页面已存在");
}
const siblings = await ctx.db
.query("documents")
.withIndex("by_workspace_parent", (q) =>
q.eq("workspace_id", source.workspace_id).eq("parent_id", source.parent_id),
)
.collect();
const sortOrder = siblings.filter((d) => d.deleted_at == null).length;
const ts = nowIso();
const baseTitle = (source.title ?? "无标题").trim() ? (source.title ?? "无标题").trim() : "无标题";
const title = (args.title ?? `${baseTitle} 副本`) || `${baseTitle} 副本`;
const rawText = extractTextFromDocumentContent(source.content ?? []);
await ctx.db.insert("documents", {
id: args.newId,
user_id: userId,
workspace_id: source.workspace_id,
parent_id: source.parent_id,
title,
content: source.content ?? [],
raw_text: rawText,
access_scope: source.access_scope,
sort_order: sortOrder,
is_starred: false,
is_template: false,
wide_layout: source.wide_layout ?? false,
use_small_text: source.use_small_text ?? false,
show_heading_numbers: source.show_heading_numbers ?? true,
show_toc: source.show_toc ?? false,
show_structure: source.show_structure ?? false,
protect_editing: source.protect_editing ?? false,
show_word_count: source.show_word_count ?? true,
collapse_backlinks: (source as any).collapse_backlinks ?? false,
page_font: (source as any).page_font ?? "default",
layout_density: (source as any).layout_density ?? "normal",
hide_child_pages: (source as any).hide_child_pages ?? false,
show_block_ref_count: (source as any).show_block_ref_count ?? false,
embed_default_block_id: (source as any).embed_default_block_id ?? null,
word_count: source.word_count ?? 0,
character_count: source.character_count ?? 0,
block_count: source.block_count ?? 0,
todo_total_count: (source as any).todo_total_count ?? 0,
todo_done_count: (source as any).todo_done_count ?? 0,
created_at: ts,
updated_at: ts,
deleted_at: null,
deleted_by: null,
});
return {
id: args.newId,
title,
parent_id: source.parent_id ?? null,
sort_order: sortOrder,
workspace_id: source.workspace_id,
access_scope: source.access_scope,
created_at: ts,
updated_at: ts,
};
},
});
export const listAllForCopy = query({
args: { workspaceId: v.string() },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const docs = await ctx.db
.query("documents")
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
.collect();
return docs
.filter((d) => d.user_id === userId)
.filter((d) => d.deleted_at == null)
.map((d) => ({
id: d.id,
title: d.title ?? null,
parent_id: d.parent_id ?? null,
workspace_id: d.workspace_id,
access_scope: d.access_scope,
sort_order: d.sort_order ?? null,
created_at: d.created_at ?? null,
content: d.content ?? null,
}));
},
});