import { mutation, query } from "./_generated/server"; import { v } from "convex/values"; import { getAuthUserId } from "@convex-dev/auth/server"; import { nowIso } from "./_utils/time"; import { requireCanonicalOwnedDocument } from "./_utils/documentRecord"; type Permission = "read" | "edit"; async function requireUserId(ctx: any): Promise { const userId = await getAuthUserId(ctx); if (userId === null) throw new Error("未登录"); return String(userId); } 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 requireGroupMember(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) throw new Error("无权限(你不在该群组内)"); return membership; } export const listByDocument = query({ args: { documentId: v.string() }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); const doc = await requireCanonicalOwnedDocument(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, disableDownload: Boolean((share as any).disable_download), disableCopy: Boolean((share as any).disable_copy), 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()), disableDownload: v.optional(v.boolean()), disableCopy: v.optional(v.boolean()), }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); const doc = await requireCanonicalOwnedDocument(ctx, args.documentId, userId); const group = await requireGroup(ctx, args.groupId); if (group.workspace_id !== doc.workspace_id) throw new Error("群组不属于当前工作空间"); await requireGroupMember(ctx, args.groupId, userId); const ts = nowIso(); const includeDescendants = Boolean(args.includeDescendants); const disableDownload = Boolean(args.disableDownload); const disableCopy = Boolean(args.disableCopy); 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, disable_download: disableDownload, disable_copy: disableCopy, 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, disable_download: disableDownload, disable_copy: disableCopy, 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 requireCanonicalOwnedDocument(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(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(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(); 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(); 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; }, });