444 lines
12 KiB
TypeScript
444 lines
12 KiB
TypeScript
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,
|
||
}));
|
||
},
|
||
});
|