174 lines
6.1 KiB
TypeScript
174 lines
6.1 KiB
TypeScript
import { mutation, query } from "./_generated/server";
|
|
import { v } from "convex/values";
|
|
import { getAuthUserId } from "@convex-dev/auth/server";
|
|
import { nowIso } from "./_utils/time";
|
|
import { getCanonicalDocumentByBusinessId, getCanonicalParentDocumentId } from "./_utils/documentRecord";
|
|
|
|
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;
|
|
parentId = await getCanonicalParentDocumentId(ctx, parentId);
|
|
}
|
|
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";
|
|
parentId = await getCanonicalParentDocumentId(ctx, parentId);
|
|
}
|
|
|
|
return best;
|
|
}
|
|
|
|
export const isStarred = query({
|
|
args: { documentId: v.string() },
|
|
handler: async (ctx, args) => {
|
|
// 退出登录/未登录时,客户端仍可能会发起 isStarred 查询(例如 Breadcrumb 仍在渲染)。
|
|
// 这里不要抛错,直接返回 false,避免把“未登录”作为运行时错误冒泡到前端。
|
|
const authUserId = await getAuthUserId(ctx);
|
|
if (authUserId === null) return false;
|
|
const userId = String(authUserId);
|
|
|
|
const doc = await getCanonicalDocumentByBusinessId<any>(ctx, args.documentId);
|
|
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 getCanonicalDocumentByBusinessId<any>(ctx, args.documentId);
|
|
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 };
|
|
},
|
|
});
|