0.3.4 小图标功能增加

This commit is contained in:
liaibo
2026-01-22 18:53:20 +08:00
parent 71de56850b
commit 25923f308c
25 changed files with 2781 additions and 141 deletions
+332 -17
View File
@@ -2,6 +2,7 @@ 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";
const accessScope = v.union(v.literal("private"), v.literal("shared"), v.literal("public"));
@@ -14,6 +15,108 @@ async function requireUserId(ctx: any): Promise<string> {
return userId;
}
type SharePermission = "read" | "edit";
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 resolveSharePermission(ctx: any, doc: any, userId: string): Promise<SharePermission | null> {
const direct = await ctx.db
.query("document_shares")
.withIndex("by_doc_user", (q: any) => q.eq("document_id", doc.id).eq("shared_with_user_id", userId))
.first();
if (direct) {
return direct.permission as SharePermission;
}
let parentId: string | null = doc.parent_id ?? null;
for (let depth = 0; depth < 60 && parentId; depth += 1) {
const parentShare = await ctx.db
.query("document_shares")
.withIndex("by_doc_user", (q: any) => q.eq("document_id", parentId).eq("shared_with_user_id", userId))
.first();
if (parentShare && parentShare.include_descendants) {
return parentShare.permission as SharePermission;
}
const parent = await ctx.db
.query("documents")
.withIndex("by_document_id", (q: any) => q.eq("id", parentId))
.first();
parentId = parent?.parent_id ?? null;
}
return null;
}
async function resolveGroupSharePermission(ctx: any, doc: any, userId: string): Promise<SharePermission | null> {
const memberships = await ctx.db
.query("group_members")
.withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", doc.workspace_id).eq("user_id", userId))
.collect();
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;
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;
const perm = await permissionFromGroup(documentId, gid);
if (perm === "edit") {
best = "edit";
return true;
}
best = best ?? "read";
}
return false;
};
// 当前页面是否被群组公开
if (await checkDocId(doc.id, false)) return "edit";
// 沿父链查找“包含子页面”的群组公开
let parentId: string | null = doc.parent_id ?? null;
for (let depth = 0; depth < 60 && parentId; depth += 1) {
if (await checkDocId(parentId, true)) return "edit";
const parent = await ctx.db
.query("documents")
.withIndex("by_document_id", (q: any) => q.eq("id", parentId))
.first();
parentId = parent?.parent_id ?? null;
}
return best;
}
export const getMeta = query({
args: { id: v.string() },
handler: async (ctx, args) => {
@@ -24,12 +127,29 @@ export const getMeta = query({
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
if (!doc) return null;
if (doc.user_id !== userId) return null;
if (doc.deleted_at != null) return null;
try {
await requireWorkspaceMember(ctx, doc.workspace_id, userId);
} catch {
return null;
}
let canEdit = false;
if (doc.user_id === userId) {
canEdit = true;
} else if (doc.access_scope === "public") {
canEdit = false;
} else {
const perm = (await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId));
if (!perm) return null;
canEdit = perm === "edit";
}
return {
id: doc.id,
user_id: doc.user_id,
workspace_id: doc.workspace_id,
access_scope: doc.access_scope,
can_edit: canEdit,
title: doc.title ?? null,
parent_id: doc.parent_id ?? null,
created_at: doc.created_at,
@@ -90,7 +210,21 @@ export const getContent = query({
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
if (!doc) return null;
if (doc.user_id !== userId) 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 };
},
});
@@ -113,23 +247,131 @@ export const listByWorkspace = query({
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();
// 说明:阶段 4 先不做垃圾桶deleted_at != null,因此这里直接过滤
return docs
.filter((d) => d.user_id === userId)
.filter((d) => d.deleted_at == null)
// 说明:这里仅用于侧边栏数据(不返回垃圾桶 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.access_scope,
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: d.is_starred ?? 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,
@@ -238,13 +480,20 @@ export const updateContent = mutation({
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
if (!doc) throw new Error("页面不存在");
if (doc.user_id !== userId) 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, { content: args.content, updated_at: ts });
// 说明:在 Convex 模式下,把"自动入库/LightRAG 触发"迁到 Convex jobs/actions。
// 采用 debounce,避免频繁保存时触发过多任务。
await enqueueIngestDocumentJob(ctx, { userId, documentId: args.id, debounceMs: 1500 });
await enqueueIngestDocumentJob(ctx, { userId: doc.user_id, documentId: args.id, debounceMs: 1500 });
return { ok: true, updated_at: ts };
},
});
@@ -259,7 +508,14 @@ export const updateTitle = mutation({
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
if (!doc) throw new Error("页面不存在");
if (doc.user_id !== userId) 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 };
@@ -372,8 +628,23 @@ export const softDelete = mutation({
if (!doc) throw new Error("页面不存在");
if (doc.user_id !== userId) throw new Error("无权限");
const ts = nowIso();
await ctx.db.patch(doc._id, { deleted_at: ts, deleted_by: userId, updated_at: ts });
return { ok: true };
// 级联软删除:删除父节点时,必须同步删除子节点,否则子节点会因为父节点缺失而“跑到根目录”。
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 });
moved += 1;
}
return { ok: true, moved, deleted_at: ts };
},
});
@@ -389,6 +660,7 @@ export const restore = mutation({
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,
@@ -396,6 +668,22 @@ export const restore = mutation({
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 };
},
});
@@ -411,8 +699,21 @@ export const purge = mutation({
.first();
if (!doc) throw new Error("页面不存在");
if (doc.user_id !== userId) throw new Error("无权限");
await ctx.db.delete(doc._id);
return { ok: true };
// 级联彻底删除:避免父节点被删后,子节点变成孤儿数据。
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 ctx.db.delete(item._id);
}
return { ok: true, deletedCount: subtree.length };
},
});
@@ -466,7 +767,14 @@ export const updateOptions = mutation({
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
if (!doc) throw new Error("页面不存在");
if (doc.user_id !== userId) 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;
@@ -503,7 +811,14 @@ export const updateStats = mutation({
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
if (!doc) throw new Error("页面不存在");
if (doc.user_id !== userId) 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,