import { mutation, query } from "./_generated/server"; import { v } from "convex/values"; import { nowIso } from "./_utils/time"; import { generateId } from "./_utils/id"; 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; } // Query: 获取单个表格 export const get = query({ args: { userId: v.string(), tableId: v.string() }, handler: async (ctx, args) => { const table = await ctx.db .query("document_tables") .withIndex("by_table_id", (q) => q.eq("id", args.tableId)) .first(); if (!table) return null; if (table.workspace_id) { // 验证用户是否在工作区中(简化版:假设 userId 有效) // TODO: 添加 workspace_members 验证 } return { id: table.id, workspace_id: table.workspace_id, document_id: table.document_id, grid_key: table.grid_key, title: table.title, schema: table.schema, view_preferences: table.view_preferences, snapshot: table.snapshot ?? null, is_archived: table.is_archived, last_synced_at: table.last_synced_at ?? null, created_by: table.created_by, updated_by: table.updated_by ?? null, created_at: table.created_at, updated_at: table.updated_at, }; }, }); // Query: 通过 gridKey 获取表格(用于 Luckysheet get-workerbook) export const getByGridKey = query({ args: { gridKey: v.string() }, handler: async (ctx, args) => { const table = await ctx.db .query("document_tables") .withIndex("by_grid_key", (q) => q.eq("grid_key", args.gridKey)) .first(); if (!table) return null; // 返回 Luckysheet 需要的格式 return { title: table.title, gridKey: table.grid_key, lang: "zh", }; }, }); // Query: 通过 gridKey 获取完整表格数据(用于 Luckysheet load) export const getByGridKeyFull = query({ args: { gridKey: v.string() }, handler: async (ctx, args) => { const table = await ctx.db .query("document_tables") .withIndex("by_grid_key", (q) => q.eq("grid_key", args.gridKey)) .first(); if (!table) return null; // 返回完整的表格数据,包括 schema 和 snapshot return { id: table.id, grid_key: table.grid_key, title: table.title, schema: table.schema, snapshot: table.snapshot ?? null, }; }, }); // Query: 列出文档的所有表格 export const listByDocument = query({ args: { userId: v.string(), documentId: v.string() }, handler: async (ctx, args) => { const tables = await ctx.db .query("document_tables") .withIndex("by_document", (q) => q.eq("document_id", args.documentId)) .collect(); return tables .filter((t) => !t.is_archived) .map((t) => ({ id: t.id, document_id: t.document_id, grid_key: t.grid_key, title: t.title, schema: t.schema, view_preferences: t.view_preferences, snapshot: t.snapshot ?? null, is_archived: t.is_archived, last_synced_at: t.last_synced_at ?? null, created_at: t.created_at, updated_at: t.updated_at, })); }, }); export const listByWorkspaceForSearch = query({ args: { userId: v.string(), workspaceId: v.string(), includeArchived: v.optional(v.boolean()), limit: v.optional(v.number()), }, handler: async (ctx, args) => { await assertWorkspaceMember(ctx, args.userId, args.workspaceId); const includeArchived = Boolean(args.includeArchived); const limit = Math.max(1, Math.min(3000, Math.floor(args.limit ?? 3000))); const rows = await ctx.db .query("document_tables") .withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId)) .order("desc") .take(limit * 2); return rows .filter((t) => (includeArchived ? true : !t.is_archived)) .slice(0, limit) .map((t) => ({ id: t.id, workspace_id: t.workspace_id, document_id: t.document_id, title: t.title, is_archived: t.is_archived, deleted_at: t.deleted_at ?? null, deleted_by: t.deleted_by ?? null, purged_at: t.purged_at ?? null, created_at: t.created_at, updated_at: t.updated_at, })); }, }); // Mutation: 创建表格 export const create = mutation({ args: { userId: v.string(), workspaceId: v.string(), documentId: v.string(), title: v.optional(v.string()), schema: v.optional(v.any()), snapshot: v.optional(v.any()), }, handler: async (ctx, args) => { const now = nowIso(); const tableId = generateId(); const gridKey = generateId(); // 用于 WebSocket 协同标识 const table = { id: tableId, workspace_id: args.workspaceId, document_id: args.documentId, grid_key: gridKey, title: args.title ?? "未命名表格", schema: args.schema ?? {}, view_preferences: {}, snapshot: args.snapshot ?? null, is_archived: false, deleted_at: null, deleted_by: null, purged_at: null, last_synced_at: now, created_by: args.userId, updated_by: args.userId, created_at: now, updated_at: now, }; await ctx.db.insert("document_tables", table); return { id: table.id, grid_key: table.grid_key, title: table.title, schema: table.schema, snapshot: table.snapshot, }; }, }); // Mutation: 更新表格 export const update = mutation({ args: { userId: v.string(), tableId: v.string(), title: v.optional(v.string()), schema: v.optional(v.any()), view_preferences: v.optional(v.any()), snapshot: v.optional(v.any()), rows: v.optional(v.any()), }, handler: async (ctx, args) => { const table = await ctx.db .query("document_tables") .withIndex("by_table_id", (q) => q.eq("id", args.tableId)) .first(); if (!table) { throw new Error("Table not found"); } const now = nowIso(); const updates: any = { updated_at: now, updated_by: args.userId, last_synced_at: now, }; if (args.title !== undefined) updates.title = args.title; if (args.schema !== undefined) updates.schema = args.schema; if (args.view_preferences !== undefined) updates.view_preferences = args.view_preferences; if (args.snapshot !== undefined) updates.snapshot = args.snapshot; await ctx.db.patch(table._id, updates); // 如果提供了 rows,更新行数据 if (args.rows && Array.isArray(args.rows)) { // 先删除旧行 const existingRows = await ctx.db .query("document_table_rows") .withIndex("by_table", (q) => q.eq("table_id", args.tableId)) .collect(); for (const row of existingRows) { await ctx.db.delete(row._id); } // 插入新行 for (const [index, rowData] of args.rows.entries()) { await ctx.db.insert("document_table_rows", { id: generateId(), workspace_id: table.workspace_id, document_id: table.document_id, table_id: args.tableId, row_index: index, row_data: rowData, row_hash: JSON.stringify(rowData), is_deleted: false, updated_by: args.userId, created_at: now, updated_at: now, }); } } return { id: table.id, title: updates.title ?? table.title, schema: updates.schema ?? table.schema, view_preferences: updates.view_preferences ?? table.view_preferences, snapshot: updates.snapshot ?? table.snapshot, updated_at: now, }; }, }); // Mutation: 删除表格(软删除) export const remove = mutation({ args: { userId: v.string(), tableId: v.string() }, handler: async (ctx, args) => { const table = await ctx.db .query("document_tables") .withIndex("by_table_id", (q) => q.eq("id", args.tableId)) .first(); if (!table) { throw new Error("Table not found"); } const now = nowIso(); await ctx.db.patch(table._id, { is_archived: true, deleted_at: now, deleted_by: args.userId, purged_at: null, updated_at: now, updated_by: args.userId, }); return { success: true }; }, }); // Mutation: 恢复表格(从垃圾桶恢复) export const restore = mutation({ args: { userId: v.string(), tableId: v.string() }, handler: async (ctx, args) => { const table = await ctx.db .query("document_tables") .withIndex("by_table_id", (q) => q.eq("id", args.tableId)) .first(); if (!table) { throw new Error("Table not found"); } const now = nowIso(); await ctx.db.patch(table._id, { is_archived: false, deleted_at: null, deleted_by: null, purged_at: null, updated_at: now, updated_by: args.userId, }); return { success: true }; }, }); // Mutation: 永久删除表格 export const purge = mutation({ args: { userId: v.string(), tableId: v.string() }, handler: async (ctx, args) => { const table = await ctx.db .query("document_tables") .withIndex("by_table_id", (q) => q.eq("id", args.tableId)) .first(); if (!table) { throw new Error("Table not found"); } // 删除关联的行 const rows = await ctx.db .query("document_table_rows") .withIndex("by_table", (q) => q.eq("table_id", args.tableId)) .collect(); for (const row of rows) { await ctx.db.delete(row._id); } // 删除表格 await ctx.db.delete(table._id); return { success: true }; }, }); export const emptyTrashByWorkspace = mutation({ args: { userId: v.string(), workspaceId: v.string(), expiredDeletedAt: v.string() }, handler: async (ctx, args) => { await assertWorkspaceMember(ctx, args.userId, args.workspaceId); const rows = await ctx.db .query("document_tables") .withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId)) .order("desc") .take(5000); // 仅清理“已进入垃圾桶且超出宽限期”的表格 const targets = rows.filter((t) => { if (!t.is_archived) return false; const deletedAt = (t as any).deleted_at ?? null; if (!deletedAt || typeof deletedAt !== "string") return false; return deletedAt <= args.expiredDeletedAt; }); if (targets.length === 0) { return { ok: true, deleted: 0 }; } for (const table of targets) { const tableId = table.id; const tableRows = await ctx.db .query("document_table_rows") .withIndex("by_table", (q) => q.eq("table_id", tableId)) .collect(); for (const r of tableRows) { await ctx.db.delete(r._id); } await ctx.db.delete(table._id); } return { ok: true, deleted: targets.length }; }, }); // Query: 获取表格行数据 export const getRows = query({ args: { tableId: v.string() }, handler: async (ctx, args) => { const rows = await ctx.db .query("document_table_rows") .withIndex("by_table", (q) => q.eq("table_id", args.tableId)) .collect(); return rows .filter((r) => !r.is_deleted) .sort((a, b) => a.row_index - b.row_index) .map((r) => r.row_data); }, }); export const listRowsByWorkspaceForSearch = query({ args: { userId: v.string(), workspaceId: v.string(), limit: v.optional(v.number()), }, handler: async (ctx, args) => { await assertWorkspaceMember(ctx, args.userId, args.workspaceId); const limit = Math.max(1, Math.min(8000, Math.floor(args.limit ?? 8000))); const rows = await ctx.db .query("document_table_rows") .withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId)) .order("desc") .take(limit * 2); return rows .filter((r) => !r.is_deleted) .slice(0, limit) .map((r) => ({ id: r.id, workspace_id: r.workspace_id, document_id: r.document_id, table_id: r.table_id, row_index: r.row_index, row_hash: r.row_hash ?? null, created_at: r.created_at ?? null, updated_at: r.updated_at ?? null, })); }, });