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 { 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 { 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 { 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(memberships.map((m: any) => String(m.group_id))); if (groupIds.size === 0) return null; const permissionFromGroup = async (documentId: string, groupId: string): Promise => { 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 }; }, });