0.3.4 小图标功能增加
This commit is contained in:
+12
@@ -8,13 +8,19 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type * as _utils_documentTree from "../_utils/documentTree.js";
|
||||
import type * as _utils_id from "../_utils/id.js";
|
||||
import type * as _utils_ingestJobs from "../_utils/ingestJobs.js";
|
||||
import type * as _utils_lightrag from "../_utils/lightrag.js";
|
||||
import type * as _utils_text from "../_utils/text.js";
|
||||
import type * as _utils_time from "../_utils/time.js";
|
||||
import type * as auth from "../auth.js";
|
||||
import type * as documentGroupShares from "../documentGroupShares.js";
|
||||
import type * as documentShares from "../documentShares.js";
|
||||
import type * as documentStars from "../documentStars.js";
|
||||
import type * as documents from "../documents.js";
|
||||
import type * as groupMembers from "../groupMembers.js";
|
||||
import type * as groups from "../groups.js";
|
||||
import type * as http from "../http.js";
|
||||
import type * as jobs from "../jobs.js";
|
||||
import type * as mediaAssets from "../mediaAssets.js";
|
||||
@@ -33,13 +39,19 @@ import type {
|
||||
} from "convex/server";
|
||||
|
||||
declare const fullApi: ApiFromModules<{
|
||||
"_utils/documentTree": typeof _utils_documentTree;
|
||||
"_utils/id": typeof _utils_id;
|
||||
"_utils/ingestJobs": typeof _utils_ingestJobs;
|
||||
"_utils/lightrag": typeof _utils_lightrag;
|
||||
"_utils/text": typeof _utils_text;
|
||||
"_utils/time": typeof _utils_time;
|
||||
auth: typeof auth;
|
||||
documentGroupShares: typeof documentGroupShares;
|
||||
documentShares: typeof documentShares;
|
||||
documentStars: typeof documentStars;
|
||||
documents: typeof documents;
|
||||
groupMembers: typeof groupMembers;
|
||||
groups: typeof groups;
|
||||
http: typeof http;
|
||||
jobs: typeof jobs;
|
||||
mediaAssets: typeof mediaAssets;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Generated `api` utility.
|
||||
*
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Generated data model types.
|
||||
*
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Generated utilities for implementing server-side Convex query and mutation functions.
|
||||
*
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Generated utilities for implementing server-side Convex query and mutation functions.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
export type ParentLinkedRow = {
|
||||
id: string;
|
||||
parent_id?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 收集以 rootId 为根的整棵子树(包含根节点本身)。
|
||||
*
|
||||
* 说明:
|
||||
* - 仅依赖 (id, parent_id) 字段,便于在 Convex 与前端共用同一套遍历逻辑。
|
||||
* - 会做去重与环检测,避免异常数据导致死循环。
|
||||
*/
|
||||
export function collectSubtree<T extends ParentLinkedRow>(rows: readonly T[], rootId: string): T[] {
|
||||
const byParentId = new Map<string | null, T[]>();
|
||||
for (const row of rows) {
|
||||
const parentId = (row.parent_id ?? null) as string | null;
|
||||
const bucket = byParentId.get(parentId);
|
||||
if (bucket) bucket.push(row);
|
||||
else byParentId.set(parentId, [row]);
|
||||
}
|
||||
|
||||
const root = rows.find((r) => r.id === rootId);
|
||||
if (!root) return [];
|
||||
|
||||
const visited = new Set<string>();
|
||||
const stack: T[] = [root];
|
||||
const result: T[] = [];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop()!;
|
||||
if (visited.has(current.id)) continue;
|
||||
visited.add(current.id);
|
||||
result.push(current);
|
||||
|
||||
const children = byParentId.get(current.id) ?? [];
|
||||
// 倒序入栈,保持更接近“原列表顺序”的遍历结果(不影响正确性)
|
||||
for (let i = children.length - 1; i >= 0; i -= 1) {
|
||||
stack.push(children[i]!);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
import { nowIso } from "./_utils/time";
|
||||
|
||||
type Permission = "read" | "edit";
|
||||
|
||||
async function requireUserId(ctx: any): Promise<string> {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (userId === null) throw new Error("未登录");
|
||||
return String(userId);
|
||||
}
|
||||
|
||||
async function requireOwnedDocument(ctx: any, documentId: string, userId: string) {
|
||||
const doc = await ctx.db
|
||||
.query("documents")
|
||||
.withIndex("by_document_id", (q: any) => q.eq("id", documentId))
|
||||
.first();
|
||||
if (!doc) throw new Error("页面不存在");
|
||||
if (doc.user_id !== userId) throw new Error("无权限");
|
||||
return doc;
|
||||
}
|
||||
|
||||
async function requireGroup(ctx: any, groupId: string) {
|
||||
const group = await ctx.db
|
||||
.query("groups")
|
||||
.withIndex("by_group_id", (q: any) => q.eq("id", groupId))
|
||||
.first();
|
||||
if (!group) throw new Error("群组不存在");
|
||||
return group;
|
||||
}
|
||||
|
||||
export const listByDocument = query({
|
||||
args: { documentId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
const doc = await requireOwnedDocument(ctx, args.documentId, userId);
|
||||
|
||||
const shares = await ctx.db
|
||||
.query("document_group_shares")
|
||||
.withIndex("by_document", (q: any) => q.eq("document_id", doc.id))
|
||||
.collect();
|
||||
|
||||
const result = [];
|
||||
for (const share of shares) {
|
||||
const group = await ctx.db
|
||||
.query("groups")
|
||||
.withIndex("by_group_id", (q: any) => q.eq("id", share.group_id))
|
||||
.first();
|
||||
|
||||
const perms = await ctx.db
|
||||
.query("document_group_user_permissions")
|
||||
.withIndex("by_workspace_group", (q: any) =>
|
||||
q.eq("workspace_id", doc.workspace_id).eq("group_id", share.group_id),
|
||||
)
|
||||
.collect();
|
||||
|
||||
const editableUserIds = perms
|
||||
.filter((p: any) => p.document_id === doc.id && p.permission === "edit")
|
||||
.map((p: any) => String(p.user_id));
|
||||
|
||||
result.push({
|
||||
groupId: share.group_id,
|
||||
groupName: group?.name ?? null,
|
||||
includeDescendants: share.include_descendants,
|
||||
editableUserIds,
|
||||
updatedAt: share.updated_at,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
export const upsert = mutation({
|
||||
args: {
|
||||
documentId: v.string(),
|
||||
groupId: v.string(),
|
||||
includeDescendants: v.optional(v.boolean()),
|
||||
editableUserIds: v.array(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
const doc = await requireOwnedDocument(ctx, args.documentId, userId);
|
||||
const group = await requireGroup(ctx, args.groupId);
|
||||
if (group.workspace_id !== doc.workspace_id) throw new Error("群组不属于当前工作空间");
|
||||
|
||||
const ts = nowIso();
|
||||
const includeDescendants = Boolean(args.includeDescendants);
|
||||
|
||||
const existingShare = await ctx.db
|
||||
.query("document_group_shares")
|
||||
.withIndex("by_doc_group", (q: any) => q.eq("document_id", doc.id).eq("group_id", args.groupId))
|
||||
.first();
|
||||
|
||||
if (existingShare) {
|
||||
await ctx.db.patch(existingShare._id, {
|
||||
include_descendants: includeDescendants,
|
||||
updated_at: ts,
|
||||
});
|
||||
} else {
|
||||
await ctx.db.insert("document_group_shares", {
|
||||
workspace_id: doc.workspace_id,
|
||||
group_id: args.groupId,
|
||||
document_id: doc.id,
|
||||
include_descendants: includeDescendants,
|
||||
created_by: userId,
|
||||
created_at: ts,
|
||||
updated_at: ts,
|
||||
});
|
||||
}
|
||||
|
||||
// 更新成员权限:默认只读,仅对可编辑用户写入 override(edit)。
|
||||
const existingPerms = await ctx.db
|
||||
.query("document_group_user_permissions")
|
||||
.withIndex("by_workspace_group", (q: any) =>
|
||||
q.eq("workspace_id", doc.workspace_id).eq("group_id", args.groupId),
|
||||
)
|
||||
.collect();
|
||||
|
||||
const targetSet = new Set(args.editableUserIds.map(String));
|
||||
for (const p of existingPerms) {
|
||||
if (p.document_id !== doc.id) continue;
|
||||
const uid = String(p.user_id);
|
||||
if (!targetSet.has(uid)) {
|
||||
await ctx.db.delete(p._id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const uid of targetSet) {
|
||||
const existing = existingPerms.find(
|
||||
(p: any) => p.document_id === doc.id && String(p.user_id) === uid,
|
||||
);
|
||||
if (existing) {
|
||||
if (existing.permission !== "edit") {
|
||||
await ctx.db.patch(existing._id, { permission: "edit" as Permission, updated_at: ts });
|
||||
}
|
||||
} else {
|
||||
await ctx.db.insert("document_group_user_permissions", {
|
||||
workspace_id: doc.workspace_id,
|
||||
group_id: args.groupId,
|
||||
document_id: doc.id,
|
||||
user_id: uid,
|
||||
permission: "edit" as Permission,
|
||||
created_at: ts,
|
||||
updated_at: ts,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.db.patch(doc._id, { updated_at: ts });
|
||||
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const remove = mutation({
|
||||
args: { documentId: v.string(), groupId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
const doc = await requireOwnedDocument(ctx, args.documentId, userId);
|
||||
|
||||
const existingShare = await ctx.db
|
||||
.query("document_group_shares")
|
||||
.withIndex("by_doc_group", (q: any) => q.eq("document_id", doc.id).eq("group_id", args.groupId))
|
||||
.first();
|
||||
if (existingShare) {
|
||||
await ctx.db.delete(existingShare._id);
|
||||
}
|
||||
|
||||
const perms = await ctx.db
|
||||
.query("document_group_user_permissions")
|
||||
.withIndex("by_workspace_group", (q: any) =>
|
||||
q.eq("workspace_id", doc.workspace_id).eq("group_id", args.groupId),
|
||||
)
|
||||
.collect();
|
||||
|
||||
for (const p of perms) {
|
||||
if (p.document_id !== doc.id) continue;
|
||||
await ctx.db.delete(p._id);
|
||||
}
|
||||
|
||||
await ctx.db.patch(doc._id, { updated_at: nowIso() });
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const listPublicByWorkspace = query({
|
||||
args: { workspaceId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
const member = await ctx.db
|
||||
.query("workspace_members")
|
||||
.withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", args.workspaceId).eq("user_id", userId))
|
||||
.first();
|
||||
if (!member) throw new Error("无权限");
|
||||
|
||||
const myGroups = await ctx.db
|
||||
.query("group_members")
|
||||
.withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", args.workspaceId).eq("user_id", userId))
|
||||
.collect();
|
||||
|
||||
const myGroupIds = new Set<string>(myGroups.map((g: any) => String(g.group_id)));
|
||||
|
||||
const myCreatedShares = await ctx.db
|
||||
.query("document_group_shares")
|
||||
.withIndex("by_workspace_created_by", (q: any) => q.eq("workspace_id", args.workspaceId).eq("created_by", userId))
|
||||
.collect();
|
||||
|
||||
const visibleGroupIds = new Set<string>(myGroupIds);
|
||||
myCreatedShares.forEach((s: any) => visibleGroupIds.add(String(s.group_id)));
|
||||
|
||||
const groups = await ctx.db
|
||||
.query("groups")
|
||||
.withIndex("by_workspace", (q: any) => q.eq("workspace_id", args.workspaceId))
|
||||
.collect();
|
||||
|
||||
const groupNameById = new Map<string, string>();
|
||||
for (const g of groups) {
|
||||
if (visibleGroupIds.has(String(g.id))) {
|
||||
groupNameById.set(String(g.id), String(g.name ?? ""));
|
||||
}
|
||||
}
|
||||
|
||||
const sharesByGroup = [];
|
||||
for (const groupId of visibleGroupIds) {
|
||||
const rows = await ctx.db
|
||||
.query("document_group_shares")
|
||||
.withIndex("by_workspace_group", (q: any) => q.eq("workspace_id", args.workspaceId).eq("group_id", groupId))
|
||||
.collect();
|
||||
|
||||
if (rows.length === 0) continue;
|
||||
|
||||
const docMap = new Map<string, { includeDescendants: boolean }>();
|
||||
for (const r of rows) {
|
||||
const existing = docMap.get(r.document_id);
|
||||
if (!existing) {
|
||||
docMap.set(r.document_id, { includeDescendants: Boolean(r.include_descendants) });
|
||||
} else {
|
||||
existing.includeDescendants = existing.includeDescendants || Boolean(r.include_descendants);
|
||||
}
|
||||
}
|
||||
|
||||
sharesByGroup.push({
|
||||
groupId,
|
||||
groupName: groupNameById.get(groupId) ?? null,
|
||||
documents: Array.from(docMap.entries()).map(([documentId, v]) => ({
|
||||
documentId,
|
||||
includeDescendants: v.includeDescendants,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// 按群组名称排序,便于折叠展示
|
||||
sharesByGroup.sort((a: any, b: any) => String(a.groupName ?? "").localeCompare(String(b.groupName ?? "")));
|
||||
|
||||
return sharesByGroup;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
import { nowIso } from "./_utils/time";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
|
||||
const permission = v.union(v.literal("read"), v.literal("edit"));
|
||||
|
||||
async function requireUserId(ctx: any): Promise<string> {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (userId === null) throw new Error("未登录");
|
||||
return String(userId);
|
||||
}
|
||||
|
||||
function normalizeUsername(raw: string): string {
|
||||
const username = String(raw ?? "").trim();
|
||||
if (!username) throw new Error("用户名不能为空");
|
||||
if (username.length < 2 || username.length > 32) throw new Error("用户名长度需为 2-32 个字符");
|
||||
if (/\s/.test(username)) throw new Error("用户名不能包含空格");
|
||||
return username;
|
||||
}
|
||||
|
||||
async function requireOwnedDocument(ctx: any, documentId: string, userId: string) {
|
||||
const doc = await ctx.db
|
||||
.query("documents")
|
||||
.withIndex("by_document_id", (q: any) => q.eq("id", documentId))
|
||||
.first();
|
||||
if (!doc) throw new Error("页面不存在");
|
||||
if (doc.user_id !== userId) throw new Error("无权限");
|
||||
return doc;
|
||||
}
|
||||
|
||||
async function resolveUserIdByUsername(ctx: any, rawUsername: string): Promise<{ userId: string; username: string }> {
|
||||
const username = normalizeUsername(rawUsername);
|
||||
|
||||
// 说明:用户名存放在 Convex Auth 的 users.name;当前没有索引,这里用 filter 做扫描。
|
||||
const user = await ctx.db
|
||||
.query("users")
|
||||
.filter((q: any) => q.eq(q.field("name"), username))
|
||||
.first();
|
||||
if (!user) throw new Error("未找到该用户(对方可能未注册或未设置用户名)");
|
||||
|
||||
return { userId: String(user._id), username: String(user.name ?? "") || username };
|
||||
}
|
||||
|
||||
export const listByDocument = query({
|
||||
args: { documentId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
const doc = await requireOwnedDocument(ctx, args.documentId, userId);
|
||||
|
||||
const shares = await ctx.db
|
||||
.query("document_shares")
|
||||
.withIndex("by_document", (q: any) => q.eq("document_id", doc.id))
|
||||
.collect();
|
||||
|
||||
const result = [];
|
||||
for (const share of shares) {
|
||||
const user = await ctx.db.get(share.shared_with_user_id as Id<"users">);
|
||||
result.push({
|
||||
userId: share.shared_with_user_id,
|
||||
username: user?.name ?? null,
|
||||
permission: share.permission,
|
||||
includeDescendants: share.include_descendants,
|
||||
createdAt: share.created_at,
|
||||
updatedAt: share.updated_at,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
export const upsert = mutation({
|
||||
args: {
|
||||
documentId: v.string(),
|
||||
username: v.string(),
|
||||
permission,
|
||||
includeDescendants: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
const doc = await requireOwnedDocument(ctx, args.documentId, userId);
|
||||
const { userId: sharedWithUserId } = await resolveUserIdByUsername(ctx, args.username);
|
||||
if (sharedWithUserId === userId) throw new Error("不能共享给自己");
|
||||
|
||||
const ts = nowIso();
|
||||
const includeDescendants = Boolean(args.includeDescendants);
|
||||
|
||||
// 确保对方成为 workspace member(否则无法看到该 workspace 的数据)
|
||||
const existingMember = await ctx.db
|
||||
.query("workspace_members")
|
||||
.withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", doc.workspace_id).eq("user_id", sharedWithUserId))
|
||||
.first();
|
||||
if (!existingMember) {
|
||||
await ctx.db.insert("workspace_members", {
|
||||
workspace_id: doc.workspace_id,
|
||||
user_id: sharedWithUserId,
|
||||
role: "member",
|
||||
is_default: false,
|
||||
created_at: ts,
|
||||
});
|
||||
}
|
||||
|
||||
const existingShare = await ctx.db
|
||||
.query("document_shares")
|
||||
.withIndex("by_doc_user", (q: any) =>
|
||||
q.eq("document_id", doc.id).eq("shared_with_user_id", sharedWithUserId),
|
||||
)
|
||||
.first();
|
||||
|
||||
if (existingShare) {
|
||||
await ctx.db.patch(existingShare._id, {
|
||||
permission: args.permission,
|
||||
include_descendants: includeDescendants,
|
||||
updated_at: ts,
|
||||
});
|
||||
} else {
|
||||
await ctx.db.insert("document_shares", {
|
||||
workspace_id: doc.workspace_id,
|
||||
document_id: doc.id,
|
||||
shared_with_user_id: sharedWithUserId,
|
||||
permission: args.permission,
|
||||
include_descendants: includeDescendants,
|
||||
created_by: userId,
|
||||
created_at: ts,
|
||||
updated_at: ts,
|
||||
});
|
||||
}
|
||||
|
||||
// 仅更新时间戳:共享不改变页面自身的 access_scope(避免把自己的页面从“私密”分区挪走)
|
||||
await ctx.db.patch(doc._id, { updated_at: ts });
|
||||
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const remove = mutation({
|
||||
args: { documentId: v.string(), sharedWithUserId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
const doc = await requireOwnedDocument(ctx, args.documentId, userId);
|
||||
|
||||
const existingShare = await ctx.db
|
||||
.query("document_shares")
|
||||
.withIndex("by_doc_user", (q: any) =>
|
||||
q.eq("document_id", doc.id).eq("shared_with_user_id", args.sharedWithUserId),
|
||||
)
|
||||
.first();
|
||||
|
||||
if (existingShare) {
|
||||
await ctx.db.delete(existingShare._id);
|
||||
}
|
||||
|
||||
await ctx.db.patch(doc._id, { updated_at: nowIso() });
|
||||
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const listShareRootsByWorkspace = query({
|
||||
args: { workspaceId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
// 仅允许查看自己所在 workspace 的共享摘要
|
||||
const member = await ctx.db
|
||||
.query("workspace_members")
|
||||
.withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", args.workspaceId).eq("user_id", userId))
|
||||
.first();
|
||||
if (!member) throw new Error("无权限");
|
||||
|
||||
const incoming = 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 outgoing = await ctx.db
|
||||
.query("document_shares")
|
||||
.withIndex("by_workspace_created_by", (q: any) => q.eq("workspace_id", args.workspaceId).eq("created_by", userId))
|
||||
.collect();
|
||||
|
||||
const outgoingByDoc = new Map<string, { includeDescendants: boolean; sharedWithCount: number }>();
|
||||
for (const row of outgoing) {
|
||||
const existing = outgoingByDoc.get(row.document_id);
|
||||
if (!existing) {
|
||||
outgoingByDoc.set(row.document_id, {
|
||||
includeDescendants: Boolean(row.include_descendants),
|
||||
sharedWithCount: 1,
|
||||
});
|
||||
} else {
|
||||
existing.includeDescendants = existing.includeDescendants || Boolean(row.include_descendants);
|
||||
existing.sharedWithCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
incoming: incoming.map((r) => ({
|
||||
documentId: r.document_id,
|
||||
permission: r.permission,
|
||||
includeDescendants: r.include_descendants,
|
||||
createdBy: r.created_by,
|
||||
updatedAt: r.updated_at,
|
||||
})),
|
||||
outgoing: Array.from(outgoingByDoc.entries()).map(([documentId, v]) => ({
|
||||
documentId,
|
||||
includeDescendants: v.includeDescendants,
|
||||
sharedWithCount: v.sharedWithCount,
|
||||
})),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
import { nowIso } from "./_utils/time";
|
||||
|
||||
type SharePermission = "read" | "edit";
|
||||
|
||||
async function requireUserId(ctx: any): Promise<string> {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (userId === null) throw new Error("未登录");
|
||||
return String(userId);
|
||||
}
|
||||
|
||||
async function requireWorkspaceMember(ctx: any, workspaceId: string, userId: string) {
|
||||
const member = await ctx.db
|
||||
.query("workspace_members")
|
||||
.withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", workspaceId).eq("user_id", userId))
|
||||
.first();
|
||||
if (!member) throw new Error("无权限");
|
||||
return member;
|
||||
}
|
||||
|
||||
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 isStarred = query({
|
||||
args: { documentId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
const doc = await ctx.db
|
||||
.query("documents")
|
||||
.withIndex("by_document_id", (q: any) => q.eq("id", args.documentId))
|
||||
.first();
|
||||
if (!doc) return false;
|
||||
if (doc.deleted_at != null) return false;
|
||||
|
||||
try {
|
||||
await requireWorkspaceMember(ctx, doc.workspace_id, userId);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const star = await ctx.db
|
||||
.query("document_stars")
|
||||
.withIndex("by_workspace_document_user", (q: any) =>
|
||||
q.eq("workspace_id", doc.workspace_id).eq("document_id", doc.id).eq("user_id", userId),
|
||||
)
|
||||
.first();
|
||||
return Boolean(star);
|
||||
},
|
||||
});
|
||||
|
||||
export const toggle = mutation({
|
||||
args: { documentId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
const doc = await ctx.db
|
||||
.query("documents")
|
||||
.withIndex("by_document_id", (q: any) => q.eq("id", args.documentId))
|
||||
.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 && doc.access_scope !== "public") {
|
||||
const sharePerm = await resolveSharePermission(ctx, doc, userId);
|
||||
const groupPerm = await resolveGroupSharePermission(ctx, doc, userId);
|
||||
if (!sharePerm && !groupPerm) throw new Error("无权限");
|
||||
}
|
||||
|
||||
const existing = await ctx.db
|
||||
.query("document_stars")
|
||||
.withIndex("by_workspace_document_user", (q: any) =>
|
||||
q.eq("workspace_id", doc.workspace_id).eq("document_id", doc.id).eq("user_id", userId),
|
||||
)
|
||||
.first();
|
||||
|
||||
const ts = nowIso();
|
||||
|
||||
if (existing) {
|
||||
await ctx.db.delete(existing._id);
|
||||
// 兼容旧字段:仅当自己是 owner 时同步 documents.is_starred
|
||||
if (doc.user_id === userId) {
|
||||
await ctx.db.patch(doc._id, { is_starred: false, updated_at: ts });
|
||||
}
|
||||
return { ok: true, starred: false };
|
||||
}
|
||||
|
||||
await ctx.db.insert("document_stars", {
|
||||
workspace_id: doc.workspace_id,
|
||||
document_id: doc.id,
|
||||
user_id: userId,
|
||||
created_at: ts,
|
||||
updated_at: ts,
|
||||
});
|
||||
|
||||
if (doc.user_id === userId) {
|
||||
await ctx.db.patch(doc._id, { is_starred: true, updated_at: ts });
|
||||
}
|
||||
|
||||
return { ok: true, starred: true };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
import { nowIso } from "./_utils/time";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
|
||||
async function requireUserId(ctx: any): Promise<string> {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (userId === null) throw new Error("未登录");
|
||||
return String(userId);
|
||||
}
|
||||
|
||||
async function requireWorkspaceMember(ctx: any, workspaceId: string, userId: string) {
|
||||
const member = await ctx.db
|
||||
.query("workspace_members")
|
||||
.withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", workspaceId).eq("user_id", userId))
|
||||
.first();
|
||||
if (!member) throw new Error("无权限");
|
||||
return member;
|
||||
}
|
||||
|
||||
function normalizeUsername(raw: string): string {
|
||||
const username = String(raw ?? "").trim();
|
||||
if (!username) throw new Error("用户名不能为空");
|
||||
if (username.length < 2 || username.length > 32) throw new Error("用户名长度需为 2-32 个字符");
|
||||
if (/\s/.test(username)) throw new Error("用户名不能包含空格");
|
||||
return username;
|
||||
}
|
||||
|
||||
async function lookupUserIdByUsername(ctx: any, rawUsername: string): Promise<string> {
|
||||
const username = normalizeUsername(rawUsername);
|
||||
const user = await ctx.db
|
||||
.query("users")
|
||||
.filter((q: any) => q.eq(q.field("name"), username))
|
||||
.first();
|
||||
if (!user) throw new Error("未找到该用户");
|
||||
return String(user._id);
|
||||
}
|
||||
|
||||
async function requireGroup(ctx: any, groupId: string) {
|
||||
const group = await ctx.db
|
||||
.query("groups")
|
||||
.withIndex("by_group_id", (q: any) => q.eq("id", groupId))
|
||||
.first();
|
||||
if (!group) throw new Error("群组不存在");
|
||||
return group;
|
||||
}
|
||||
|
||||
async function requireGroupOwner(ctx: any, groupId: string, userId: string) {
|
||||
const membership = await ctx.db
|
||||
.query("group_members")
|
||||
.withIndex("by_group_user", (q: any) => q.eq("group_id", groupId).eq("user_id", userId))
|
||||
.first();
|
||||
if (!membership || membership.role !== "owner") throw new Error("无权限");
|
||||
return membership;
|
||||
}
|
||||
|
||||
export const listByGroup = query({
|
||||
args: { groupId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
const group = await requireGroup(ctx, args.groupId);
|
||||
await requireWorkspaceMember(ctx, group.workspace_id, userId);
|
||||
|
||||
const membership = await ctx.db
|
||||
.query("group_members")
|
||||
.withIndex("by_group_user", (q: any) => q.eq("group_id", args.groupId).eq("user_id", userId))
|
||||
.first();
|
||||
if (!membership) throw new Error("无权限");
|
||||
|
||||
const members = await ctx.db
|
||||
.query("group_members")
|
||||
.withIndex("by_group", (q: any) => q.eq("group_id", args.groupId))
|
||||
.collect();
|
||||
|
||||
const result = [];
|
||||
for (const m of members) {
|
||||
const user = await ctx.db.get(m.user_id as Id<"users">);
|
||||
result.push({
|
||||
userId: m.user_id,
|
||||
username: user?.name ?? null,
|
||||
role: m.role,
|
||||
createdAt: m.created_at,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
export const inviteByUsername = mutation({
|
||||
args: { groupId: v.string(), username: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
const group = await requireGroup(ctx, args.groupId);
|
||||
await requireWorkspaceMember(ctx, group.workspace_id, userId);
|
||||
await requireGroupOwner(ctx, args.groupId, userId);
|
||||
|
||||
const targetUserId = await lookupUserIdByUsername(ctx, args.username);
|
||||
if (targetUserId === userId) throw new Error("不能邀请自己");
|
||||
|
||||
// 确保对方为 workspace member
|
||||
const existingWorkspaceMember = await ctx.db
|
||||
.query("workspace_members")
|
||||
.withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", group.workspace_id).eq("user_id", targetUserId))
|
||||
.first();
|
||||
if (!existingWorkspaceMember) {
|
||||
await ctx.db.insert("workspace_members", {
|
||||
workspace_id: group.workspace_id,
|
||||
user_id: targetUserId,
|
||||
role: "member",
|
||||
is_default: false,
|
||||
created_at: nowIso(),
|
||||
});
|
||||
}
|
||||
|
||||
const existing = await ctx.db
|
||||
.query("group_members")
|
||||
.withIndex("by_group_user", (q: any) => q.eq("group_id", args.groupId).eq("user_id", targetUserId))
|
||||
.first();
|
||||
if (existing) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
await ctx.db.insert("group_members", {
|
||||
workspace_id: group.workspace_id,
|
||||
group_id: args.groupId,
|
||||
user_id: targetUserId,
|
||||
role: "member",
|
||||
created_at: nowIso(),
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const removeMember = mutation({
|
||||
args: { groupId: v.string(), userId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
const group = await requireGroup(ctx, args.groupId);
|
||||
await requireWorkspaceMember(ctx, group.workspace_id, userId);
|
||||
await requireGroupOwner(ctx, args.groupId, userId);
|
||||
|
||||
const existing = await ctx.db
|
||||
.query("group_members")
|
||||
.withIndex("by_group_user", (q: any) => q.eq("group_id", args.groupId).eq("user_id", args.userId))
|
||||
.first();
|
||||
if (!existing) return { ok: true };
|
||||
if (existing.role === "owner") throw new Error("不能移除群主");
|
||||
|
||||
await ctx.db.delete(existing._id);
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
import { nowIso } from "./_utils/time";
|
||||
|
||||
async function requireUserId(ctx: any): Promise<string> {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (userId === null) throw new Error("未登录");
|
||||
return String(userId);
|
||||
}
|
||||
|
||||
async function requireWorkspaceMember(ctx: any, workspaceId: string, userId: string) {
|
||||
const member = await ctx.db
|
||||
.query("workspace_members")
|
||||
.withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", workspaceId).eq("user_id", userId))
|
||||
.first();
|
||||
if (!member) throw new Error("无权限");
|
||||
return member;
|
||||
}
|
||||
|
||||
function normalizeGroupName(raw: string): string {
|
||||
const name = String(raw ?? "").trim();
|
||||
if (!name) throw new Error("群组名称不能为空");
|
||||
if (name.length > 50) throw new Error("群组名称最多 50 个字符");
|
||||
return name;
|
||||
}
|
||||
|
||||
export const listByWorkspace = query({
|
||||
args: { workspaceId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
await requireWorkspaceMember(ctx, args.workspaceId, userId);
|
||||
|
||||
const groups = await ctx.db
|
||||
.query("groups")
|
||||
.withIndex("by_workspace", (q: any) => q.eq("workspace_id", args.workspaceId))
|
||||
.collect();
|
||||
|
||||
groups.sort((a: any, b: any) => (a.created_at ?? "").localeCompare(b.created_at ?? ""));
|
||||
return groups.map((g: any) => ({
|
||||
id: g.id,
|
||||
workspace_id: g.workspace_id,
|
||||
name: g.name,
|
||||
created_by: g.created_by,
|
||||
created_at: g.created_at,
|
||||
updated_at: g.updated_at,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
export const create = mutation({
|
||||
args: {
|
||||
id: v.string(),
|
||||
workspaceId: v.string(),
|
||||
name: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
await requireWorkspaceMember(ctx, args.workspaceId, userId);
|
||||
|
||||
const name = normalizeGroupName(args.name);
|
||||
const ts = nowIso();
|
||||
|
||||
const existing = await ctx.db
|
||||
.query("groups")
|
||||
.withIndex("by_group_id", (q: any) => q.eq("id", args.id))
|
||||
.first();
|
||||
if (existing) throw new Error("群组已存在");
|
||||
|
||||
await ctx.db.insert("groups", {
|
||||
id: args.id,
|
||||
workspace_id: args.workspaceId,
|
||||
name,
|
||||
created_by: userId,
|
||||
created_at: ts,
|
||||
updated_at: ts,
|
||||
});
|
||||
|
||||
await ctx.db.insert("group_members", {
|
||||
workspace_id: args.workspaceId,
|
||||
group_id: args.id,
|
||||
user_id: userId,
|
||||
role: "owner",
|
||||
created_at: ts,
|
||||
});
|
||||
|
||||
return { ok: true, id: args.id };
|
||||
},
|
||||
});
|
||||
|
||||
export const rename = mutation({
|
||||
args: { groupId: v.string(), name: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
const group = await ctx.db
|
||||
.query("groups")
|
||||
.withIndex("by_group_id", (q: any) => q.eq("id", args.groupId))
|
||||
.first();
|
||||
if (!group) throw new Error("群组不存在");
|
||||
|
||||
await requireWorkspaceMember(ctx, group.workspace_id, userId);
|
||||
|
||||
const membership = await ctx.db
|
||||
.query("group_members")
|
||||
.withIndex("by_group_user", (q: any) => q.eq("group_id", args.groupId).eq("user_id", userId))
|
||||
.first();
|
||||
if (!membership || membership.role !== "owner") throw new Error("无权限");
|
||||
|
||||
const ts = nowIso();
|
||||
await ctx.db.patch(group._id, { name: normalizeGroupName(args.name), updated_at: ts });
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const remove = mutation({
|
||||
args: { groupId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
const group = await ctx.db
|
||||
.query("groups")
|
||||
.withIndex("by_group_id", (q: any) => q.eq("id", args.groupId))
|
||||
.first();
|
||||
if (!group) return { ok: true };
|
||||
|
||||
await requireWorkspaceMember(ctx, group.workspace_id, userId);
|
||||
|
||||
const membership = await ctx.db
|
||||
.query("group_members")
|
||||
.withIndex("by_group_user", (q: any) => q.eq("group_id", args.groupId).eq("user_id", userId))
|
||||
.first();
|
||||
if (!membership || membership.role !== "owner") throw new Error("无权限");
|
||||
|
||||
const members = await ctx.db
|
||||
.query("group_members")
|
||||
.withIndex("by_group", (q: any) => q.eq("group_id", args.groupId))
|
||||
.collect();
|
||||
|
||||
const shares = await ctx.db
|
||||
.query("document_group_shares")
|
||||
.withIndex("by_workspace_group", (q: any) => q.eq("workspace_id", group.workspace_id).eq("group_id", args.groupId))
|
||||
.collect();
|
||||
|
||||
const perms = await ctx.db
|
||||
.query("document_group_user_permissions")
|
||||
.withIndex("by_workspace_group", (q: any) => q.eq("workspace_id", group.workspace_id).eq("group_id", args.groupId))
|
||||
.collect();
|
||||
|
||||
for (const m of members) {
|
||||
await ctx.db.delete(m._id);
|
||||
}
|
||||
for (const s of shares) {
|
||||
await ctx.db.delete(s._id);
|
||||
}
|
||||
for (const p of perms) {
|
||||
await ctx.db.delete(p._id);
|
||||
}
|
||||
await ctx.db.delete(group._id);
|
||||
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -237,4 +237,79 @@ export default defineSchema({
|
||||
searchField: "row_hash",
|
||||
filterFields: ["table_id", "is_deleted"],
|
||||
}),
|
||||
|
||||
document_shares: defineTable({
|
||||
workspace_id: v.string(),
|
||||
document_id: v.string(),
|
||||
shared_with_user_id: v.string(),
|
||||
permission: v.union(v.literal("read"), v.literal("edit")),
|
||||
include_descendants: v.boolean(),
|
||||
created_by: v.string(),
|
||||
created_at: v.string(),
|
||||
updated_at: v.string(),
|
||||
})
|
||||
.index("by_document", ["document_id"])
|
||||
.index("by_doc_user", ["document_id", "shared_with_user_id"])
|
||||
.index("by_workspace_shared_with", ["workspace_id", "shared_with_user_id"])
|
||||
.index("by_workspace_created_by", ["workspace_id", "created_by"]),
|
||||
|
||||
groups: defineTable({
|
||||
id: v.string(),
|
||||
workspace_id: v.string(),
|
||||
name: v.string(),
|
||||
created_by: v.string(),
|
||||
created_at: v.string(),
|
||||
updated_at: v.string(),
|
||||
})
|
||||
.index("by_group_id", ["id"])
|
||||
.index("by_workspace", ["workspace_id"]),
|
||||
|
||||
group_members: defineTable({
|
||||
workspace_id: v.string(),
|
||||
group_id: v.string(),
|
||||
user_id: v.string(),
|
||||
role: v.union(v.literal("owner"), v.literal("member")),
|
||||
created_at: v.string(),
|
||||
})
|
||||
.index("by_group", ["group_id"])
|
||||
.index("by_group_user", ["group_id", "user_id"])
|
||||
.index("by_workspace_user", ["workspace_id", "user_id"]),
|
||||
|
||||
document_group_shares: defineTable({
|
||||
workspace_id: v.string(),
|
||||
group_id: v.string(),
|
||||
document_id: v.string(),
|
||||
include_descendants: v.boolean(),
|
||||
created_by: v.string(),
|
||||
created_at: v.string(),
|
||||
updated_at: v.string(),
|
||||
})
|
||||
.index("by_document", ["document_id"])
|
||||
.index("by_doc_group", ["document_id", "group_id"])
|
||||
.index("by_workspace_group", ["workspace_id", "group_id"])
|
||||
.index("by_workspace_created_by", ["workspace_id", "created_by"]),
|
||||
|
||||
document_group_user_permissions: defineTable({
|
||||
workspace_id: v.string(),
|
||||
group_id: v.string(),
|
||||
document_id: v.string(),
|
||||
user_id: v.string(),
|
||||
permission: v.union(v.literal("read"), v.literal("edit")),
|
||||
created_at: v.string(),
|
||||
updated_at: v.string(),
|
||||
})
|
||||
.index("by_doc_group_user", ["document_id", "group_id", "user_id"])
|
||||
.index("by_workspace_user", ["workspace_id", "user_id"])
|
||||
.index("by_workspace_group", ["workspace_id", "group_id"]),
|
||||
|
||||
document_stars: defineTable({
|
||||
workspace_id: v.string(),
|
||||
document_id: v.string(),
|
||||
user_id: v.string(),
|
||||
created_at: v.string(),
|
||||
updated_at: v.string(),
|
||||
})
|
||||
.index("by_workspace_user", ["workspace_id", "user_id"])
|
||||
.index("by_user_document", ["user_id", "document_id"])
|
||||
.index("by_workspace_document_user", ["workspace_id", "document_id", "user_id"]),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { query } from "./_generated/server";
|
||||
import { mutation } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
|
||||
/**
|
||||
@@ -15,3 +17,38 @@ export const currentUser = query({
|
||||
return await ctx.db.get(userId);
|
||||
},
|
||||
});
|
||||
|
||||
function normalizeUsername(raw: string): string {
|
||||
const username = String(raw ?? "").trim();
|
||||
if (!username) throw new Error("用户名不能为空");
|
||||
if (username.length < 2 || username.length > 32) throw new Error("用户名长度需为 2-32 个字符");
|
||||
if (/\s/.test(username)) throw new Error("用户名不能包含空格");
|
||||
return username;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置当前用户的唯一用户名(写入 users.name)
|
||||
*/
|
||||
export const setMyUsername = mutation({
|
||||
args: { username: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (userId === null) throw new Error("未登录");
|
||||
|
||||
const username = normalizeUsername(args.username);
|
||||
|
||||
// 说明:authTables 的 users 表目前没有为 name 建索引,这里用 filter 做一次全表扫描。
|
||||
// 用户量较小的桌面场景可接受;后续如需优化可增加独立 username 索引表。
|
||||
const existing = await ctx.db
|
||||
.query("users")
|
||||
.filter((q) => q.eq(q.field("name"), username))
|
||||
.first();
|
||||
|
||||
if (existing && String(existing._id) !== String(userId)) {
|
||||
throw new Error("用户名已被占用");
|
||||
}
|
||||
|
||||
await ctx.db.patch(userId, { name: username });
|
||||
return { ok: true, username };
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user