0.3.5 共享功能修复
This commit is contained in:
@@ -4,6 +4,7 @@ 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"));
|
||||
|
||||
@@ -17,6 +18,8 @@ async function requireUserId(ctx: any): Promise<string> {
|
||||
|
||||
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")
|
||||
@@ -28,13 +31,17 @@ async function requireWorkspaceMember(ctx: any, workspaceId: string, userId: str
|
||||
return member;
|
||||
}
|
||||
|
||||
async function resolveSharePermission(ctx: any, doc: any, userId: string): Promise<SharePermission | null> {
|
||||
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 direct.permission as SharePermission;
|
||||
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;
|
||||
@@ -44,7 +51,11 @@ async function resolveSharePermission(ctx: any, doc: any, userId: string): Promi
|
||||
.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;
|
||||
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
|
||||
@@ -57,7 +68,12 @@ async function resolveSharePermission(ctx: any, doc: any, userId: string): Promi
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveGroupSharePermission(ctx: any, doc: any, userId: string): Promise<SharePermission | 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))
|
||||
@@ -77,6 +93,8 @@ async function resolveGroupSharePermission(ctx: any, doc: any, userId: string):
|
||||
};
|
||||
|
||||
let best: SharePermission | null = null;
|
||||
let disableDownload = false;
|
||||
let disableCopy = false;
|
||||
|
||||
const checkDocId = async (documentId: string, requireIncludeDesc: boolean) => {
|
||||
const shares = await ctx.db
|
||||
@@ -89,23 +107,25 @@ async function resolveGroupSharePermission(ctx: any, doc: any, userId: string):
|
||||
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";
|
||||
return true;
|
||||
} else {
|
||||
best = best ?? "read";
|
||||
}
|
||||
best = best ?? "read";
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// 当前页面是否被群组公开
|
||||
if (await checkDocId(doc.id, false)) return "edit";
|
||||
await checkDocId(doc.id, false);
|
||||
|
||||
// 沿父链查找“包含子页面”的群组公开
|
||||
let parentId: string | null = doc.parent_id ?? null;
|
||||
for (let depth = 0; depth < 60 && parentId; depth += 1) {
|
||||
if (await checkDocId(parentId, true)) return "edit";
|
||||
await checkDocId(parentId, true);
|
||||
|
||||
const parent = await ctx.db
|
||||
.query("documents")
|
||||
@@ -114,7 +134,48 @@ async function resolveGroupSharePermission(ctx: any, doc: any, userId: string):
|
||||
parentId = parent?.parent_id ?? null;
|
||||
}
|
||||
|
||||
return best;
|
||||
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({
|
||||
@@ -135,14 +196,18 @@ export const getMeta = query({
|
||||
}
|
||||
|
||||
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 perm = (await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId));
|
||||
if (!perm) return null;
|
||||
canEdit = perm === "edit";
|
||||
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,
|
||||
@@ -150,6 +215,8 @@ export const getMeta = query({
|
||||
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,
|
||||
@@ -168,6 +235,37 @@ export const getMeta = query({
|
||||
},
|
||||
});
|
||||
|
||||
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) => {
|
||||
@@ -379,6 +477,134 @@ export const listByWorkspace = query({
|
||||
},
|
||||
});
|
||||
|
||||
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) => {
|
||||
@@ -426,6 +652,7 @@ export const create = mutation({
|
||||
|
||||
const title = (args.title ?? "无标题") || "无标题";
|
||||
const content = typeof args.content === "undefined" ? [] : args.content;
|
||||
const rawText = extractTextFromDocumentContent(content);
|
||||
|
||||
await ctx.db.insert("documents", {
|
||||
id: args.id,
|
||||
@@ -434,6 +661,7 @@ export const create = mutation({
|
||||
parent_id: args.parentId,
|
||||
title,
|
||||
content,
|
||||
raw_text: rawText,
|
||||
access_scope: args.accessScope,
|
||||
sort_order: sortOrder,
|
||||
is_starred: false,
|
||||
@@ -489,7 +717,8 @@ export const updateContent = mutation({
|
||||
if (perm !== "edit") throw new Error("无权限");
|
||||
}
|
||||
const ts = nowIso();
|
||||
await ctx.db.patch(doc._id, { content: args.content, updated_at: ts });
|
||||
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,避免频繁保存时触发过多任务。
|
||||
@@ -641,6 +870,9 @@ export const softDelete = mutation({
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -710,6 +942,7 @@ export const purge = mutation({
|
||||
|
||||
// 删除顺序对当前数据模型无强制要求,这里简单逐个删除即可。
|
||||
for (const item of subtree) {
|
||||
await purgeDocumentShareRelations(ctx, doc.workspace_id, item.id);
|
||||
await ctx.db.delete(item._id);
|
||||
}
|
||||
|
||||
@@ -739,6 +972,7 @@ export const emptyTrashByWorkspace = mutation({
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -857,6 +1091,7 @@ export const duplicate = mutation({
|
||||
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,
|
||||
@@ -865,6 +1100,7 @@ export const duplicate = mutation({
|
||||
parent_id: source.parent_id,
|
||||
title,
|
||||
content: source.content ?? [],
|
||||
raw_text: rawText,
|
||||
access_scope: source.access_scope,
|
||||
sort_order: sortOrder,
|
||||
is_starred: false,
|
||||
|
||||
Reference in New Issue
Block a user