0.2.1 onlyoffice修复

This commit is contained in:
liaibo
2026-01-17 10:12:53 +08:00
parent 94957dc361
commit 19907bccdc
102 changed files with 7188 additions and 186 deletions
+65
View File
@@ -0,0 +1,65 @@
/* eslint-disable */
/**
* Generated `api` utility.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type * as _utils_time from "../_utils/time.js";
import type * as documents from "../documents.js";
import type * as jobs from "../jobs.js";
import type * as mediaAssets from "../mediaAssets.js";
import type * as mindmaps from "../mindmaps.js";
import type * as ping from "../ping.js";
import type * as recents from "../recents.js";
import type * as references from "../references.js";
import type * as workspaces from "../workspaces.js";
import type {
ApiFromModules,
FilterApi,
FunctionReference,
} from "convex/server";
declare const fullApi: ApiFromModules<{
"_utils/time": typeof _utils_time;
documents: typeof documents;
jobs: typeof jobs;
mediaAssets: typeof mediaAssets;
mindmaps: typeof mindmaps;
ping: typeof ping;
recents: typeof recents;
references: typeof references;
workspaces: typeof workspaces;
}>;
/**
* A utility for referencing Convex functions in your app's public API.
*
* Usage:
* ```js
* const myFunctionReference = api.myModule.myFunction;
* ```
*/
export declare const api: FilterApi<
typeof fullApi,
FunctionReference<any, "public">
>;
/**
* A utility for referencing Convex functions in your app's internal API.
*
* Usage:
* ```js
* const myFunctionReference = internal.myModule.myFunction;
* ```
*/
export declare const internal: FilterApi<
typeof fullApi,
FunctionReference<any, "internal">
>;
export declare const components: {};
+23
View File
@@ -0,0 +1,23 @@
/* eslint-disable */
/**
* Generated `api` utility.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import { anyApi, componentsGeneric } from "convex/server";
/**
* A utility for referencing Convex functions in your app's API.
*
* Usage:
* ```js
* const myFunctionReference = api.myModule.myFunction;
* ```
*/
export const api = anyApi;
export const internal = anyApi;
export const components = componentsGeneric();
+60
View File
@@ -0,0 +1,60 @@
/* eslint-disable */
/**
* Generated data model types.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type {
DataModelFromSchemaDefinition,
DocumentByName,
TableNamesInDataModel,
SystemTableNames,
} from "convex/server";
import type { GenericId } from "convex/values";
import schema from "../schema.js";
/**
* The names of all of your Convex tables.
*/
export type TableNames = TableNamesInDataModel<DataModel>;
/**
* The type of a document stored in Convex.
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/
export type Doc<TableName extends TableNames> = DocumentByName<
DataModel,
TableName
>;
/**
* An identifier for a document in Convex.
*
* Convex documents are uniquely identified by their `Id`, which is accessible
* on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
*
* Documents can be loaded using `db.get(tableName, id)` in query and mutation functions.
*
* IDs are just strings at runtime, but this type can be used to distinguish them from other
* strings when type checking.
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/
export type Id<TableName extends TableNames | SystemTableNames> =
GenericId<TableName>;
/**
* A type describing your Convex data model.
*
* This type includes information about what tables you have, the type of
* documents stored in those tables, and the indexes defined on them.
*
* This type is used to parameterize methods like `queryGeneric` and
* `mutationGeneric` to make them type-safe.
*/
export type DataModel = DataModelFromSchemaDefinition<typeof schema>;
+143
View File
@@ -0,0 +1,143 @@
/* eslint-disable */
/**
* Generated utilities for implementing server-side Convex query and mutation functions.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import {
ActionBuilder,
HttpActionBuilder,
MutationBuilder,
QueryBuilder,
GenericActionCtx,
GenericMutationCtx,
GenericQueryCtx,
GenericDatabaseReader,
GenericDatabaseWriter,
} from "convex/server";
import type { DataModel } from "./dataModel.js";
/**
* Define a query in this Convex app's public API.
*
* This function will be allowed to read your Convex database and will be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export declare const query: QueryBuilder<DataModel, "public">;
/**
* Define a query that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export declare const internalQuery: QueryBuilder<DataModel, "internal">;
/**
* Define a mutation in this Convex app's public API.
*
* This function will be allowed to modify your Convex database and will be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export declare const mutation: MutationBuilder<DataModel, "public">;
/**
* Define a mutation that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export declare const internalMutation: MutationBuilder<DataModel, "internal">;
/**
* Define an action in this Convex app's public API.
*
* An action is a function which can execute any JavaScript code, including non-deterministic
* code and code with side-effects, like calling third-party services.
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
*
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
*/
export declare const action: ActionBuilder<DataModel, "public">;
/**
* Define an action that is only accessible from other Convex functions (but not from the client).
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
*/
export declare const internalAction: ActionBuilder<DataModel, "internal">;
/**
* Define an HTTP action.
*
* The wrapped function will be used to respond to HTTP requests received
* by a Convex deployment if the requests matches the path and method where
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument
* and a Fetch API `Request` object as its second.
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
*/
export declare const httpAction: HttpActionBuilder;
/**
* A set of services for use within Convex query functions.
*
* The query context is passed as the first argument to any Convex query
* function run on the server.
*
* This differs from the {@link MutationCtx} because all of the services are
* read-only.
*/
export type QueryCtx = GenericQueryCtx<DataModel>;
/**
* A set of services for use within Convex mutation functions.
*
* The mutation context is passed as the first argument to any Convex mutation
* function run on the server.
*/
export type MutationCtx = GenericMutationCtx<DataModel>;
/**
* A set of services for use within Convex action functions.
*
* The action context is passed as the first argument to any Convex action
* function run on the server.
*/
export type ActionCtx = GenericActionCtx<DataModel>;
/**
* An interface to read from the database within Convex query functions.
*
* The two entry points are {@link DatabaseReader.get}, which fetches a single
* document by its {@link Id}, or {@link DatabaseReader.query}, which starts
* building a query.
*/
export type DatabaseReader = GenericDatabaseReader<DataModel>;
/**
* An interface to read from and write to the database within Convex mutation
* functions.
*
* Convex guarantees that all writes within a single mutation are
* executed atomically, so you never have to worry about partial writes leaving
* your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)
* for the guarantees Convex provides your functions.
*/
export type DatabaseWriter = GenericDatabaseWriter<DataModel>;
@@ -0,0 +1,93 @@
/* eslint-disable */
/**
* Generated utilities for implementing server-side Convex query and mutation functions.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import {
actionGeneric,
httpActionGeneric,
queryGeneric,
mutationGeneric,
internalActionGeneric,
internalMutationGeneric,
internalQueryGeneric,
} from "convex/server";
/**
* Define a query in this Convex app's public API.
*
* This function will be allowed to read your Convex database and will be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export const query = queryGeneric;
/**
* Define a query that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export const internalQuery = internalQueryGeneric;
/**
* Define a mutation in this Convex app's public API.
*
* This function will be allowed to modify your Convex database and will be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export const mutation = mutationGeneric;
/**
* Define a mutation that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export const internalMutation = internalMutationGeneric;
/**
* Define an action in this Convex app's public API.
*
* An action is a function which can execute any JavaScript code, including non-deterministic
* code and code with side-effects, like calling third-party services.
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
*
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
*/
export const action = actionGeneric;
/**
* Define an action that is only accessible from other Convex functions (but not from the client).
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
*/
export const internalAction = internalActionGeneric;
/**
* Define an HTTP action.
*
* The wrapped function will be used to respond to HTTP requests received
* by a Convex deployment if the requests matches the path and method where
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument
* and a Fetch API `Request` object as its second.
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
*/
export const httpAction = httpActionGeneric;
+4
View File
@@ -0,0 +1,4 @@
export function nowIso(): string {
return new Date().toISOString();
}
+457
View File
@@ -0,0 +1,457 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { nowIso } from "./_utils/time";
const accessScope = v.union(v.literal("private"), v.literal("shared"), v.literal("public"));
export const getMeta = query({
args: { userId: v.string(), id: v.string() },
handler: async (ctx, args) => {
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
if (!doc) return null;
if (doc.user_id !== args.userId) return null;
return {
id: doc.id,
user_id: doc.user_id,
workspace_id: doc.workspace_id,
access_scope: doc.access_scope,
title: doc.title ?? null,
parent_id: doc.parent_id ?? null,
created_at: doc.created_at,
updated_at: doc.updated_at ?? null,
wide_layout: doc.wide_layout ?? null,
use_small_text: doc.use_small_text ?? null,
show_heading_numbers: doc.show_heading_numbers ?? null,
show_toc: doc.show_toc ?? null,
show_structure: doc.show_structure ?? null,
protect_editing: doc.protect_editing ?? null,
show_word_count: doc.show_word_count ?? null,
word_count: doc.word_count ?? null,
character_count: doc.character_count ?? null,
block_count: doc.block_count ?? null,
};
},
});
export const getContent = query({
args: { userId: v.string(), id: v.string() },
handler: async (ctx, args) => {
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
if (!doc) return null;
if (doc.user_id !== args.userId) return null;
return { content: doc.content ?? null };
},
});
export const listByWorkspace = query({
args: { userId: v.string(), workspaceId: v.string() },
handler: async (ctx, args) => {
const docs = await ctx.db
.query("documents")
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
.collect();
// 说明:阶段 4 先不做垃圾桶(deleted_at != null),因此这里直接过滤。
return docs
.filter((d) => d.user_id === args.userId)
.filter((d) => d.deleted_at == null)
.map((d) => ({
access_scope: d.access_scope,
id: d.id,
workspace_id: d.workspace_id,
title: d.title ?? "无标题",
parent_id: d.parent_id ?? null,
sort_order: d.sort_order ?? null,
is_starred: d.is_starred ?? null,
is_template: d.is_template ?? false,
created_at: d.created_at,
updated_at: d.updated_at ?? null,
}));
},
});
export const listTrashedByWorkspace = query({
args: { userId: v.string(), workspaceId: v.string() },
handler: async (ctx, args) => {
const docs = await ctx.db
.query("documents")
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
.collect();
return docs
.filter((d) => d.user_id === args.userId)
.filter((d) => d.deleted_at != null)
.sort((a, b) => (b.deleted_at ?? "").localeCompare(a.deleted_at ?? ""))
.slice(0, 100)
.map((d) => ({
id: d.id,
title: d.title ?? null,
parent_id: d.parent_id ?? null,
deleted_at: d.deleted_at!,
access_scope: d.access_scope,
}));
},
});
export const create = mutation({
args: {
userId: v.string(),
id: v.string(),
workspaceId: v.string(),
parentId: v.union(v.string(), v.null()),
title: v.optional(v.union(v.string(), v.null())),
accessScope,
content: v.optional(v.any()),
},
handler: async (ctx, args) => {
const siblings = await ctx.db
.query("documents")
.withIndex("by_workspace_parent", (q) => q.eq("workspace_id", args.workspaceId).eq("parent_id", args.parentId))
.collect();
const sortOrder = siblings.filter((d) => d.deleted_at == null).length;
const ts = nowIso();
const title = (args.title ?? "无标题") || "无标题";
const content = typeof args.content === "undefined" ? [] : args.content;
await ctx.db.insert("documents", {
id: args.id,
user_id: args.userId,
workspace_id: args.workspaceId,
parent_id: args.parentId,
title,
content,
access_scope: args.accessScope,
sort_order: sortOrder,
is_starred: false,
is_template: false,
wide_layout: false,
use_small_text: false,
show_heading_numbers: true,
show_toc: false,
show_structure: false,
protect_editing: false,
show_word_count: true,
word_count: 0,
character_count: 0,
block_count: 0,
created_at: ts,
updated_at: ts,
deleted_at: null,
deleted_by: null,
});
return {
id: args.id,
title,
parent_id: args.parentId,
sort_order: sortOrder,
is_starred: false,
created_at: ts,
updated_at: ts,
workspace_id: args.workspaceId,
access_scope: args.accessScope,
is_template: false,
};
},
});
export const updateContent = mutation({
args: { userId: v.string(), id: v.string(), content: v.any() },
handler: async (ctx, args) => {
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
if (!doc) throw new Error("页面不存在");
if (doc.user_id !== args.userId) throw new Error("无权限");
const ts = nowIso();
await ctx.db.patch(doc._id, { content: args.content, updated_at: ts });
return { ok: true, updated_at: ts };
},
});
export const updateTitle = mutation({
args: { userId: v.string(), id: v.string(), title: v.union(v.string(), v.null()) },
handler: async (ctx, args) => {
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
if (!doc) throw new Error("页面不存在");
if (doc.user_id !== args.userId) throw new Error("无权限");
const ts = nowIso();
await ctx.db.patch(doc._id, { title: args.title, updated_at: ts });
return { ok: true, updated_at: ts };
},
});
export const move = mutation({
args: {
userId: v.string(),
id: v.string(),
parentId: v.union(v.string(), v.null()),
sortOrder: v.number(),
},
handler: async (ctx, args) => {
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
if (!doc) throw new Error("页面不存在");
if (doc.user_id !== args.userId) throw new Error("无权限");
const ts = nowIso();
await ctx.db.patch(doc._id, {
parent_id: args.parentId,
sort_order: args.sortOrder,
updated_at: ts,
});
return { ok: true, updated_at: ts };
},
});
export const softDelete = mutation({
args: { userId: v.string(), id: v.string() },
handler: async (ctx, args) => {
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
if (!doc) throw new Error("页面不存在");
if (doc.user_id !== args.userId) throw new Error("无权限");
const ts = nowIso();
await ctx.db.patch(doc._id, { deleted_at: ts, deleted_by: args.userId, updated_at: ts });
return { ok: true };
},
});
export const restore = mutation({
args: { userId: v.string(), id: v.string() },
handler: async (ctx, args) => {
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
if (!doc) throw new Error("页面不存在");
if (doc.user_id !== args.userId) throw new Error("无权限");
const ts = nowIso();
await ctx.db.patch(doc._id, {
deleted_at: null,
deleted_by: null,
parent_id: null,
access_scope: "private",
updated_at: ts,
});
return { ok: true };
},
});
export const purge = mutation({
args: { userId: v.string(), id: v.string() },
handler: async (ctx, args) => {
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
if (!doc) throw new Error("页面不存在");
if (doc.user_id !== args.userId) throw new Error("无权限");
await ctx.db.delete(doc._id);
return { ok: true };
},
});
export const emptyTrashByWorkspace = mutation({
args: { userId: v.string(), workspaceId: v.string() },
handler: async (ctx, args) => {
// 说明:阶段 4/5 先用“membership 存在即可”的规则,避免引入复杂权限模型。
const membership = await ctx.db
.query("workspace_members")
.withIndex("by_workspace_user", (q) => q.eq("workspace_id", args.workspaceId).eq("user_id", args.userId))
.first();
if (!membership) {
throw new Error("无权操作该工作空间");
}
const docs = await ctx.db
.query("documents")
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
.collect();
const toDelete = docs.filter((d) => d.user_id === args.userId && d.deleted_at != null);
for (const d of toDelete) {
await ctx.db.delete(d._id);
}
return { ok: true, deletedCount: toDelete.length };
},
});
export const updateOptions = mutation({
args: {
userId: v.string(),
id: v.string(),
options: v.object({
wideLayout: v.optional(v.boolean()),
smallText: v.optional(v.boolean()),
showHeadingNumbers: v.optional(v.boolean()),
showToc: v.optional(v.boolean()),
showStructure: v.optional(v.boolean()),
protectEditing: v.optional(v.boolean()),
showWordCount: v.optional(v.boolean()),
}),
},
handler: async (ctx, args) => {
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
if (!doc) throw new Error("页面不存在");
if (doc.user_id !== args.userId) throw new Error("无权限");
const patch: Record<string, unknown> = {};
if (typeof args.options.wideLayout === "boolean") patch.wide_layout = args.options.wideLayout;
if (typeof args.options.smallText === "boolean") patch.use_small_text = args.options.smallText;
if (typeof args.options.showHeadingNumbers === "boolean")
patch.show_heading_numbers = args.options.showHeadingNumbers;
if (typeof args.options.showToc === "boolean") patch.show_toc = args.options.showToc;
if (typeof args.options.showStructure === "boolean") patch.show_structure = args.options.showStructure;
if (typeof args.options.protectEditing === "boolean") patch.protect_editing = args.options.protectEditing;
if (typeof args.options.showWordCount === "boolean") patch.show_word_count = args.options.showWordCount;
if (Object.keys(patch).length === 0) {
throw new Error("缺少可更新的选项");
}
const ts = nowIso();
await ctx.db.patch(doc._id, { ...patch, updated_at: ts });
return { ok: true, updated_at: ts };
},
});
export const updateStats = mutation({
args: {
userId: v.string(),
id: v.string(),
wordCount: v.number(),
characterCount: v.number(),
blockCount: v.number(),
},
handler: async (ctx, args) => {
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
if (!doc) throw new Error("页面不存在");
if (doc.user_id !== args.userId) throw new Error("无权限");
const ts = nowIso();
await ctx.db.patch(doc._id, {
word_count: args.wordCount,
character_count: args.characterCount,
block_count: args.blockCount,
updated_at: ts,
});
return { ok: true, updated_at: ts };
},
});
export const duplicate = mutation({
args: {
userId: v.string(),
sourceId: v.string(),
newId: v.string(),
title: v.optional(v.union(v.string(), v.null())),
},
handler: async (ctx, args) => {
const source = await ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.sourceId))
.first();
if (!source) throw new Error("页面不存在或无权限访问");
if (source.user_id !== args.userId) throw new Error("页面不存在或无权限访问");
const siblings = await ctx.db
.query("documents")
.withIndex("by_workspace_parent", (q) =>
q.eq("workspace_id", source.workspace_id).eq("parent_id", source.parent_id),
)
.collect();
const sortOrder = siblings.filter((d) => d.deleted_at == null).length;
const ts = nowIso();
const baseTitle = (source.title ?? "无标题").trim() ? (source.title ?? "无标题").trim() : "无标题";
const title = (args.title ?? `${baseTitle} 副本`) || `${baseTitle} 副本`;
await ctx.db.insert("documents", {
id: args.newId,
user_id: args.userId,
workspace_id: source.workspace_id,
parent_id: source.parent_id,
title,
content: source.content ?? [],
access_scope: source.access_scope,
sort_order: sortOrder,
is_starred: false,
is_template: false,
wide_layout: source.wide_layout ?? false,
use_small_text: source.use_small_text ?? false,
show_heading_numbers: source.show_heading_numbers ?? true,
show_toc: source.show_toc ?? false,
show_structure: source.show_structure ?? false,
protect_editing: source.protect_editing ?? false,
show_word_count: source.show_word_count ?? true,
word_count: source.word_count ?? 0,
character_count: source.character_count ?? 0,
block_count: source.block_count ?? 0,
created_at: ts,
updated_at: ts,
deleted_at: null,
deleted_by: null,
});
return {
id: args.newId,
title,
parent_id: source.parent_id ?? null,
sort_order: sortOrder,
workspace_id: source.workspace_id,
access_scope: source.access_scope,
created_at: ts,
updated_at: ts,
};
},
});
export const listAllForCopy = query({
args: { userId: v.string(), workspaceId: v.string() },
handler: async (ctx, args) => {
const docs = await ctx.db
.query("documents")
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
.collect();
return docs
.filter((d) => d.user_id === args.userId)
.filter((d) => d.deleted_at == null)
.map((d) => ({
id: d.id,
title: d.title ?? null,
parent_id: d.parent_id ?? null,
workspace_id: d.workspace_id,
access_scope: d.access_scope,
sort_order: d.sort_order ?? null,
created_at: d.created_at ?? null,
content: d.content ?? null,
}));
},
});
+149
View File
@@ -0,0 +1,149 @@
import { internalAction, internalMutation, internalQuery, mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { nowIso } from "./_utils/time";
import { internal } from "./_generated/api";
export const get = query({
args: { userId: v.string(), id: v.string() },
handler: async (ctx, args) => {
const job = await ctx.db
.query("jobs")
.withIndex("by_job_id", (q) => q.eq("id", args.id))
.first();
if (!job) return null;
if (job.user_id !== args.userId) return null;
return {
id: job.id,
type: job.type,
status: job.status,
payload: job.payload,
result: job.result,
error: job.error,
created_at: job.created_at,
updated_at: job.updated_at,
started_at: job.started_at,
finished_at: job.finished_at,
};
},
});
export const enqueueDemo = mutation({
args: { userId: v.string(), id: v.string(), ms: v.optional(v.number()) },
handler: async (ctx, args) => {
const ts = nowIso();
const payload = { ms: args.ms ?? 800 };
await ctx.db.insert("jobs", {
id: args.id,
user_id: args.userId,
type: "demo.sleep",
status: "queued",
payload,
result: null,
error: null,
created_at: ts,
updated_at: ts,
started_at: null,
finished_at: null,
});
// 说明:阶段 5 骨架——用 scheduler 触发内部 mutation,再由内部 action 执行耗时逻辑。
await ctx.scheduler.runAfter(0, internal.jobs.start, { id: args.id });
return { ok: true, id: args.id };
},
});
export const start = internalMutation({
args: { id: v.string() },
handler: async (ctx, args) => {
const job = await ctx.db
.query("jobs")
.withIndex("by_job_id", (q) => q.eq("id", args.id))
.first();
if (!job) return;
if (job.status !== "queued") return;
const ts = nowIso();
await ctx.db.patch(job._id, { status: "running", started_at: ts, updated_at: ts });
await ctx.scheduler.runAfter(0, internal.jobs.run, { id: args.id });
},
});
export const run = internalAction({
args: { id: v.string() },
handler: async (ctx, args) => {
const job = await ctx.runQuery(internal.jobs._getInternal, { id: args.id });
if (!job) return;
if (job.status !== "running") return;
try {
if (job.type === "demo.sleep") {
const ms = typeof job.payload?.ms === "number" ? job.payload.ms : 800;
await new Promise((r) => setTimeout(r, ms));
await ctx.runMutation(internal.jobs.finishSuccess, {
id: args.id,
result: { ok: true, sleptMs: ms },
});
return;
}
throw new Error(`未知任务类型:${job.type}`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await ctx.runMutation(internal.jobs.finishFailure, { id: args.id, error: message });
}
},
});
export const _getInternal = internalQuery({
args: { id: v.string() },
handler: async (ctx, args) => {
const job = await ctx.db
.query("jobs")
.withIndex("by_job_id", (q) => q.eq("id", args.id))
.first();
if (!job) return null;
return {
id: job.id,
type: job.type,
status: job.status,
payload: job.payload,
};
},
});
export const finishSuccess = internalMutation({
args: { id: v.string(), result: v.any() },
handler: async (ctx, args) => {
const job = await ctx.db
.query("jobs")
.withIndex("by_job_id", (q) => q.eq("id", args.id))
.first();
if (!job) return;
const ts = nowIso();
await ctx.db.patch(job._id, {
status: "succeeded",
result: args.result,
error: null,
finished_at: ts,
updated_at: ts,
});
},
});
export const finishFailure = internalMutation({
args: { id: v.string(), error: v.string() },
handler: async (ctx, args) => {
const job = await ctx.db
.query("jobs")
.withIndex("by_job_id", (q) => q.eq("id", args.id))
.first();
if (!job) return;
const ts = nowIso();
await ctx.db.patch(job._id, {
status: "failed",
result: null,
error: args.error,
finished_at: ts,
updated_at: ts,
});
},
});
+409
View File
@@ -0,0 +1,409 @@
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;
}
export const getById = query({
args: { userId: v.string(), id: v.string() },
handler: async (ctx, args) => {
const row = await ctx.db
.query("media_assets")
.withIndex("by_asset_id", (q) => q.eq("id", args.id))
.first();
if (!row) return null;
await assertWorkspaceMember(ctx, args.userId, row.workspace_id);
return row;
},
});
export const generateUploadUrl = mutation({
args: { userId: v.string() },
handler: async (ctx, args) => {
// 说明:简单兜底,要求用户至少有一个工作空间 membership。
const membership = await ctx.db
.query("workspace_members")
.withIndex("by_user_id", (q) => q.eq("user_id", args.userId))
.first();
if (!membership) {
throw new Error("尚未初始化工作空间,无法上传");
}
return await ctx.storage.generateUploadUrl();
},
});
export const listByWorkspace = query({
args: {
userId: v.string(),
workspaceId: v.string(),
assetType: v.optional(v.string()),
includeDeleted: v.optional(v.boolean()),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
const includeDeleted = Boolean(args.includeDeleted);
const limit = Math.max(1, Math.min(200, Math.floor(args.limit ?? 12)));
let rows = await ctx.db
.query("media_assets")
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
.order("desc")
.take(limit * 5);
if (!includeDeleted) {
rows = rows.filter((r) => !r.deleted_at);
}
if (args.assetType) {
rows = rows.filter((r) => r.asset_type === args.assetType);
}
// 说明:Convex 的 take 以索引排序为准,这里再截一刀保证输出稳定。
return rows.slice(0, limit);
},
});
export const listByDocument = query({
args: {
userId: v.string(),
documentId: v.string(),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const limit = Math.max(1, Math.min(500, Math.floor(args.limit ?? 200)));
const rows = await ctx.db
.query("media_assets")
.withIndex("by_document", (q) => q.eq("document_id", args.documentId))
.order("desc")
.take(limit * 2);
const filtered = rows.filter((r) => !r.deleted_at);
const ws = filtered[0]?.workspace_id ?? rows[0]?.workspace_id ?? null;
if (ws) await assertWorkspaceMember(ctx, args.userId, ws);
return filtered.slice(0, limit);
},
});
export const listDeletedByWorkspace = 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(2000, Math.floor(args.limit ?? 2000)));
// 说明:Convex 目前不支持“deleted_at is not null”这种索引条件,先全取再过滤(对练手项目足够)。
const rows = await ctx.db
.query("media_assets")
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
.order("desc")
.take(limit * 3);
return rows.filter((r) => Boolean(r.deleted_at) && !r.purged_at).slice(0, limit);
},
});
export const listByIds = query({
args: { userId: v.string(), ids: v.array(v.string()) },
handler: async (ctx, args) => {
const ids = Array.from(new Set(args.ids.filter(Boolean))).slice(0, 200);
const out: any[] = [];
for (const id of ids) {
const row = await ctx.db
.query("media_assets")
.withIndex("by_asset_id", (q) => q.eq("id", id))
.first();
if (!row) continue;
await assertWorkspaceMember(ctx, args.userId, row.workspace_id);
out.push(row);
}
return out;
},
});
export const create = mutation({
args: {
userId: v.string(),
asset: v.object({
id: v.string(),
workspace_id: v.string(),
document_id: v.string(),
asset_type: v.string(),
file_url: v.union(v.string(), v.null()),
thumbnail_url: v.union(v.string(), v.null()),
storage_id: v.optional(v.union(v.id("_storage"), v.null())),
bucket: v.union(v.string(), v.null()),
storage_path: v.union(v.string(), v.null()),
file_name: v.union(v.string(), v.null()),
file_size: v.union(v.number(), v.null()),
mime_type: v.union(v.string(), v.null()),
}),
},
handler: async (ctx, args) => {
await assertWorkspaceMember(ctx, args.userId, args.asset.workspace_id);
const ts = nowIso();
await ctx.db.insert("media_assets", {
...args.asset,
storage_id: args.asset.storage_id ?? null,
ocr_text: null,
ocr_status: null,
deleted_at: null,
deleted_by: null,
purged_at: null,
created_by: args.userId,
created_at: ts,
updated_at: ts,
});
return args.asset;
},
});
export const createWithStorage = mutation({
args: {
userId: v.string(),
storageId: v.id("_storage"),
asset: v.object({
id: v.string(),
workspace_id: v.string(),
document_id: v.string(),
asset_type: v.string(),
file_name: v.union(v.string(), v.null()),
file_size: v.union(v.number(), v.null()),
mime_type: v.union(v.string(), v.null()),
}),
},
handler: async (ctx, args) => {
await assertWorkspaceMember(ctx, args.userId, args.asset.workspace_id);
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.asset.document_id))
.first();
if (!doc || doc.workspace_id !== args.asset.workspace_id) {
throw new Error("目标页面不存在或不属于该工作空间");
}
const url = await ctx.storage.getUrl(args.storageId);
if (!url) {
throw new Error("文件不存在或已过期");
}
const ts = nowIso();
const row = {
id: args.asset.id,
workspace_id: args.asset.workspace_id,
document_id: args.asset.document_id,
asset_type: args.asset.asset_type,
file_url: url,
thumbnail_url: url,
storage_id: args.storageId,
bucket: null,
storage_path: null,
file_name: args.asset.file_name,
file_size: args.asset.file_size,
mime_type: args.asset.mime_type,
ocr_text: null,
ocr_status: null,
deleted_at: null,
deleted_by: null,
purged_at: null,
created_by: args.userId,
created_at: ts,
updated_at: ts,
};
await ctx.db.insert("media_assets", row);
return row;
},
});
export const patchById = mutation({
args: {
userId: v.string(),
id: v.string(),
patch: v.any(),
},
handler: async (ctx, args) => {
const row = await ctx.db
.query("media_assets")
.withIndex("by_asset_id", (q) => q.eq("id", args.id))
.first();
if (!row) throw new Error("资源不存在");
await assertWorkspaceMember(ctx, args.userId, row.workspace_id);
const next = { ...(args.patch as Record<string, unknown>), updated_at: nowIso() };
await ctx.db.patch(row._id, next);
return { ok: true };
},
});
export const refreshUrl = mutation({
args: { userId: v.string(), id: v.string() },
handler: async (ctx, args) => {
const row = await ctx.db
.query("media_assets")
.withIndex("by_asset_id", (q) => q.eq("id", args.id))
.first();
if (!row) throw new Error("资源不存在");
await assertWorkspaceMember(ctx, args.userId, row.workspace_id);
const storageId = (row.storage_id as any) ?? null;
if (!storageId) {
// 外链资源:直接回传现有 URL
return { signedUrl: row.file_url };
}
const url = await ctx.storage.getUrl(storageId);
if (!url) {
throw new Error("文件不存在或已被删除");
}
await ctx.db.patch(row._id, { file_url: url, thumbnail_url: url, updated_at: nowIso() });
return { signedUrl: url };
},
});
export const replaceStorageFromUpload = mutation({
args: { userId: v.string(), id: v.string(), storageId: v.id("_storage") },
handler: async (ctx, args) => {
const row = await ctx.db
.query("media_assets")
.withIndex("by_asset_id", (q) => q.eq("id", args.id))
.first();
if (!row) throw new Error("资源不存在");
await assertWorkspaceMember(ctx, args.userId, row.workspace_id);
const url = await ctx.storage.getUrl(args.storageId);
if (!url) {
throw new Error("文件不存在或已过期");
}
await ctx.db.patch(row._id, {
storage_id: args.storageId,
file_url: url,
thumbnail_url: url,
bucket: null,
storage_path: null,
updated_at: nowIso(),
});
return { ok: true, fileUrl: url };
},
});
export const purgeById = mutation({
args: { userId: v.string(), id: v.string(), expiredDeletedAt: v.string() },
handler: async (ctx, args) => {
const row = await ctx.db
.query("media_assets")
.withIndex("by_asset_id", (q) => q.eq("id", args.id))
.first();
if (!row) throw new Error("未找到附件");
await assertWorkspaceMember(ctx, args.userId, row.workspace_id);
if (row.purged_at) {
return { ok: true, alreadyPurged: true };
}
const storageId = (row.storage_id as any) ?? null;
if (storageId) {
const refs = await ctx.db
.query("media_assets")
.withIndex("by_storage_id", (q) => q.eq("storage_id", storageId))
.collect();
const otherAlive = refs.some((r) => r.id !== row.id && !r.purged_at);
if (!otherAlive) {
await ctx.storage.delete(storageId);
}
}
const ts = nowIso();
await ctx.db.patch(row._id, {
deleted_at: args.expiredDeletedAt,
deleted_by: args.userId,
purged_at: ts,
file_url: null,
thumbnail_url: null,
storage_id: null,
updated_at: ts,
});
return { ok: 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("media_assets")
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
.order("desc")
.take(5000);
const targets = rows.filter((r) => Boolean(r.deleted_at) && !r.purged_at);
if (targets.length === 0) {
return { ok: true, updated: 0 };
}
// 说明:按 storage_id 分组,只有当该 storage_id 没有任何“未清理”的引用时才删除底层文件。
const byStorage = new Map<string, string[]>();
for (const r of targets) {
const sid = (r.storage_id as any) ?? null;
if (!sid) continue;
const list = byStorage.get(sid) ?? [];
list.push(r.id);
byStorage.set(sid, list);
}
for (const [sid] of byStorage.entries()) {
const refs = await ctx.db
.query("media_assets")
.withIndex("by_storage_id", (q) => q.eq("storage_id", sid as any))
.collect();
const alive = refs.some((r) => !r.purged_at && !r.deleted_at);
if (!alive) {
try {
await ctx.storage.delete(sid as any);
} catch {
// ignore
}
}
}
const ts = nowIso();
for (const r of targets) {
await ctx.db.patch(r._id, {
deleted_at: args.expiredDeletedAt,
deleted_by: args.userId,
purged_at: ts,
file_url: null,
thumbnail_url: null,
storage_id: null,
updated_at: ts,
});
}
return { ok: true, updated: targets.length };
},
});
+294
View File
@@ -0,0 +1,294 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { nowIso } from "./_utils/time";
const defaultMindmapData = {
data: { text: "中心主题" },
children: [],
};
function normalizeMindmapId(docId: string, mindmapId: string): string {
const raw = String(mindmapId ?? "").trim();
if (raw) return raw;
// 兜底:不允许空 mindmapId(否则无法索引)
return `legacy-${docId}`;
}
async function requireOwnedDocument(ctx: any, userId: string, docId: string) {
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q: any) => q.eq("id", docId))
.first();
if (!doc) {
throw new Error("页面不存在");
}
if (doc.user_id !== userId) {
throw new Error("无权限");
}
return doc;
}
export const get = query({
args: { userId: v.string(), docId: v.string(), mindmapId: v.string() },
handler: async (ctx, args) => {
const doc = await requireOwnedDocument(ctx, args.userId, args.docId);
const mindmapId = normalizeMindmapId(args.docId, args.mindmapId);
const row = await ctx.db
.query("mindmaps")
.withIndex("by_doc_mindmap", (q) => q.eq("document_id", args.docId).eq("mindmap_id", mindmapId))
.first();
// 兼容旧逻辑:不存在或已删除时仍返回默认导图,避免前端卡死。
if (!row || row.deleted_at != null) {
return {
ok: true,
data: defaultMindmapData,
meta: {
workspace_id: doc.workspace_id,
document_id: args.docId,
mindmap_id: mindmapId,
exists: false,
deleted_at: row?.deleted_at ?? null,
},
};
}
return {
ok: true,
data: row.data ?? defaultMindmapData,
meta: {
workspace_id: row.workspace_id,
document_id: row.document_id,
mindmap_id: row.mindmap_id,
exists: true,
deleted_at: row.deleted_at,
created_at: row.created_at,
updated_at: row.updated_at,
},
};
},
});
export const put = mutation({
args: {
userId: v.string(),
docId: v.string(),
mindmapId: v.string(),
data: v.any(),
createOnly: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const doc = await requireOwnedDocument(ctx, args.userId, args.docId);
const mindmapId = normalizeMindmapId(args.docId, args.mindmapId);
const existing = await ctx.db
.query("mindmaps")
.withIndex("by_doc_mindmap", (q) => q.eq("document_id", args.docId).eq("mindmap_id", mindmapId))
.first();
const ts = nowIso();
const payload = args.data ?? defaultMindmapData;
if (existing && existing.deleted_at == null && args.createOnly) {
return { ok: true, created: false, skipped: true, updated_at: existing.updated_at ?? null };
}
if (existing) {
await ctx.db.patch(existing._id, {
data: payload,
updated_at: ts,
deleted_at: null,
deleted_by: null,
});
return { ok: true, created: false, skipped: false, updated_at: ts };
}
await ctx.db.insert("mindmaps", {
id: `${args.docId}:${mindmapId}`,
user_id: args.userId,
workspace_id: doc.workspace_id,
document_id: args.docId,
mindmap_id: mindmapId,
data: payload,
created_at: ts,
updated_at: ts,
deleted_at: null,
deleted_by: null,
});
return { ok: true, created: true, skipped: false, updated_at: ts };
},
});
export const softDelete = mutation({
args: { userId: v.string(), docId: v.string(), mindmapId: v.string() },
handler: async (ctx, args) => {
await requireOwnedDocument(ctx, args.userId, args.docId);
const mindmapId = normalizeMindmapId(args.docId, args.mindmapId);
const existing = await ctx.db
.query("mindmaps")
.withIndex("by_doc_mindmap", (q) => q.eq("document_id", args.docId).eq("mindmap_id", mindmapId))
.first();
if (!existing) {
// 兼容:不存在也视为成功
return { ok: true, moved: 0 };
}
const ts = nowIso();
await ctx.db.patch(existing._id, { deleted_at: ts, deleted_by: args.userId, updated_at: ts });
return { ok: true, moved: 1, deleted_at: ts };
},
});
export const restore = mutation({
args: { userId: v.string(), docId: v.string(), mindmapId: v.string() },
handler: async (ctx, args) => {
await requireOwnedDocument(ctx, args.userId, args.docId);
const mindmapId = normalizeMindmapId(args.docId, args.mindmapId);
const existing = await ctx.db
.query("mindmaps")
.withIndex("by_doc_mindmap", (q) => q.eq("document_id", args.docId).eq("mindmap_id", mindmapId))
.first();
if (!existing) {
throw new Error("未找到可操作的记录");
}
const ts = nowIso();
await ctx.db.patch(existing._id, { deleted_at: null, deleted_by: null, updated_at: ts });
return { ok: true };
},
});
export const purge = mutation({
args: { userId: v.string(), docId: v.string(), mindmapId: v.string() },
handler: async (ctx, args) => {
await requireOwnedDocument(ctx, args.userId, args.docId);
const mindmapId = normalizeMindmapId(args.docId, args.mindmapId);
const existing = await ctx.db
.query("mindmaps")
.withIndex("by_doc_mindmap", (q) => q.eq("document_id", args.docId).eq("mindmap_id", mindmapId))
.first();
if (!existing) {
throw new Error("未找到可操作的记录");
}
await ctx.db.delete(existing._id);
return { ok: true };
},
});
export const listByWorkspace = query({
args: { userId: v.string(), workspaceId: v.string(), includeDeleted: v.optional(v.boolean()) },
handler: async (ctx, args) => {
// 说明:阶段 6 先按 membership 存在即可,避免引入复杂权限模型。
const membership = await ctx.db
.query("workspace_members")
.withIndex("by_workspace_user", (q) => q.eq("workspace_id", args.workspaceId).eq("user_id", args.userId))
.first();
if (!membership) {
throw new Error("无权操作该工作空间");
}
const rows = await ctx.db
.query("mindmaps")
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
.collect();
const includeDeleted = Boolean(args.includeDeleted);
return rows
.filter((r) => r.user_id === args.userId)
.filter((r) => (includeDeleted ? true : r.deleted_at == null))
.sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? ""))
.map((r) => ({
id: r.id,
workspace_id: r.workspace_id,
document_id: r.document_id,
mindmap_id: r.mindmap_id,
data: r.data ?? defaultMindmapData,
created_at: r.created_at,
updated_at: r.updated_at,
deleted_at: r.deleted_at,
deleted_by: r.deleted_by,
}));
},
});
export const copyByDocument = mutation({
args: { userId: v.string(), sourceDocId: v.string(), targetDocId: v.string() },
handler: async (ctx, args) => {
const sourceDoc = await requireOwnedDocument(ctx, args.userId, args.sourceDocId);
const targetDoc = await requireOwnedDocument(ctx, args.userId, args.targetDocId);
// 说明:同一工作空间复制最常见;若跨工作空间复制,这里仍允许,但 mindmaps 会落到目标页面的 workspace_id。
const sourceRows = await ctx.db
.query("mindmaps")
.withIndex("by_workspace", (q) => q.eq("workspace_id", sourceDoc.workspace_id))
.collect();
const sourceMindmaps = sourceRows
.filter((r) => r.user_id === args.userId)
.filter((r) => r.document_id === args.sourceDocId)
.filter((r) => r.deleted_at == null);
let copied = 0;
const ts = nowIso();
for (const r of sourceMindmaps) {
const existing = await ctx.db
.query("mindmaps")
.withIndex("by_doc_mindmap", (q) => q.eq("document_id", args.targetDocId).eq("mindmap_id", r.mindmap_id))
.first();
if (existing) continue;
await ctx.db.insert("mindmaps", {
id: `${args.targetDocId}:${r.mindmap_id}`,
user_id: args.userId,
workspace_id: targetDoc.workspace_id,
document_id: args.targetDocId,
mindmap_id: r.mindmap_id,
data: r.data ?? defaultMindmapData,
created_at: ts,
updated_at: ts,
deleted_at: null,
deleted_by: null,
});
copied += 1;
}
return { ok: true, copied };
},
});
export const emptyTrashByWorkspace = mutation({
args: { userId: v.string(), workspaceId: v.string() },
handler: async (ctx, args) => {
const membership = await ctx.db
.query("workspace_members")
.withIndex("by_workspace_user", (q) => q.eq("workspace_id", args.workspaceId).eq("user_id", args.userId))
.first();
if (!membership) {
throw new Error("无权操作该工作空间");
}
const rows = await ctx.db
.query("mindmaps")
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
.collect();
const toDelete = rows.filter((r) => r.user_id === args.userId && r.deleted_at != null);
for (const r of toDelete) {
await ctx.db.delete(r._id);
}
return { ok: true, deletedCount: toDelete.length };
},
});
+13
View File
@@ -0,0 +1,13 @@
import { query } from "./_generated/server";
export const ping = query({
args: {},
handler: async () => {
return {
ok: true,
message: "pong",
now: Date.now(),
};
},
});
+49
View File
@@ -0,0 +1,49 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
export const listByWorkspace = query({
args: { userId: v.string(), workspaceId: v.string(), limit: v.optional(v.number()) },
handler: async (ctx, args) => {
const rows = await ctx.db
.query("user_recent_pages")
.withIndex("by_user_workspace", (q) => q.eq("user_id", args.userId).eq("workspace_id", args.workspaceId))
.collect();
const sorted = rows.sort((a, b) => (b.last_accessed_at ?? "").localeCompare(a.last_accessed_at ?? ""));
const limit = Math.max(0, Math.min(args.limit ?? 10, 50));
return sorted.slice(0, limit).map((r) => ({
user_id: r.user_id,
workspace_id: r.workspace_id,
document_id: r.document_id,
last_accessed_at: r.last_accessed_at,
}));
},
});
export const upsert = mutation({
args: { userId: v.string(), workspaceId: v.string(), documentId: v.string(), lastAccessedAt: v.string() },
handler: async (ctx, args) => {
const existing = await ctx.db
.query("user_recent_pages")
.withIndex("by_user_document", (q) => q.eq("user_id", args.userId).eq("document_id", args.documentId))
.first();
if (existing) {
await ctx.db.patch(existing._id, {
workspace_id: args.workspaceId,
last_accessed_at: args.lastAccessedAt,
});
return { ok: true, updated: true };
}
await ctx.db.insert("user_recent_pages", {
user_id: args.userId,
workspace_id: args.workspaceId,
document_id: args.documentId,
last_accessed_at: args.lastAccessedAt,
});
return { ok: true, updated: false };
},
});
+142
View File
@@ -0,0 +1,142 @@
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<string, string | null>();
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,
};
});
},
});
+190
View File
@@ -0,0 +1,190 @@
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
// 说明:
// - 这里先按“兼容现有 Next API 返回结构”的思路设计字段:id/workspace_id/user_id 等命名保持与 Supabase 一致。
// - Convex 自带的 _id 仍然存在,但我们暂时不把它暴露给上层业务,便于逐步迁移与回退。
export default defineSchema({
workspaces: defineTable({
id: v.string(),
name: v.string(),
type: v.union(v.literal("personal"), v.literal("team")),
icon_url: v.union(v.string(), v.null()),
created_by: v.string(),
created_at: v.string(),
})
.index("by_workspace_id", ["id"])
.index("by_created_by", ["created_by"]),
workspace_members: defineTable({
workspace_id: v.string(),
user_id: v.string(),
role: v.string(),
is_default: v.boolean(),
created_at: v.string(),
})
.index("by_user_id", ["user_id"])
.index("by_workspace_user", ["workspace_id", "user_id"])
.index("by_workspace_id", ["workspace_id"]),
documents: defineTable({
id: v.string(),
user_id: v.string(),
workspace_id: v.string(),
title: v.union(v.string(), v.null()),
parent_id: v.union(v.string(), v.null()),
sort_order: v.union(v.number(), v.null()),
is_starred: v.union(v.boolean(), v.null()),
is_template: v.boolean(),
access_scope: v.union(v.literal("private"), v.literal("shared"), v.literal("public")),
// 页面选项(对应 Supabase documents 上的 UI 配置列)
wide_layout: v.union(v.boolean(), v.null()),
use_small_text: v.union(v.boolean(), v.null()),
show_heading_numbers: v.union(v.boolean(), v.null()),
show_toc: v.union(v.boolean(), v.null()),
show_structure: v.union(v.boolean(), v.null()),
protect_editing: v.union(v.boolean(), v.null()),
show_word_count: v.union(v.boolean(), v.null()),
// 统计信息(由客户端编辑器计算后回写)
word_count: v.union(v.number(), v.null()),
character_count: v.union(v.number(), v.null()),
block_count: v.union(v.number(), v.null()),
// 说明:当前文档内容结构还在演进,先用 any 承接(与 Supabase Json 一致的宽松形态)。
content: v.any(),
// 说明:后续可用于搜索/索引(目前先留空,不强制写入)。
raw_text: v.optional(v.union(v.string(), v.null())),
// 时间戳统一使用 ISO 字符串,便于直接复用前端现有排序逻辑。
created_at: v.string(),
updated_at: v.union(v.string(), v.null()),
// 软删除(阶段 4 先不实现垃圾桶逻辑,但字段先留好,便于后续迁移)。
deleted_at: v.union(v.string(), v.null()),
deleted_by: v.union(v.string(), v.null()),
// 兼容旧逻辑:Supabase documents.mindmap_data。
mindmap_data: v.optional(v.any()),
})
.index("by_document_id", ["id"])
.index("by_user", ["user_id"])
.index("by_workspace", ["workspace_id"])
.index("by_workspace_parent", ["workspace_id", "parent_id"]),
// 最近访问(替代 Supabase user_recent_pages
user_recent_pages: defineTable({
user_id: v.string(),
workspace_id: v.string(),
document_id: v.string(),
last_accessed_at: v.string(),
})
.index("by_user_workspace", ["user_id", "workspace_id"])
.index("by_user_document", ["user_id", "document_id"]),
// 阶段 5:异步任务/队列表(最小骨架,后续可扩展为通用作业系统)
jobs: defineTable({
id: v.string(),
user_id: v.string(),
type: v.string(),
status: v.union(v.literal("queued"), v.literal("running"), v.literal("succeeded"), v.literal("failed")),
payload: v.any(),
result: v.union(v.any(), v.null()),
error: v.union(v.string(), v.null()),
created_at: v.string(),
updated_at: v.string(),
started_at: v.union(v.string(), v.null()),
finished_at: v.union(v.string(), v.null()),
})
.index("by_job_id", ["id"])
.index("by_user", ["user_id"])
.index("by_status", ["status"]),
// 阶段 6:媒体/附件(替代 Supabase Storage + media_assets 表)
media_assets: defineTable({
id: v.string(),
workspace_id: v.string(),
document_id: v.string(),
asset_type: v.string(),
file_url: v.union(v.string(), v.null()),
thumbnail_url: v.union(v.string(), v.null()),
// Convex Filesstorage)定位信息
// 说明:早期阶段 6(MinIO 直存)写入的记录可能没有该字段;允许缺失以便平滑迁移。
storage_id: v.optional(v.union(v.id("_storage"), v.null())),
// S3/MinIO 定位信息
bucket: v.union(v.string(), v.null()),
storage_path: v.union(v.string(), v.null()),
file_name: v.union(v.string(), v.null()),
file_size: v.union(v.number(), v.null()),
mime_type: v.union(v.string(), v.null()),
// OCR 相关(后续再接入)
ocr_text: v.union(v.string(), v.null()),
ocr_status: v.union(v.string(), v.null()),
ocr_payload: v.optional(v.any()),
ocr_strategy: v.optional(v.union(v.string(), v.null())),
// 回收站/清理
deleted_at: v.union(v.string(), v.null()),
deleted_by: v.union(v.string(), v.null()),
purged_at: v.union(v.string(), v.null()),
created_by: v.string(),
created_at: v.string(),
updated_at: v.string(),
})
.index("by_asset_id", ["id"])
.index("by_storage_id", ["storage_id"])
.index("by_workspace", ["workspace_id"])
.index("by_document", ["document_id"])
.index("by_workspace_deleted", ["workspace_id", "deleted_at"]),
// M2:思维导图(替代本地文件 public/documents/<docId>/mindmap*.json + Supabase documents.mindmap_data
// 说明:
// - 业务侧依然以 (document_id, mindmap_id) 作为“定位键”,避免跨页面冲突。
// - id 是一个便于排查的全局唯一串(例如 `${docId}:${mindmapId}`),但不要求前端依赖它。
mindmaps: defineTable({
id: v.string(),
user_id: v.string(),
workspace_id: v.string(),
document_id: v.string(),
mindmap_id: v.string(),
data: v.any(),
created_at: v.string(),
updated_at: v.string(),
deleted_at: v.union(v.string(), v.null()),
deleted_by: v.union(v.string(), v.null()),
})
.index("by_mindmap_id", ["id"])
.index("by_doc_mindmap", ["document_id", "mindmap_id"])
.index("by_workspace", ["workspace_id"])
.index("by_workspace_deleted", ["workspace_id", "deleted_at"])
.index("by_user", ["user_id"]),
// M3:页面引用/反链(替代 Supabase RPCrecord_page_ref、list_backlinks
page_references: defineTable({
id: v.string(),
workspace_id: v.string(),
source_page_id: v.string(),
source_block_id: v.union(v.string(), v.null()),
target_page_id: v.string(),
alias: v.union(v.string(), v.null()),
display_mode: v.union(v.literal("inline"), v.literal("embed")),
is_previewable: v.boolean(),
created_by: v.string(),
created_at: v.string(),
updated_at: v.string(),
})
.index("by_reference_id", ["id"])
.index("by_workspace_target", ["workspace_id", "target_page_id"])
.index("by_workspace_source", ["workspace_id", "source_page_id"])
.index("by_unique", ["workspace_id", "source_page_id", "source_block_id", "target_page_id", "display_mode"]),
});
+166
View File
@@ -0,0 +1,166 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { nowIso } from "./_utils/time";
import type { MutationCtx, QueryCtx } from "./_generated/server";
type WorkspaceSummary = {
id: string;
name: string;
type: "personal" | "team";
iconUrl: string | null;
memberCount: number;
isDefault: boolean;
};
async function findWorkspaceById(ctx: QueryCtx | MutationCtx, workspaceId: string) {
return await ctx.db
.query("workspaces")
.withIndex("by_workspace_id", (q) => q.eq("id", workspaceId))
.first();
}
async function countMembers(ctx: QueryCtx | MutationCtx, workspaceId: string): Promise<number> {
const members = await ctx.db
.query("workspace_members")
.withIndex("by_workspace_id", (q) => q.eq("workspace_id", workspaceId))
.collect();
return members.length;
}
export const ensureDefaultWorkspace = mutation({
args: {
userId: v.string(),
fallbackName: v.optional(v.string()),
workspaceIdIfCreate: v.string(),
},
handler: async (ctx, args) => {
const memberships = await ctx.db
.query("workspace_members")
.withIndex("by_user_id", (q) => q.eq("user_id", args.userId))
.collect();
if (memberships.length === 0) {
const workspaceName = (args.fallbackName ?? "").trim()
? `${args.fallbackName!.trim()} 的空间`
: "我的空间";
const ts = nowIso();
await ctx.db.insert("workspaces", {
id: args.workspaceIdIfCreate,
name: workspaceName,
type: "personal",
icon_url: null,
created_by: args.userId,
created_at: ts,
});
await ctx.db.insert("workspace_members", {
workspace_id: args.workspaceIdIfCreate,
user_id: args.userId,
role: "owner",
is_default: true,
created_at: ts,
});
const summary: WorkspaceSummary = {
id: args.workspaceIdIfCreate,
name: workspaceName,
type: "personal",
iconUrl: null,
memberCount: 1,
isDefault: true,
};
return {
workspaces: [summary],
activeWorkspaceId: args.workspaceIdIfCreate,
};
}
// 有 membership 就认为已有 workspace;再兜底一次补齐 workspace 记录。
const workspaceIds = Array.from(new Set(memberships.map((m) => m.workspace_id)));
const summaries: WorkspaceSummary[] = [];
for (const wid of workspaceIds) {
const ws = await findWorkspaceById(ctx, wid);
if (!ws) continue;
const memberCount = await countMembers(ctx, wid);
const isDefault = memberships.some((m) => m.workspace_id === wid && m.is_default);
summaries.push({
id: ws.id,
name: ws.name,
type: ws.type,
iconUrl: ws.icon_url,
memberCount: memberCount || 1,
isDefault,
});
}
// 说明:保持与原 fetchWorkspaceSummaries 一致:默认 workspace 优先,否则取第一个。
const defaultWs = summaries.find((w) => w.isDefault);
const activeWorkspaceId = defaultWs?.id ?? summaries[0]?.id ?? "";
return { workspaces: summaries, activeWorkspaceId };
},
});
export const fetchWorkspaceSummaries = query({
args: { userId: v.string() },
handler: async (ctx, args) => {
// 说明:为了复用 ensureDefaultWorkspace 的返回结构,这里直接走同样的聚合逻辑。
const memberships = await ctx.db
.query("workspace_members")
.withIndex("by_user_id", (q) => q.eq("user_id", args.userId))
.collect();
const workspaceIds = Array.from(new Set(memberships.map((m) => m.workspace_id)));
const summaries: WorkspaceSummary[] = [];
for (const wid of workspaceIds) {
const ws = await findWorkspaceById(ctx, wid);
if (!ws) continue;
const memberCount = await countMembers(ctx, wid);
const isDefault = memberships.some((m) => m.workspace_id === wid && m.is_default);
summaries.push({
id: ws.id,
name: ws.name,
type: ws.type,
iconUrl: ws.icon_url,
memberCount: memberCount || 1,
isDefault,
});
}
const defaultWs = summaries.find((w) => w.isDefault);
const activeWorkspaceId = defaultWs?.id ?? summaries[0]?.id ?? "";
return { workspaces: summaries, activeWorkspaceId };
},
});
export const switchDefaultWorkspace = mutation({
args: { userId: v.string(), workspaceId: v.string() },
handler: async (ctx, args) => {
const target = await ctx.db
.query("workspace_members")
.withIndex("by_workspace_user", (q) =>
q.eq("workspace_id", args.workspaceId).eq("user_id", args.userId),
)
.first();
if (!target) {
throw new Error("无权切换至该工作空间");
}
const memberships = await ctx.db
.query("workspace_members")
.withIndex("by_user_id", (q) => q.eq("user_id", args.userId))
.collect();
// 说明:Convex 暂无批量 update,这里逐条 patch。
for (const m of memberships) {
if (m.is_default) {
await ctx.db.patch(m._id, { is_default: false });
}
}
await ctx.db.patch(target._id, { is_default: true });
return { ok: true };
},
});