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

189 lines
6.4 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";
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) => {
// 退出登录/未登录时,客户端仍可能会发起 isStarred 查询(例如 Breadcrumb 仍在渲染)。
// 这里不要抛错,直接返回 false,避免把“未登录”作为运行时错误冒泡到前端。
const authUserId = await getAuthUserId(ctx);
if (authUserId === null) return false;
const userId = String(authUserId);
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 };
},
});