Files
mnote/wolai-frontend/convex/documents.ts
T
2026-01-24 12:32:51 +08:00

1164 lines
40 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 { getAuthUserId } from "@convex-dev/auth/server";
import { nowIso } from "./_utils/time";
import { collectSubtree } from "./_utils/documentTree";
import { enqueueIngestDocumentJob } from "./_utils/ingestJobs";
import { extractTextFromDocumentContent } from "./_utils/text";
const accessScope = v.union(v.literal("private"), v.literal("shared"), v.literal("public"));
async function requireUserId(ctx: any): Promise<string> {
const userId = await getAuthUserId(ctx);
if (userId === null) {
throw new Error("未登录");
}
return userId;
}
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),
};
}
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 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);
const parent = await ctx.db
.query("documents")
.withIndex("by_document_id", (q: any) => q.eq("id", parentId))
.first();
parentId = parent?.parent_id ?? null;
}
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);
}
}
}
export const getMeta = query({
args: { id: v.string() },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
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,
word_count: doc.word_count ?? null,
character_count: doc.character_count ?? null,
block_count: doc.block_count ?? null,
};
},
});
export const getPermissionForUser = query({
args: { userId: v.string(), id: v.string() },
handler: async (ctx, args) => {
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
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 ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
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 ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
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 };
}
if (doc.access_scope === "public") {
return { content: doc.content ?? null };
}
const perm = (await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId));
if (!perm) return null;
return { content: doc.content ?? null };
},
});
export const getContentForIngest = internalQuery({
args: { userId: v.string(), id: v.string() },
handler: async (ctx, args) => {
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
if (!doc) return null;
if (doc.user_id !== args.userId) return null;
return { content: doc.content ?? null };
},
});
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 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,
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,
word_count: 0,
character_count: 0,
block_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() },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
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();
const rawText = extractTextFromDocumentContent(args.content);
await ctx.db.patch(doc._id, { content: args.content, 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 };
},
});
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 ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
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 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 ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
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 ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
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 ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
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 ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
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);
// 删除顺序对当前数据模型无强制要求,这里简单逐个删除即可。
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);
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()),
}),
},
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
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 (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(),
},
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
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,
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 ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.sourceId))
.first();
if (!source) throw new Error("页面不存在或无权限访问");
if (source.user_id !== userId) 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,
word_count: source.word_count ?? 0,
character_count: source.character_count ?? 0,
block_count: source.block_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,
}));
},
});