import { mutation, query } from "./_generated/server"; import { v } from "convex/values"; import { nowIso } from "./_utils/time"; import type { MutationCtx, QueryCtx } from "./_generated/server"; async function assertWorkspaceMember(ctx: QueryCtx | MutationCtx, userId: string, workspaceId: string) { const membership = await ctx.db .query("workspace_members") .withIndex("by_workspace_user", (q) => q.eq("workspace_id", workspaceId).eq("user_id", userId)) .first(); if (!membership) { throw new Error("无权访问该工作空间"); } return membership; } const displayMode = v.union(v.literal("inline"), v.literal("embed")); export const record = mutation({ args: { userId: v.string(), workspaceId: v.string(), sourcePageId: v.string(), targetPageId: v.string(), sourceBlockId: v.union(v.string(), v.null()), alias: v.union(v.string(), v.null()), displayMode, isPreviewable: v.boolean(), }, handler: async (ctx, args) => { await assertWorkspaceMember(ctx, args.userId, args.workspaceId); const ts = nowIso(); const existing = await ctx.db .query("page_references") .withIndex("by_unique", (q) => q .eq("workspace_id", args.workspaceId) .eq("source_page_id", args.sourcePageId) .eq("source_block_id", args.sourceBlockId) .eq("target_page_id", args.targetPageId) .eq("display_mode", args.displayMode), ) .first(); if (existing) { await ctx.db.patch(existing._id, { alias: args.alias ?? null, is_previewable: Boolean(args.isPreviewable), updated_at: ts, }); return { id: existing.id, workspace_id: existing.workspace_id, source_page_id: existing.source_page_id, source_block_id: existing.source_block_id, target_page_id: existing.target_page_id, alias: args.alias ?? null, display_mode: existing.display_mode, is_previewable: Boolean(args.isPreviewable), created_at: existing.created_at, updated_at: ts, }; } // 说明:id 使用可读的组合键,便于调试;并不要求前端依赖该规则。 const id = `ref:${args.workspaceId}:${args.sourcePageId}:${args.sourceBlockId ?? "page"}:${args.targetPageId}:${args.displayMode}:${ts}`; await ctx.db.insert("page_references", { id, workspace_id: args.workspaceId, source_page_id: args.sourcePageId, source_block_id: args.sourceBlockId ?? null, target_page_id: args.targetPageId, alias: args.alias ?? null, display_mode: args.displayMode, is_previewable: Boolean(args.isPreviewable), created_by: args.userId, created_at: ts, updated_at: ts, }); return { id, workspace_id: args.workspaceId, source_page_id: args.sourcePageId, source_block_id: args.sourceBlockId ?? null, target_page_id: args.targetPageId, alias: args.alias ?? null, display_mode: args.displayMode, is_previewable: Boolean(args.isPreviewable), created_at: ts, updated_at: ts, }; }, }); export const listBacklinks = query({ args: { userId: v.string(), workspaceId: v.string(), pageId: v.string(), limit: v.optional(v.number()), offset: v.optional(v.number()), }, handler: async (ctx, args) => { await assertWorkspaceMember(ctx, args.userId, args.workspaceId); const limit = Math.max(1, Math.min(200, Math.floor(args.limit ?? 50))); const offset = Math.max(0, Math.floor(args.offset ?? 0)); const rows = await ctx.db .query("page_references") .withIndex("by_workspace_target", (q) => q.eq("workspace_id", args.workspaceId).eq("target_page_id", args.pageId)) .order("desc") .take(limit + offset + 200); const sliced = rows.slice(offset, offset + limit); const sourceIds = Array.from(new Set(sliced.map((r) => String((r as any).source_page_id ?? "")).filter(Boolean))); const sourceTitleById = new Map(); for (const sid of sourceIds) { const doc = await ctx.db.query("documents").withIndex("by_document_id", (q) => q.eq("id", sid)).first(); sourceTitleById.set(sid, doc ? (doc.title ?? null) : null); } return sliced.map((row) => { const r = row as any; return { id: r.id, source_page_id: r.source_page_id, source_block_id: r.source_block_id ?? null, alias: r.alias ?? null, display_mode: r.display_mode, is_previewable: Boolean(r.is_previewable), created_at: r.created_at, updated_at: r.updated_at, source_title: sourceTitleById.get(String(r.source_page_id ?? "")) ?? null, }; }); }, });