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 { 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, disableDownload: Boolean((share as any).disable_download), disableCopy: Boolean((share as any).disable_copy), 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()), disableDownload: v.optional(v.boolean()), disableCopy: 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); const disableDownload = Boolean(args.disableDownload); const disableCopy = Boolean(args.disableCopy); // 确保对方成为 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, disable_download: disableDownload, disable_copy: disableCopy, 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, disable_download: disableDownload, disable_copy: disableCopy, 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(); 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, })), }; }, }); export const listMyShareRoots = query({ args: {}, handler: async (ctx) => { const userId = await requireUserId(ctx); const incomingRows = await ctx.db .query("document_shares") .withIndex("by_shared_with_user", (q: any) => q.eq("shared_with_user_id", userId)) .collect(); // 说明:只统计“我共享出去的”(自己是 created_by);用于顶部共享面板展示摘要。 const outgoingRows = await ctx.db .query("document_shares") .withIndex("by_created_by", (q: any) => q.eq("created_by", userId)) .collect(); // 说明:去重/合并:同一页面共享给多个用户时,只展示一条,并累计 sharedWithCount。 const outgoingByKey = new Map< string, { workspaceId: string; documentId: string; includeDescendants: boolean; sharedWithCount: number; updatedAt: string; } >(); for (const row of outgoingRows) { const key = `${row.workspace_id}:${row.document_id}`; const existing = outgoingByKey.get(key); if (!existing) { outgoingByKey.set(key, { workspaceId: row.workspace_id, documentId: row.document_id, includeDescendants: Boolean(row.include_descendants), sharedWithCount: 1, updatedAt: String(row.updated_at ?? ""), }); } else { existing.includeDescendants = existing.includeDescendants || Boolean(row.include_descendants); existing.sharedWithCount += 1; const u = String(row.updated_at ?? ""); if (u && u.localeCompare(existing.updatedAt) > 0) { existing.updatedAt = u; } } } const workspaceNameCache = new Map(); const getWorkspaceName = async (workspaceId: string): Promise => { const cached = workspaceNameCache.get(workspaceId); if (workspaceNameCache.has(workspaceId)) return cached ?? null; const ws = await ctx.db .query("workspaces") .withIndex("by_workspace_id", (q: any) => q.eq("id", workspaceId)) .first(); const name = ws?.name ?? null; workspaceNameCache.set(workspaceId, name); return name; }; const documentCache = new Map< string, { exists: boolean; deleted: boolean; title: string | null } >(); const getDocumentInfo = async ( documentId: string, ): Promise<{ exists: boolean; deleted: boolean; title: string | null }> => { const cached = documentCache.get(documentId); if (documentCache.has(documentId)) { return cached ?? { exists: false, deleted: true, title: null }; } const doc = await ctx.db .query("documents") .withIndex("by_document_id", (q: any) => q.eq("id", documentId)) .first(); const info = doc ? { exists: true, deleted: doc.deleted_at != null, title: (doc.title ?? null) as string | null } : { exists: false, deleted: true, title: null }; documentCache.set(documentId, info); return info; }; const incoming = []; for (const row of incomingRows) { // 说明:若对方仅写入了 share 但没把我加入 workspace,则这里会被文档/页面接口挡住; // 为避免面板出现“打不开的幽灵分享”,这里要求我确实是 workspace member。 const member = await ctx.db .query("workspace_members") .withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", row.workspace_id).eq("user_id", userId)) .first(); if (!member) continue; const docInfo = await getDocumentInfo(row.document_id); if (!docInfo.exists) continue; if (docInfo.deleted) continue; incoming.push({ workspaceId: row.workspace_id, workspaceName: await getWorkspaceName(row.workspace_id), documentId: row.document_id, documentTitle: docInfo.title, permission: row.permission, includeDescendants: row.include_descendants, createdBy: row.created_by, updatedAt: row.updated_at, }); } const outgoing = []; for (const v of outgoingByKey.values()) { const docInfo = await getDocumentInfo(v.documentId); if (!docInfo.exists) continue; if (docInfo.deleted) continue; outgoing.push({ workspaceId: v.workspaceId, workspaceName: await getWorkspaceName(v.workspaceId), documentId: v.documentId, documentTitle: docInfo.title, includeDescendants: v.includeDescendants, sharedWithCount: v.sharedWithCount, updatedAt: v.updatedAt, }); } incoming.sort((a: any, b: any) => String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? ""))); outgoing.sort((a: any, b: any) => String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? ""))); return { incoming, outgoing }; }, });