0.2.1 onlyoffice修复
This commit is contained in:
+65
@@ -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: {};
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
@@ -0,0 +1,4 @@
|
||||
export function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { query } from "./_generated/server";
|
||||
|
||||
export const ping = query({
|
||||
args: {},
|
||||
handler: async () => {
|
||||
return {
|
||||
ok: true,
|
||||
message: "pong",
|
||||
now: Date.now(),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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 Files(storage)定位信息
|
||||
// 说明:早期阶段 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 RPC:record_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"]),
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user