0.3.5 共享功能修复
This commit is contained in:
+4
@@ -8,6 +8,7 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type * as _utils_attachmentExtract from "../_utils/attachmentExtract.js";
|
||||
import type * as _utils_documentTree from "../_utils/documentTree.js";
|
||||
import type * as _utils_id from "../_utils/id.js";
|
||||
import type * as _utils_ingestJobs from "../_utils/ingestJobs.js";
|
||||
@@ -19,6 +20,7 @@ import type * as documentGroupShares from "../documentGroupShares.js";
|
||||
import type * as documentShares from "../documentShares.js";
|
||||
import type * as documentStars from "../documentStars.js";
|
||||
import type * as documents from "../documents.js";
|
||||
import type * as groupInvitations from "../groupInvitations.js";
|
||||
import type * as groupMembers from "../groupMembers.js";
|
||||
import type * as groups from "../groups.js";
|
||||
import type * as http from "../http.js";
|
||||
@@ -39,6 +41,7 @@ import type {
|
||||
} from "convex/server";
|
||||
|
||||
declare const fullApi: ApiFromModules<{
|
||||
"_utils/attachmentExtract": typeof _utils_attachmentExtract;
|
||||
"_utils/documentTree": typeof _utils_documentTree;
|
||||
"_utils/id": typeof _utils_id;
|
||||
"_utils/ingestJobs": typeof _utils_ingestJobs;
|
||||
@@ -50,6 +53,7 @@ declare const fullApi: ApiFromModules<{
|
||||
documentShares: typeof documentShares;
|
||||
documentStars: typeof documentStars;
|
||||
documents: typeof documents;
|
||||
groupInvitations: typeof groupInvitations;
|
||||
groupMembers: typeof groupMembers;
|
||||
groups: typeof groups;
|
||||
http: typeof http;
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import JSZip from "jszip";
|
||||
|
||||
const MAX_TEXT_CHARS = 120_000;
|
||||
|
||||
function clampText(input: string, maxChars = MAX_TEXT_CHARS): string {
|
||||
const trimmed = String(input || "").replace(/\s+\n/g, "\n").trim();
|
||||
if (!trimmed) return "";
|
||||
if (trimmed.length <= maxChars) return trimmed;
|
||||
return `${trimmed.slice(0, maxChars)}…`;
|
||||
}
|
||||
|
||||
function decodeXmlEntities(input: string): string {
|
||||
return input
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => {
|
||||
const code = Number.parseInt(hex, 16);
|
||||
if (!Number.isFinite(code)) return "";
|
||||
return String.fromCodePoint(code);
|
||||
})
|
||||
.replace(/&#(\d+);/g, (_, dec) => {
|
||||
const code = Number.parseInt(dec, 10);
|
||||
if (!Number.isFinite(code)) return "";
|
||||
return String.fromCodePoint(code);
|
||||
});
|
||||
}
|
||||
|
||||
function getExtension(fileName: string | null | undefined): string {
|
||||
const raw = String(fileName ?? "").trim().toLowerCase();
|
||||
const idx = raw.lastIndexOf(".");
|
||||
if (idx === -1) return "";
|
||||
return raw.slice(idx + 1).replace(/[^a-z0-9]+/g, "");
|
||||
}
|
||||
|
||||
function detectKind(args: { mimeType?: string | null; fileName?: string | null }): "pdf" | "docx" | "pptx" | "xlsx" | null {
|
||||
const mime = String(args.mimeType ?? "").toLowerCase().trim();
|
||||
if (mime === "application/pdf") return "pdf";
|
||||
if (mime === "application/vnd.openxmlformats-officedocument.wordprocessingml.document") return "docx";
|
||||
if (mime === "application/vnd.openxmlformats-officedocument.presentationml.presentation") return "pptx";
|
||||
if (mime === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") return "xlsx";
|
||||
|
||||
const ext = getExtension(args.fileName ?? null);
|
||||
if (ext === "pdf") return "pdf";
|
||||
if (ext === "docx") return "docx";
|
||||
if (ext === "pptx") return "pptx";
|
||||
if (ext === "xlsx") return "xlsx";
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractTextFromXmlByTag(xml: string, tagName: string): string {
|
||||
const out: string[] = [];
|
||||
const re = new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)<\\/${tagName}>`, "g");
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(xml))) {
|
||||
const raw = m[1] ?? "";
|
||||
const clean = decodeXmlEntities(raw).replace(/\s+/g, " ").trim();
|
||||
if (clean) out.push(clean);
|
||||
}
|
||||
return out.join(" ").trim();
|
||||
}
|
||||
|
||||
function extractDocxText(documentXml: string): string {
|
||||
const paras = documentXml.split(/<w:p[\s>]/g);
|
||||
const out: string[] = [];
|
||||
for (const p of paras) {
|
||||
const line: string[] = [];
|
||||
const re = /<w:t[^>]*>([\s\S]*?)<\/w:t>/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(p))) {
|
||||
const raw = m[1] ?? "";
|
||||
const clean = decodeXmlEntities(raw).replace(/\s+/g, " ").trim();
|
||||
if (clean) line.push(clean);
|
||||
}
|
||||
const joined = line.join("").trim();
|
||||
if (joined) out.push(joined);
|
||||
}
|
||||
return out.join("\n").trim();
|
||||
}
|
||||
|
||||
function extractPptxText(slideXmls: string[]): string {
|
||||
const out: string[] = [];
|
||||
for (const xml of slideXmls) {
|
||||
const line = extractTextFromXmlByTag(xml, "a:t");
|
||||
if (line) out.push(line);
|
||||
}
|
||||
return out.join("\n\n").trim();
|
||||
}
|
||||
|
||||
function extractXlsxText(args: { sharedStringsXml: string | null; sheetXmls: string[] }): string {
|
||||
const sharedStrings: string[] = [];
|
||||
if (args.sharedStringsXml) {
|
||||
const re = /<t[^>]*>([\s\S]*?)<\/t>/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(args.sharedStringsXml))) {
|
||||
const raw = m[1] ?? "";
|
||||
const clean = decodeXmlEntities(raw).replace(/\s+/g, " ").trim();
|
||||
if (clean) sharedStrings.push(clean);
|
||||
}
|
||||
}
|
||||
|
||||
const out: string[] = [];
|
||||
for (const xml of args.sheetXmls) {
|
||||
const rows = xml.split(/<row[\s>]/g);
|
||||
for (const r of rows) {
|
||||
const cells: string[] = [];
|
||||
|
||||
// t="s" => sharedStrings index;t="inlineStr" => <is><t>...;默认 => <v>number
|
||||
const cellRe = /<c\b[^>]*?(?:t="([^"]+)")?[^>]*>([\s\S]*?)<\/c>/g;
|
||||
let cm: RegExpExecArray | null;
|
||||
while ((cm = cellRe.exec(r))) {
|
||||
const t = (cm[1] ?? "").trim();
|
||||
const body = cm[2] ?? "";
|
||||
if (t === "inlineStr") {
|
||||
const inline = extractTextFromXmlByTag(body, "t");
|
||||
if (inline) cells.push(inline);
|
||||
continue;
|
||||
}
|
||||
const vMatch = /<v>([\s\S]*?)<\/v>/.exec(body);
|
||||
if (!vMatch) continue;
|
||||
const rawV = decodeXmlEntities(String(vMatch[1] ?? "")).trim();
|
||||
if (!rawV) continue;
|
||||
if (t === "s") {
|
||||
const idx = Number.parseInt(rawV, 10);
|
||||
const s = Number.isFinite(idx) ? (sharedStrings[idx] ?? "") : "";
|
||||
if (s) cells.push(s);
|
||||
} else {
|
||||
cells.push(rawV);
|
||||
}
|
||||
}
|
||||
|
||||
const line = cells.join(" ").replace(/\s+/g, " ").trim();
|
||||
if (line) out.push(line);
|
||||
}
|
||||
}
|
||||
return out.join("\n").trim();
|
||||
}
|
||||
|
||||
export type AttachmentExtractOk = {
|
||||
ok: true;
|
||||
strategy: string;
|
||||
text: string;
|
||||
meta?: Record<string, unknown>;
|
||||
};
|
||||
export type AttachmentExtractResult =
|
||||
| AttachmentExtractOk
|
||||
| { ok: false; strategy: string; reason: string; meta?: Record<string, unknown> };
|
||||
|
||||
export async function extractTextFromAttachment(args: {
|
||||
mimeType: string | null;
|
||||
fileName: string | null;
|
||||
bytes: ArrayBuffer;
|
||||
}): Promise<AttachmentExtractResult> {
|
||||
const kind = detectKind({ mimeType: args.mimeType, fileName: args.fileName });
|
||||
if (!kind) {
|
||||
return { ok: false, strategy: "unsupported", reason: "暂不支持该附件类型" };
|
||||
}
|
||||
|
||||
if (kind === "pdf") {
|
||||
try {
|
||||
const mod = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
||||
const data = new Uint8Array(args.bytes);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const loadingTask = (mod as any).getDocument({ data });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const pdf = await (loadingTask as any).promise;
|
||||
const out: string[] = [];
|
||||
const pages = Number(pdf?.numPages ?? 0) || 0;
|
||||
for (let i = 1; i <= pages; i += 1) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const page = await (pdf as any).getPage(i);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const content = await (page as any).getTextContent();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const items = Array.isArray((content as any)?.items) ? (content as any).items : [];
|
||||
const line = items
|
||||
.map((it: any) => (typeof it?.str === "string" ? it.str : ""))
|
||||
.join(" ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
if (line) out.push(line);
|
||||
}
|
||||
const text = clampText(out.join("\n\n"));
|
||||
if (!text) {
|
||||
return { ok: false, strategy: "pdfjs", reason: "未提取到可用文本", meta: { pages } };
|
||||
}
|
||||
return { ok: true, strategy: "pdfjs", text, meta: { pages } };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, strategy: "pdfjs", reason: message };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const zip = await JSZip.loadAsync(args.bytes);
|
||||
if (kind === "docx") {
|
||||
const file = zip.file("word/document.xml");
|
||||
const xml = file ? await file.async("string") : "";
|
||||
const text = clampText(extractDocxText(xml));
|
||||
if (!text) return { ok: false, strategy: "docx.xml", reason: "未提取到可用文本" };
|
||||
return { ok: true, strategy: "docx.xml", text };
|
||||
}
|
||||
|
||||
if (kind === "pptx") {
|
||||
const slideFiles = Object.keys(zip.files)
|
||||
.filter((p) => /^ppt\/slides\/slide\d+\.xml$/i.test(p))
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
||||
const slideXmls: string[] = [];
|
||||
for (const p of slideFiles) {
|
||||
const f = zip.file(p);
|
||||
if (!f) continue;
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
slideXmls.push(await f.async("string"));
|
||||
}
|
||||
const text = clampText(extractPptxText(slideXmls));
|
||||
if (!text) return { ok: false, strategy: "pptx.xml", reason: "未提取到可用文本" };
|
||||
return { ok: true, strategy: "pptx.xml", text, meta: { slides: slideXmls.length } };
|
||||
}
|
||||
|
||||
if (kind === "xlsx") {
|
||||
const sharedStringsXml = await (async () => {
|
||||
const f = zip.file("xl/sharedStrings.xml");
|
||||
if (!f) return null;
|
||||
return await f.async("string");
|
||||
})();
|
||||
|
||||
const sheetFiles = Object.keys(zip.files)
|
||||
.filter((p) => /^xl\/worksheets\/sheet\d+\.xml$/i.test(p))
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
||||
const sheetXmls: string[] = [];
|
||||
for (const p of sheetFiles) {
|
||||
const f = zip.file(p);
|
||||
if (!f) continue;
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
sheetXmls.push(await f.async("string"));
|
||||
}
|
||||
const text = clampText(extractXlsxText({ sharedStringsXml, sheetXmls }));
|
||||
if (!text) return { ok: false, strategy: "xlsx.xml", reason: "未提取到可用文本" };
|
||||
return { ok: true, strategy: "xlsx.xml", text, meta: { sheets: sheetXmls.length } };
|
||||
}
|
||||
|
||||
return { ok: false, strategy: "unsupported", reason: "暂不支持该附件类型" };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, strategy: "zip", reason: message };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,10 @@ export function ingestMediaAssetJobId(assetId: string): string {
|
||||
return `ingest:media:${assetId}`;
|
||||
}
|
||||
|
||||
export function extractMediaAssetTextJobId(assetId: string): string {
|
||||
return `extract:media:${assetId}`;
|
||||
}
|
||||
|
||||
export async function enqueueIngestDocumentJob(
|
||||
ctx: MutationCtx,
|
||||
args: { userId: string; documentId: string; debounceMs?: number },
|
||||
@@ -123,3 +127,18 @@ export async function enqueueIngestMediaAssetJob(
|
||||
});
|
||||
return { id };
|
||||
}
|
||||
|
||||
export async function enqueueExtractMediaAssetTextJob(
|
||||
ctx: MutationCtx,
|
||||
args: { userId: string; assetId: string; debounceMs?: number },
|
||||
): Promise<{ id: string }> {
|
||||
const id = extractMediaAssetTextJobId(args.assetId);
|
||||
await upsertJob(ctx, {
|
||||
id,
|
||||
userId: args.userId,
|
||||
type: "extract.media_asset_text",
|
||||
payload: { assetId: args.assetId },
|
||||
debounceMs: args.debounceMs,
|
||||
});
|
||||
return { id };
|
||||
}
|
||||
|
||||
@@ -30,6 +30,15 @@ async function requireGroup(ctx: any, groupId: string) {
|
||||
return group;
|
||||
}
|
||||
|
||||
async function requireGroupMember(ctx: any, groupId: string, userId: string) {
|
||||
const membership = await ctx.db
|
||||
.query("group_members")
|
||||
.withIndex("by_group_user", (q: any) => q.eq("group_id", groupId).eq("user_id", userId))
|
||||
.first();
|
||||
if (!membership) throw new Error("无权限(你不在该群组内)");
|
||||
return membership;
|
||||
}
|
||||
|
||||
export const listByDocument = query({
|
||||
args: { documentId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
@@ -63,6 +72,8 @@ export const listByDocument = query({
|
||||
groupId: share.group_id,
|
||||
groupName: group?.name ?? null,
|
||||
includeDescendants: share.include_descendants,
|
||||
disableDownload: Boolean((share as any).disable_download),
|
||||
disableCopy: Boolean((share as any).disable_copy),
|
||||
editableUserIds,
|
||||
updatedAt: share.updated_at,
|
||||
});
|
||||
@@ -78,15 +89,20 @@ export const upsert = mutation({
|
||||
groupId: v.string(),
|
||||
includeDescendants: v.optional(v.boolean()),
|
||||
editableUserIds: v.array(v.string()),
|
||||
disableDownload: v.optional(v.boolean()),
|
||||
disableCopy: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
const doc = await requireOwnedDocument(ctx, args.documentId, userId);
|
||||
const group = await requireGroup(ctx, args.groupId);
|
||||
if (group.workspace_id !== doc.workspace_id) throw new Error("群组不属于当前工作空间");
|
||||
await requireGroupMember(ctx, args.groupId, userId);
|
||||
|
||||
const ts = nowIso();
|
||||
const includeDescendants = Boolean(args.includeDescendants);
|
||||
const disableDownload = Boolean(args.disableDownload);
|
||||
const disableCopy = Boolean(args.disableCopy);
|
||||
|
||||
const existingShare = await ctx.db
|
||||
.query("document_group_shares")
|
||||
@@ -96,6 +112,8 @@ export const upsert = mutation({
|
||||
if (existingShare) {
|
||||
await ctx.db.patch(existingShare._id, {
|
||||
include_descendants: includeDescendants,
|
||||
disable_download: disableDownload,
|
||||
disable_copy: disableCopy,
|
||||
updated_at: ts,
|
||||
});
|
||||
} else {
|
||||
@@ -104,6 +122,8 @@ export const upsert = mutation({
|
||||
group_id: args.groupId,
|
||||
document_id: doc.id,
|
||||
include_descendants: includeDescendants,
|
||||
disable_download: disableDownload,
|
||||
disable_copy: disableCopy,
|
||||
created_by: userId,
|
||||
created_at: ts,
|
||||
updated_at: ts,
|
||||
|
||||
@@ -62,6 +62,8 @@ export const listByDocument = query({
|
||||
username: user?.name ?? null,
|
||||
permission: share.permission,
|
||||
includeDescendants: share.include_descendants,
|
||||
disableDownload: Boolean((share as any).disable_download),
|
||||
disableCopy: Boolean((share as any).disable_copy),
|
||||
createdAt: share.created_at,
|
||||
updatedAt: share.updated_at,
|
||||
});
|
||||
@@ -77,6 +79,8 @@ export const upsert = mutation({
|
||||
username: v.string(),
|
||||
permission,
|
||||
includeDescendants: v.optional(v.boolean()),
|
||||
disableDownload: v.optional(v.boolean()),
|
||||
disableCopy: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
@@ -86,6 +90,8 @@ export const upsert = mutation({
|
||||
|
||||
const ts = nowIso();
|
||||
const includeDescendants = Boolean(args.includeDescendants);
|
||||
const disableDownload = Boolean(args.disableDownload);
|
||||
const disableCopy = Boolean(args.disableCopy);
|
||||
|
||||
// 确保对方成为 workspace member(否则无法看到该 workspace 的数据)
|
||||
const existingMember = await ctx.db
|
||||
@@ -113,6 +119,8 @@ export const upsert = mutation({
|
||||
await ctx.db.patch(existingShare._id, {
|
||||
permission: args.permission,
|
||||
include_descendants: includeDescendants,
|
||||
disable_download: disableDownload,
|
||||
disable_copy: disableCopy,
|
||||
updated_at: ts,
|
||||
});
|
||||
} else {
|
||||
@@ -122,6 +130,8 @@ export const upsert = mutation({
|
||||
shared_with_user_id: sharedWithUserId,
|
||||
permission: args.permission,
|
||||
include_descendants: includeDescendants,
|
||||
disable_download: disableDownload,
|
||||
disable_copy: disableCopy,
|
||||
created_by: userId,
|
||||
created_at: ts,
|
||||
updated_at: ts,
|
||||
@@ -210,3 +220,137 @@ export const listShareRootsByWorkspace = query({
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const listMyShareRoots = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
const incomingRows = await ctx.db
|
||||
.query("document_shares")
|
||||
.withIndex("by_shared_with_user", (q: any) => q.eq("shared_with_user_id", userId))
|
||||
.collect();
|
||||
|
||||
// 说明:只统计“我共享出去的”(自己是 created_by);用于顶部共享面板展示摘要。
|
||||
const outgoingRows = await ctx.db
|
||||
.query("document_shares")
|
||||
.withIndex("by_created_by", (q: any) => q.eq("created_by", userId))
|
||||
.collect();
|
||||
|
||||
// 说明:去重/合并:同一页面共享给多个用户时,只展示一条,并累计 sharedWithCount。
|
||||
const outgoingByKey = new Map<
|
||||
string,
|
||||
{
|
||||
workspaceId: string;
|
||||
documentId: string;
|
||||
includeDescendants: boolean;
|
||||
sharedWithCount: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const row of outgoingRows) {
|
||||
const key = `${row.workspace_id}:${row.document_id}`;
|
||||
const existing = outgoingByKey.get(key);
|
||||
if (!existing) {
|
||||
outgoingByKey.set(key, {
|
||||
workspaceId: row.workspace_id,
|
||||
documentId: row.document_id,
|
||||
includeDescendants: Boolean(row.include_descendants),
|
||||
sharedWithCount: 1,
|
||||
updatedAt: String(row.updated_at ?? ""),
|
||||
});
|
||||
} else {
|
||||
existing.includeDescendants = existing.includeDescendants || Boolean(row.include_descendants);
|
||||
existing.sharedWithCount += 1;
|
||||
const u = String(row.updated_at ?? "");
|
||||
if (u && u.localeCompare(existing.updatedAt) > 0) {
|
||||
existing.updatedAt = u;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const workspaceNameCache = new Map<string, string | null>();
|
||||
const getWorkspaceName = async (workspaceId: string): Promise<string | null> => {
|
||||
const cached = workspaceNameCache.get(workspaceId);
|
||||
if (workspaceNameCache.has(workspaceId)) return cached ?? null;
|
||||
const ws = await ctx.db
|
||||
.query("workspaces")
|
||||
.withIndex("by_workspace_id", (q: any) => q.eq("id", workspaceId))
|
||||
.first();
|
||||
const name = ws?.name ?? null;
|
||||
workspaceNameCache.set(workspaceId, name);
|
||||
return name;
|
||||
};
|
||||
|
||||
const documentCache = new Map<
|
||||
string,
|
||||
{ exists: boolean; deleted: boolean; title: string | null }
|
||||
>();
|
||||
const getDocumentInfo = async (
|
||||
documentId: string,
|
||||
): Promise<{ exists: boolean; deleted: boolean; title: string | null }> => {
|
||||
const cached = documentCache.get(documentId);
|
||||
if (documentCache.has(documentId)) {
|
||||
return cached ?? { exists: false, deleted: true, title: null };
|
||||
}
|
||||
const doc = await ctx.db
|
||||
.query("documents")
|
||||
.withIndex("by_document_id", (q: any) => q.eq("id", documentId))
|
||||
.first();
|
||||
const info = doc
|
||||
? { exists: true, deleted: doc.deleted_at != null, title: (doc.title ?? null) as string | null }
|
||||
: { exists: false, deleted: true, title: null };
|
||||
documentCache.set(documentId, info);
|
||||
return info;
|
||||
};
|
||||
|
||||
const incoming = [];
|
||||
for (const row of incomingRows) {
|
||||
// 说明:若对方仅写入了 share 但没把我加入 workspace,则这里会被文档/页面接口挡住;
|
||||
// 为避免面板出现“打不开的幽灵分享”,这里要求我确实是 workspace member。
|
||||
const member = await ctx.db
|
||||
.query("workspace_members")
|
||||
.withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", row.workspace_id).eq("user_id", userId))
|
||||
.first();
|
||||
if (!member) continue;
|
||||
|
||||
const docInfo = await getDocumentInfo(row.document_id);
|
||||
if (!docInfo.exists) continue;
|
||||
if (docInfo.deleted) continue;
|
||||
|
||||
incoming.push({
|
||||
workspaceId: row.workspace_id,
|
||||
workspaceName: await getWorkspaceName(row.workspace_id),
|
||||
documentId: row.document_id,
|
||||
documentTitle: docInfo.title,
|
||||
permission: row.permission,
|
||||
includeDescendants: row.include_descendants,
|
||||
createdBy: row.created_by,
|
||||
updatedAt: row.updated_at,
|
||||
});
|
||||
}
|
||||
|
||||
const outgoing = [];
|
||||
for (const v of outgoingByKey.values()) {
|
||||
const docInfo = await getDocumentInfo(v.documentId);
|
||||
if (!docInfo.exists) continue;
|
||||
if (docInfo.deleted) continue;
|
||||
|
||||
outgoing.push({
|
||||
workspaceId: v.workspaceId,
|
||||
workspaceName: await getWorkspaceName(v.workspaceId),
|
||||
documentId: v.documentId,
|
||||
documentTitle: docInfo.title,
|
||||
includeDescendants: v.includeDescendants,
|
||||
sharedWithCount: v.sharedWithCount,
|
||||
updatedAt: v.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
incoming.sort((a: any, b: any) => String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? "")));
|
||||
outgoing.sort((a: any, b: any) => String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? "")));
|
||||
|
||||
return { incoming, outgoing };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -103,7 +103,11 @@ async function resolveGroupSharePermission(ctx: any, doc: any, userId: string):
|
||||
export const isStarred = query({
|
||||
args: { documentId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
// 退出登录/未登录时,客户端仍可能会发起 isStarred 查询(例如 Breadcrumb 仍在渲染)。
|
||||
// 这里不要抛错,直接返回 false,避免把“未登录”作为运行时错误冒泡到前端。
|
||||
const authUserId = await getAuthUserId(ctx);
|
||||
if (authUserId === null) return false;
|
||||
const userId = String(authUserId);
|
||||
|
||||
const doc = await ctx.db
|
||||
.query("documents")
|
||||
@@ -182,4 +186,3 @@ export const toggle = mutation({
|
||||
return { ok: true, starred: true };
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
import { nowIso } from "./_utils/time";
|
||||
import { collectSubtree } from "./_utils/documentTree";
|
||||
import { enqueueIngestDocumentJob } from "./_utils/ingestJobs";
|
||||
import { extractTextFromDocumentContent } from "./_utils/text";
|
||||
|
||||
const accessScope = v.union(v.literal("private"), v.literal("shared"), v.literal("public"));
|
||||
|
||||
@@ -17,6 +18,8 @@ async function requireUserId(ctx: any): Promise<string> {
|
||||
|
||||
type SharePermission = "read" | "edit";
|
||||
|
||||
type SharePolicy = { permission: SharePermission; disableDownload: boolean; disableCopy: boolean };
|
||||
|
||||
async function requireWorkspaceMember(ctx: any, workspaceId: string, userId: string) {
|
||||
const member = await ctx.db
|
||||
.query("workspace_members")
|
||||
@@ -28,13 +31,17 @@ async function requireWorkspaceMember(ctx: any, workspaceId: string, userId: str
|
||||
return member;
|
||||
}
|
||||
|
||||
async function resolveSharePermission(ctx: any, doc: any, userId: string): Promise<SharePermission | null> {
|
||||
async function resolveSharePolicy(ctx: any, doc: any, userId: string): Promise<SharePolicy | null> {
|
||||
const direct = await ctx.db
|
||||
.query("document_shares")
|
||||
.withIndex("by_doc_user", (q: any) => q.eq("document_id", doc.id).eq("shared_with_user_id", userId))
|
||||
.first();
|
||||
if (direct) {
|
||||
return direct.permission as SharePermission;
|
||||
return {
|
||||
permission: direct.permission as SharePermission,
|
||||
disableDownload: Boolean((direct as any).disable_download),
|
||||
disableCopy: Boolean((direct as any).disable_copy),
|
||||
};
|
||||
}
|
||||
|
||||
let parentId: string | null = doc.parent_id ?? null;
|
||||
@@ -44,7 +51,11 @@ async function resolveSharePermission(ctx: any, doc: any, userId: string): Promi
|
||||
.withIndex("by_doc_user", (q: any) => q.eq("document_id", parentId).eq("shared_with_user_id", userId))
|
||||
.first();
|
||||
if (parentShare && parentShare.include_descendants) {
|
||||
return parentShare.permission as SharePermission;
|
||||
return {
|
||||
permission: parentShare.permission as SharePermission,
|
||||
disableDownload: Boolean((parentShare as any).disable_download),
|
||||
disableCopy: Boolean((parentShare as any).disable_copy),
|
||||
};
|
||||
}
|
||||
|
||||
const parent = await ctx.db
|
||||
@@ -57,7 +68,12 @@ async function resolveSharePermission(ctx: any, doc: any, userId: string): Promi
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveGroupSharePermission(ctx: any, doc: any, userId: string): Promise<SharePermission | null> {
|
||||
async function resolveSharePermission(ctx: any, doc: any, userId: string): Promise<SharePermission | null> {
|
||||
const policy = await resolveSharePolicy(ctx, doc, userId);
|
||||
return policy?.permission ?? null;
|
||||
}
|
||||
|
||||
async function resolveGroupSharePolicy(ctx: any, doc: any, userId: string): Promise<SharePolicy | null> {
|
||||
const memberships = await ctx.db
|
||||
.query("group_members")
|
||||
.withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", doc.workspace_id).eq("user_id", userId))
|
||||
@@ -77,6 +93,8 @@ async function resolveGroupSharePermission(ctx: any, doc: any, userId: string):
|
||||
};
|
||||
|
||||
let best: SharePermission | null = null;
|
||||
let disableDownload = false;
|
||||
let disableCopy = false;
|
||||
|
||||
const checkDocId = async (documentId: string, requireIncludeDesc: boolean) => {
|
||||
const shares = await ctx.db
|
||||
@@ -89,23 +107,25 @@ async function resolveGroupSharePermission(ctx: any, doc: any, userId: string):
|
||||
if (!groupIds.has(gid)) continue;
|
||||
if (requireIncludeDesc && !s.include_descendants) continue;
|
||||
|
||||
disableDownload = disableDownload || Boolean((s as any).disable_download);
|
||||
disableCopy = disableCopy || Boolean((s as any).disable_copy);
|
||||
|
||||
const perm = await permissionFromGroup(documentId, gid);
|
||||
if (perm === "edit") {
|
||||
best = "edit";
|
||||
return true;
|
||||
} else {
|
||||
best = best ?? "read";
|
||||
}
|
||||
best = best ?? "read";
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// 当前页面是否被群组公开
|
||||
if (await checkDocId(doc.id, false)) return "edit";
|
||||
await checkDocId(doc.id, false);
|
||||
|
||||
// 沿父链查找“包含子页面”的群组公开
|
||||
let parentId: string | null = doc.parent_id ?? null;
|
||||
for (let depth = 0; depth < 60 && parentId; depth += 1) {
|
||||
if (await checkDocId(parentId, true)) return "edit";
|
||||
await checkDocId(parentId, true);
|
||||
|
||||
const parent = await ctx.db
|
||||
.query("documents")
|
||||
@@ -114,7 +134,48 @@ async function resolveGroupSharePermission(ctx: any, doc: any, userId: string):
|
||||
parentId = parent?.parent_id ?? null;
|
||||
}
|
||||
|
||||
return best;
|
||||
if (!best) return null;
|
||||
return { permission: best, disableDownload, disableCopy };
|
||||
}
|
||||
|
||||
async function resolveGroupSharePermission(ctx: any, doc: any, userId: string): Promise<SharePermission | null> {
|
||||
const policy = await resolveGroupSharePolicy(ctx, doc, userId);
|
||||
return policy?.permission ?? null;
|
||||
}
|
||||
|
||||
async function purgeDocumentShareRelations(ctx: any, workspaceId: string, documentId: string) {
|
||||
// 说明:页面被“永久删除/清空回收站”后,需要级联清理共享关系;
|
||||
// 否则被分享者可能在“共享页面/公共页面”里看到幽灵条目(点开 404 / 无标题)。
|
||||
const directShares = await ctx.db
|
||||
.query("document_shares")
|
||||
.withIndex("by_document", (q: any) => q.eq("document_id", documentId))
|
||||
.collect();
|
||||
for (const row of directShares) {
|
||||
await ctx.db.delete(row._id);
|
||||
}
|
||||
|
||||
const groupShares = await ctx.db
|
||||
.query("document_group_shares")
|
||||
.withIndex("by_document", (q: any) => q.eq("document_id", documentId))
|
||||
.collect();
|
||||
|
||||
const touchedGroupIds = new Set<string>();
|
||||
for (const row of groupShares) {
|
||||
touchedGroupIds.add(String(row.group_id));
|
||||
await ctx.db.delete(row._id);
|
||||
}
|
||||
|
||||
// 删除该文档在相关群组下的“用户可编辑覆盖权限”
|
||||
for (const groupId of touchedGroupIds) {
|
||||
const perms = await ctx.db
|
||||
.query("document_group_user_permissions")
|
||||
.withIndex("by_workspace_group", (q: any) => q.eq("workspace_id", workspaceId).eq("group_id", groupId))
|
||||
.collect();
|
||||
for (const p of perms) {
|
||||
if (String(p.document_id) !== documentId) continue;
|
||||
await ctx.db.delete(p._id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const getMeta = query({
|
||||
@@ -135,14 +196,18 @@ export const getMeta = query({
|
||||
}
|
||||
|
||||
let canEdit = false;
|
||||
let disableDownload = false;
|
||||
let disableCopy = false;
|
||||
if (doc.user_id === userId) {
|
||||
canEdit = true;
|
||||
} else if (doc.access_scope === "public") {
|
||||
canEdit = false;
|
||||
} else {
|
||||
const perm = (await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId));
|
||||
if (!perm) return null;
|
||||
canEdit = perm === "edit";
|
||||
const policy = (await resolveSharePolicy(ctx, doc, userId)) ?? (await resolveGroupSharePolicy(ctx, doc, userId));
|
||||
if (!policy) return null;
|
||||
canEdit = policy.permission === "edit";
|
||||
disableDownload = policy.disableDownload;
|
||||
disableCopy = policy.disableCopy;
|
||||
}
|
||||
return {
|
||||
id: doc.id,
|
||||
@@ -150,6 +215,8 @@ export const getMeta = query({
|
||||
workspace_id: doc.workspace_id,
|
||||
access_scope: doc.access_scope,
|
||||
can_edit: canEdit,
|
||||
disable_download: disableDownload,
|
||||
disable_copy: disableCopy,
|
||||
title: doc.title ?? null,
|
||||
parent_id: doc.parent_id ?? null,
|
||||
created_at: doc.created_at,
|
||||
@@ -168,6 +235,37 @@ export const getMeta = query({
|
||||
},
|
||||
});
|
||||
|
||||
export const getPermissionForUser = 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.deleted_at != null) return null;
|
||||
|
||||
// workspace 权限
|
||||
await requireWorkspaceMember(ctx, doc.workspace_id, args.userId);
|
||||
|
||||
if (doc.user_id === args.userId) {
|
||||
return { permission: "edit" as const, disableDownload: false, disableCopy: false };
|
||||
}
|
||||
if (doc.access_scope === "public") {
|
||||
return { permission: "read" as const, disableDownload: false, disableCopy: false };
|
||||
}
|
||||
|
||||
const policy = (await resolveSharePolicy(ctx, doc, args.userId)) ?? (await resolveGroupSharePolicy(ctx, doc, args.userId));
|
||||
if (!policy) return null;
|
||||
|
||||
return {
|
||||
permission: policy.permission === "edit" ? ("edit" as const) : ("read" as const),
|
||||
disableDownload: policy.disableDownload,
|
||||
disableCopy: policy.disableCopy,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const getMetaForIngest = internalQuery({
|
||||
args: { userId: v.string(), id: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
@@ -379,6 +477,134 @@ export const listByWorkspace = query({
|
||||
},
|
||||
});
|
||||
|
||||
export const listSearchDataByWorkspace = query({
|
||||
args: { workspaceId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
// workspace 权限
|
||||
try {
|
||||
await requireWorkspaceMember(ctx, args.workspaceId, userId);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const docs = await ctx.db
|
||||
.query("documents")
|
||||
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
|
||||
.collect();
|
||||
|
||||
const alive = docs.filter((d) => d.deleted_at == null);
|
||||
|
||||
const shares = await ctx.db
|
||||
.query("document_shares")
|
||||
.withIndex("by_workspace_shared_with", (q: any) =>
|
||||
q.eq("workspace_id", args.workspaceId).eq("shared_with_user_id", userId),
|
||||
)
|
||||
.collect();
|
||||
|
||||
const directShares = new Map<
|
||||
string,
|
||||
{ permission: SharePermission; includeDescendants: boolean }
|
||||
>();
|
||||
for (const s of shares) {
|
||||
directShares.set(s.document_id, {
|
||||
permission: s.permission as SharePermission,
|
||||
includeDescendants: Boolean(s.include_descendants),
|
||||
});
|
||||
}
|
||||
|
||||
const groupMemberships = await ctx.db
|
||||
.query("group_members")
|
||||
.withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", args.workspaceId).eq("user_id", userId))
|
||||
.collect();
|
||||
const groupIds = new Set<string>(groupMemberships.map((m: any) => String(m.group_id)));
|
||||
|
||||
const directGroupShares = new Map<string, { includeDescendants: boolean }>();
|
||||
if (groupIds.size > 0) {
|
||||
for (const gid of groupIds) {
|
||||
const rows = await ctx.db
|
||||
.query("document_group_shares")
|
||||
.withIndex("by_workspace_group", (q: any) => q.eq("workspace_id", args.workspaceId).eq("group_id", gid))
|
||||
.collect();
|
||||
for (const r of rows) {
|
||||
const existing = directGroupShares.get(r.document_id);
|
||||
if (!existing) {
|
||||
directGroupShares.set(r.document_id, { includeDescendants: Boolean(r.include_descendants) });
|
||||
} else {
|
||||
existing.includeDescendants = existing.includeDescendants || Boolean(r.include_descendants);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const parentById = new Map<string, string | null>();
|
||||
for (const d of alive) {
|
||||
parentById.set(d.id, d.parent_id ?? null);
|
||||
}
|
||||
|
||||
const shareAccessCache = new Map<string, boolean>();
|
||||
const canAccessByShare = (docId: string): boolean => {
|
||||
const cached = shareAccessCache.get(docId);
|
||||
if (typeof cached === "boolean") return cached;
|
||||
if (directShares.has(docId)) {
|
||||
shareAccessCache.set(docId, true);
|
||||
return true;
|
||||
}
|
||||
let parentId = parentById.get(docId) ?? null;
|
||||
for (let depth = 0; depth < 60 && parentId; depth += 1) {
|
||||
const parentShare = directShares.get(parentId);
|
||||
if (parentShare && parentShare.includeDescendants) {
|
||||
shareAccessCache.set(docId, true);
|
||||
return true;
|
||||
}
|
||||
parentId = parentById.get(parentId) ?? null;
|
||||
}
|
||||
shareAccessCache.set(docId, false);
|
||||
return false;
|
||||
};
|
||||
|
||||
const groupAccessCache = new Map<string, boolean>();
|
||||
const canAccessByGroupShare = (docId: string): boolean => {
|
||||
const cached = groupAccessCache.get(docId);
|
||||
if (typeof cached === "boolean") return cached;
|
||||
if (directGroupShares.has(docId)) {
|
||||
groupAccessCache.set(docId, true);
|
||||
return true;
|
||||
}
|
||||
let parentId = parentById.get(docId) ?? null;
|
||||
for (let depth = 0; depth < 60 && parentId; depth += 1) {
|
||||
const parentShare = directGroupShares.get(parentId);
|
||||
if (parentShare && parentShare.includeDescendants) {
|
||||
groupAccessCache.set(docId, true);
|
||||
return true;
|
||||
}
|
||||
parentId = parentById.get(parentId) ?? null;
|
||||
}
|
||||
groupAccessCache.set(docId, false);
|
||||
return false;
|
||||
};
|
||||
|
||||
return alive
|
||||
.filter((d) => {
|
||||
if (d.user_id === userId) return true;
|
||||
if (d.access_scope === "public") return true;
|
||||
return canAccessByShare(d.id) || canAccessByGroupShare(d.id);
|
||||
})
|
||||
.map((d) => ({
|
||||
id: d.id,
|
||||
workspace_id: d.workspace_id,
|
||||
title: d.title ?? null,
|
||||
created_at: d.created_at ?? null,
|
||||
updated_at: d.updated_at ?? null,
|
||||
raw_text:
|
||||
typeof d.raw_text === "string" && d.raw_text.trim()
|
||||
? d.raw_text
|
||||
: extractTextFromDocumentContent(d.content ?? null),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
export const listTrashedByWorkspace = query({
|
||||
args: { workspaceId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
@@ -426,6 +652,7 @@ export const create = mutation({
|
||||
|
||||
const title = (args.title ?? "无标题") || "无标题";
|
||||
const content = typeof args.content === "undefined" ? [] : args.content;
|
||||
const rawText = extractTextFromDocumentContent(content);
|
||||
|
||||
await ctx.db.insert("documents", {
|
||||
id: args.id,
|
||||
@@ -434,6 +661,7 @@ export const create = mutation({
|
||||
parent_id: args.parentId,
|
||||
title,
|
||||
content,
|
||||
raw_text: rawText,
|
||||
access_scope: args.accessScope,
|
||||
sort_order: sortOrder,
|
||||
is_starred: false,
|
||||
@@ -489,7 +717,8 @@ export const updateContent = mutation({
|
||||
if (perm !== "edit") throw new Error("无权限");
|
||||
}
|
||||
const ts = nowIso();
|
||||
await ctx.db.patch(doc._id, { content: args.content, updated_at: ts });
|
||||
const rawText = extractTextFromDocumentContent(args.content);
|
||||
await ctx.db.patch(doc._id, { content: args.content, raw_text: rawText, updated_at: ts });
|
||||
|
||||
// 说明:在 Convex 模式下,把"自动入库/LightRAG 触发"迁到 Convex jobs/actions。
|
||||
// 采用 debounce,避免频繁保存时触发过多任务。
|
||||
@@ -641,6 +870,9 @@ export const softDelete = mutation({
|
||||
for (const item of subtree) {
|
||||
if (item.deleted_at != null) continue;
|
||||
await ctx.db.patch(item._id, { deleted_at: ts, deleted_by: userId, updated_at: ts });
|
||||
// 说明:为了避免被分享者仍看到已删除页面(点开 404),软删除时也同步移除共享关系。
|
||||
// 如需保留共享关系用于恢复后自动生效,可改为仅在 purge/emptyTrash 时清理。
|
||||
await purgeDocumentShareRelations(ctx, doc.workspace_id, item.id);
|
||||
moved += 1;
|
||||
}
|
||||
|
||||
@@ -710,6 +942,7 @@ export const purge = mutation({
|
||||
|
||||
// 删除顺序对当前数据模型无强制要求,这里简单逐个删除即可。
|
||||
for (const item of subtree) {
|
||||
await purgeDocumentShareRelations(ctx, doc.workspace_id, item.id);
|
||||
await ctx.db.delete(item._id);
|
||||
}
|
||||
|
||||
@@ -739,6 +972,7 @@ export const emptyTrashByWorkspace = mutation({
|
||||
|
||||
const toDelete = docs.filter((d) => d.user_id === userId && d.deleted_at != null);
|
||||
for (const d of toDelete) {
|
||||
await purgeDocumentShareRelations(ctx, args.workspaceId, d.id);
|
||||
await ctx.db.delete(d._id);
|
||||
}
|
||||
|
||||
@@ -857,6 +1091,7 @@ export const duplicate = mutation({
|
||||
const ts = nowIso();
|
||||
const baseTitle = (source.title ?? "无标题").trim() ? (source.title ?? "无标题").trim() : "无标题";
|
||||
const title = (args.title ?? `${baseTitle} 副本`) || `${baseTitle} 副本`;
|
||||
const rawText = extractTextFromDocumentContent(source.content ?? []);
|
||||
|
||||
await ctx.db.insert("documents", {
|
||||
id: args.newId,
|
||||
@@ -865,6 +1100,7 @@ export const duplicate = mutation({
|
||||
parent_id: source.parent_id,
|
||||
title,
|
||||
content: source.content ?? [],
|
||||
raw_text: rawText,
|
||||
access_scope: source.access_scope,
|
||||
sort_order: sortOrder,
|
||||
is_starred: false,
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
import { nowIso } from "./_utils/time";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
|
||||
async function requireUserId(ctx: any): Promise<string> {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (userId === null) throw new Error("未登录");
|
||||
return String(userId);
|
||||
}
|
||||
|
||||
async function requireWorkspaceMember(ctx: any, workspaceId: string, userId: string) {
|
||||
const member = await ctx.db
|
||||
.query("workspace_members")
|
||||
.withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", workspaceId).eq("user_id", userId))
|
||||
.first();
|
||||
if (!member) throw new Error("无权限");
|
||||
return member;
|
||||
}
|
||||
|
||||
async function requireGroup(ctx: any, groupId: string) {
|
||||
const group = await ctx.db
|
||||
.query("groups")
|
||||
.withIndex("by_group_id", (q: any) => q.eq("id", groupId))
|
||||
.first();
|
||||
if (!group) throw new Error("群组不存在");
|
||||
return group;
|
||||
}
|
||||
|
||||
async function requireGroupOwner(ctx: any, groupId: string, userId: string) {
|
||||
const membership = await ctx.db
|
||||
.query("group_members")
|
||||
.withIndex("by_group_user", (q: any) => q.eq("group_id", groupId).eq("user_id", userId))
|
||||
.first();
|
||||
if (!membership || membership.role !== "owner") throw new Error("无权限");
|
||||
return membership;
|
||||
}
|
||||
|
||||
function normalizeUsername(raw: string): string {
|
||||
const username = String(raw ?? "").trim();
|
||||
if (!username) throw new Error("用户名不能为空");
|
||||
if (username.length < 2 || username.length > 32) throw new Error("用户名长度需为 2-32 个字符");
|
||||
if (/\s/.test(username)) throw new Error("用户名不能包含空格");
|
||||
return username;
|
||||
}
|
||||
|
||||
async function lookupUserIdByUsername(ctx: any, rawUsername: string): Promise<string> {
|
||||
const username = normalizeUsername(rawUsername);
|
||||
const user = await ctx.db
|
||||
.query("users")
|
||||
.filter((q: any) => q.eq(q.field("name"), username))
|
||||
.first();
|
||||
if (!user) throw new Error("未找到该用户");
|
||||
return String(user._id);
|
||||
}
|
||||
|
||||
export const listMineByWorkspace = query({
|
||||
args: { workspaceId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
const invites = await ctx.db
|
||||
.query("group_invitations")
|
||||
.withIndex("by_workspace_invited_user", (q: any) =>
|
||||
q.eq("workspace_id", args.workspaceId).eq("invited_user_id", userId),
|
||||
)
|
||||
.collect();
|
||||
|
||||
// 最新的排前面
|
||||
invites.sort((a: any, b: any) => String(b.updated_at ?? "").localeCompare(String(a.updated_at ?? "")));
|
||||
|
||||
const result = [];
|
||||
for (const inv of invites) {
|
||||
const group = await ctx.db
|
||||
.query("groups")
|
||||
.withIndex("by_group_id", (q: any) => q.eq("id", inv.group_id))
|
||||
.first();
|
||||
const inviter = await ctx.db.get(inv.invited_by as Id<"users">);
|
||||
result.push({
|
||||
groupId: inv.group_id,
|
||||
groupName: group?.name ?? null,
|
||||
invitedByUserId: inv.invited_by,
|
||||
invitedByUsername: inviter?.name ?? null,
|
||||
createdAt: inv.created_at,
|
||||
updatedAt: inv.updated_at,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
export const listMine = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
const invites = await ctx.db
|
||||
.query("group_invitations")
|
||||
.withIndex("by_invited_user", (q: any) => q.eq("invited_user_id", userId))
|
||||
.collect();
|
||||
|
||||
invites.sort((a: any, b: any) => String(b.updated_at ?? "").localeCompare(String(a.updated_at ?? "")));
|
||||
|
||||
const result = [];
|
||||
for (const inv of invites) {
|
||||
const group = await ctx.db
|
||||
.query("groups")
|
||||
.withIndex("by_group_id", (q: any) => q.eq("id", inv.group_id))
|
||||
.first();
|
||||
const workspace = await ctx.db
|
||||
.query("workspaces")
|
||||
.withIndex("by_workspace_id", (q: any) => q.eq("id", inv.workspace_id))
|
||||
.first();
|
||||
const inviter = await ctx.db.get(inv.invited_by as Id<"users">);
|
||||
result.push({
|
||||
workspaceId: inv.workspace_id,
|
||||
workspaceName: workspace?.name ?? null,
|
||||
groupId: inv.group_id,
|
||||
groupName: group?.name ?? null,
|
||||
invitedByUserId: inv.invited_by,
|
||||
invitedByUsername: inviter?.name ?? null,
|
||||
createdAt: inv.created_at,
|
||||
updatedAt: inv.updated_at,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
export const inviteByUsername = mutation({
|
||||
args: { groupId: v.string(), username: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
const group = await requireGroup(ctx, args.groupId);
|
||||
|
||||
await requireWorkspaceMember(ctx, group.workspace_id, userId);
|
||||
await requireGroupOwner(ctx, args.groupId, userId);
|
||||
|
||||
const targetUserId = await lookupUserIdByUsername(ctx, args.username);
|
||||
if (targetUserId === userId) throw new Error("不能邀请自己");
|
||||
|
||||
const existingMember = await ctx.db
|
||||
.query("group_members")
|
||||
.withIndex("by_group_user", (q: any) => q.eq("group_id", args.groupId).eq("user_id", targetUserId))
|
||||
.first();
|
||||
if (existingMember) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const ts = nowIso();
|
||||
const existingInvite = await ctx.db
|
||||
.query("group_invitations")
|
||||
.withIndex("by_group_user", (q: any) => q.eq("group_id", args.groupId).eq("invited_user_id", targetUserId))
|
||||
.first();
|
||||
|
||||
if (existingInvite) {
|
||||
await ctx.db.patch(existingInvite._id, { invited_by: userId, updated_at: ts });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
await ctx.db.insert("group_invitations", {
|
||||
workspace_id: group.workspace_id,
|
||||
group_id: args.groupId,
|
||||
invited_user_id: targetUserId,
|
||||
invited_by: userId,
|
||||
created_at: ts,
|
||||
updated_at: ts,
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const accept = mutation({
|
||||
args: { groupId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
const group = await requireGroup(ctx, args.groupId);
|
||||
|
||||
await requireWorkspaceMember(ctx, group.workspace_id, userId).catch(async () => {
|
||||
// 说明:允许被邀请者尚未加入 workspace,接受邀请时自动加入。
|
||||
await ctx.db.insert("workspace_members", {
|
||||
workspace_id: group.workspace_id,
|
||||
user_id: userId,
|
||||
role: "member",
|
||||
is_default: false,
|
||||
created_at: nowIso(),
|
||||
});
|
||||
});
|
||||
|
||||
const invite = await ctx.db
|
||||
.query("group_invitations")
|
||||
.withIndex("by_group_user", (q: any) => q.eq("group_id", args.groupId).eq("invited_user_id", userId))
|
||||
.first();
|
||||
if (!invite) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const existingMember = await ctx.db
|
||||
.query("group_members")
|
||||
.withIndex("by_group_user", (q: any) => q.eq("group_id", args.groupId).eq("user_id", userId))
|
||||
.first();
|
||||
|
||||
const ts = nowIso();
|
||||
if (!existingMember) {
|
||||
await ctx.db.insert("group_members", {
|
||||
workspace_id: group.workspace_id,
|
||||
group_id: args.groupId,
|
||||
user_id: userId,
|
||||
role: "member",
|
||||
created_at: ts,
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.delete(invite._id);
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const decline = mutation({
|
||||
args: { groupId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
const group = await requireGroup(ctx, args.groupId);
|
||||
await requireWorkspaceMember(ctx, group.workspace_id, userId).catch(() => {
|
||||
// 即使没进 workspace,也可以拒绝邀请(删除邀请记录)。
|
||||
});
|
||||
|
||||
const invite = await ctx.db
|
||||
.query("group_invitations")
|
||||
.withIndex("by_group_user", (q: any) => q.eq("group_id", args.groupId).eq("invited_user_id", userId))
|
||||
.first();
|
||||
if (invite) {
|
||||
await ctx.db.delete(invite._id);
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
@@ -90,46 +90,7 @@ export const listByGroup = query({
|
||||
export const inviteByUsername = mutation({
|
||||
args: { groupId: v.string(), username: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
const group = await requireGroup(ctx, args.groupId);
|
||||
await requireWorkspaceMember(ctx, group.workspace_id, userId);
|
||||
await requireGroupOwner(ctx, args.groupId, userId);
|
||||
|
||||
const targetUserId = await lookupUserIdByUsername(ctx, args.username);
|
||||
if (targetUserId === userId) throw new Error("不能邀请自己");
|
||||
|
||||
// 确保对方为 workspace member
|
||||
const existingWorkspaceMember = await ctx.db
|
||||
.query("workspace_members")
|
||||
.withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", group.workspace_id).eq("user_id", targetUserId))
|
||||
.first();
|
||||
if (!existingWorkspaceMember) {
|
||||
await ctx.db.insert("workspace_members", {
|
||||
workspace_id: group.workspace_id,
|
||||
user_id: targetUserId,
|
||||
role: "member",
|
||||
is_default: false,
|
||||
created_at: nowIso(),
|
||||
});
|
||||
}
|
||||
|
||||
const existing = await ctx.db
|
||||
.query("group_members")
|
||||
.withIndex("by_group_user", (q: any) => q.eq("group_id", args.groupId).eq("user_id", targetUserId))
|
||||
.first();
|
||||
if (existing) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
await ctx.db.insert("group_members", {
|
||||
workspace_id: group.workspace_id,
|
||||
group_id: args.groupId,
|
||||
user_id: targetUserId,
|
||||
role: "member",
|
||||
created_at: nowIso(),
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
throw new Error("接口已废弃:请改用 groupInvitations.inviteByUsername(支持对方接受/拒绝)。");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -152,4 +113,3 @@ export const removeMember = mutation({
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { mutation, query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
import { nowIso } from "./_utils/time";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
|
||||
async function requireUserId(ctx: any): Promise<string> {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
@@ -48,6 +49,49 @@ export const listByWorkspace = query({
|
||||
},
|
||||
});
|
||||
|
||||
export const listMineByWorkspace = query({
|
||||
args: { workspaceId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
await requireWorkspaceMember(ctx, args.workspaceId, userId);
|
||||
|
||||
const memberships = await ctx.db
|
||||
.query("group_members")
|
||||
.withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", args.workspaceId).eq("user_id", userId))
|
||||
.collect();
|
||||
|
||||
const groupsById = new Map<string, any>();
|
||||
for (const m of memberships) {
|
||||
const group = await ctx.db
|
||||
.query("groups")
|
||||
.withIndex("by_group_id", (q: any) => q.eq("id", m.group_id))
|
||||
.first();
|
||||
if (group) {
|
||||
groupsById.set(String(group.id), {
|
||||
id: group.id,
|
||||
workspace_id: group.workspace_id,
|
||||
name: group.name,
|
||||
created_by: group.created_by,
|
||||
created_at: group.created_at,
|
||||
updated_at: group.updated_at,
|
||||
my_role: m.role,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const rows = Array.from(groupsById.values());
|
||||
rows.sort((a: any, b: any) => String(a.created_at ?? "").localeCompare(String(b.created_at ?? "")));
|
||||
|
||||
// 额外:带上当前用户用户名,前端可用
|
||||
const me = await ctx.db.get(userId as Id<"users">);
|
||||
|
||||
return {
|
||||
me: { userId, username: me?.name ?? null },
|
||||
groups: rows,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const create = mutation({
|
||||
args: {
|
||||
id: v.string(),
|
||||
@@ -159,4 +203,3 @@ export const remove = mutation({
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { api, internal } from "./_generated/api";
|
||||
import { lightragIngestText } from "./_utils/lightrag";
|
||||
import { extractTextFromDocumentContent, extractTextFromMindmapData } from "./_utils/text";
|
||||
import { enqueueIngestDocumentJob, enqueueIngestMediaAssetJob, enqueueIngestMindmapJob } from "./_utils/ingestJobs";
|
||||
import { extractTextFromAttachment } from "./_utils/attachmentExtract";
|
||||
|
||||
export const get = query({
|
||||
args: { userId: v.string(), id: v.string() },
|
||||
@@ -199,6 +200,105 @@ export const run = internalAction({
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.type === "extract.media_asset_text") {
|
||||
const assetId = String(job.payload?.assetId ?? "").trim();
|
||||
if (!assetId) throw new Error("缺少 assetId");
|
||||
|
||||
const asset = await ctx.runQuery(api.mediaAssets.getById, { userId: job.user_id, id: assetId });
|
||||
if (!asset) throw new Error("资源不存在或无权限");
|
||||
|
||||
// 说明:只处理 file 类型的常见附件(pdf/docx/pptx/xlsx)。
|
||||
if (String(asset.asset_type ?? "") !== "file") {
|
||||
await ctx.runMutation(api.mediaAssets.patchById, {
|
||||
userId: job.user_id,
|
||||
id: assetId,
|
||||
patch: {
|
||||
ocr_status: "skipped",
|
||||
ocr_payload: { reason: "non_file_asset" },
|
||||
ocr_strategy: "attachment_extract",
|
||||
},
|
||||
});
|
||||
await ctx.runMutation(internal.jobs.finishSuccess, {
|
||||
id: args.id,
|
||||
result: { ok: true, kind: "extract_media_asset_text", assetId, skipped: true, reason: "non_file_asset" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const fileSize = typeof asset.file_size === "number" ? asset.file_size : null;
|
||||
const maxBytes = 25 * 1024 * 1024;
|
||||
if (typeof fileSize === "number" && fileSize > maxBytes) {
|
||||
await ctx.runMutation(api.mediaAssets.patchById, {
|
||||
userId: job.user_id,
|
||||
id: assetId,
|
||||
patch: {
|
||||
ocr_status: "failed",
|
||||
ocr_payload: { error: `文件过大(${fileSize} bytes),暂不解析`, maxBytes },
|
||||
ocr_strategy: "attachment_extract",
|
||||
},
|
||||
});
|
||||
await ctx.runMutation(internal.jobs.finishFailure, {
|
||||
id: args.id,
|
||||
error: "文件过大,暂不解析",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 说明:Convex Files 的 getUrl 可能过期,先刷新并获取当前可用链接。
|
||||
const refreshed = await ctx.runMutation(api.mediaAssets.refreshUrl, { userId: job.user_id, id: assetId });
|
||||
const url = String((refreshed as any)?.signedUrl ?? asset.file_url ?? "").trim();
|
||||
if (!url) throw new Error("缺少可用文件链接");
|
||||
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(`下载附件失败:${res.status}`);
|
||||
}
|
||||
const bytes = await res.arrayBuffer();
|
||||
|
||||
const extracted = await extractTextFromAttachment({
|
||||
mimeType: (asset as any).mime_type ?? null,
|
||||
fileName: (asset as any).file_name ?? null,
|
||||
bytes,
|
||||
});
|
||||
|
||||
if (!extracted.ok) {
|
||||
await ctx.runMutation(api.mediaAssets.patchById, {
|
||||
userId: job.user_id,
|
||||
id: assetId,
|
||||
patch: {
|
||||
ocr_status: "failed",
|
||||
ocr_payload: { error: extracted.reason, meta: extracted.meta ?? null },
|
||||
ocr_strategy: extracted.strategy,
|
||||
},
|
||||
});
|
||||
await ctx.runMutation(internal.jobs.finishFailure, { id: args.id, error: extracted.reason });
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.runMutation(api.mediaAssets.patchById, {
|
||||
userId: job.user_id,
|
||||
id: assetId,
|
||||
patch: {
|
||||
ocr_text: extracted.text,
|
||||
ocr_status: "completed",
|
||||
ocr_payload: extracted.meta ?? null,
|
||||
ocr_strategy: extracted.strategy,
|
||||
},
|
||||
});
|
||||
|
||||
await ctx.runMutation(internal.jobs.finishSuccess, {
|
||||
id: args.id,
|
||||
result: {
|
||||
ok: true,
|
||||
kind: "extract_media_asset_text",
|
||||
assetId,
|
||||
strategy: extracted.strategy,
|
||||
chars: extracted.text.length,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`未知任务类型:${job.type}`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { nowIso } from "./_utils/time";
|
||||
import { enqueueIngestMediaAssetJob } from "./_utils/ingestJobs";
|
||||
import { enqueueExtractMediaAssetTextJob, enqueueIngestMediaAssetJob } from "./_utils/ingestJobs";
|
||||
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
||||
|
||||
async function assertWorkspaceMember(ctx: QueryCtx | MutationCtx, userId: string, workspaceId: string) {
|
||||
@@ -15,6 +15,26 @@ async function assertWorkspaceMember(ctx: QueryCtx | MutationCtx, userId: string
|
||||
return membership;
|
||||
}
|
||||
|
||||
function shouldExtractAttachmentText(args: {
|
||||
assetType?: string | null;
|
||||
mimeType?: string | null;
|
||||
fileName?: string | null;
|
||||
}): boolean {
|
||||
if (String(args.assetType ?? "") !== "file") return false;
|
||||
const mime = String(args.mimeType ?? "").toLowerCase().trim();
|
||||
if (mime === "application/pdf") return true;
|
||||
if (mime === "application/vnd.openxmlformats-officedocument.wordprocessingml.document") return true;
|
||||
if (mime === "application/vnd.openxmlformats-officedocument.presentationml.presentation") return true;
|
||||
if (mime === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") return true;
|
||||
|
||||
const name = String(args.fileName ?? "").toLowerCase().trim();
|
||||
if (name.endsWith(".pdf")) return true;
|
||||
if (name.endsWith(".docx")) return true;
|
||||
if (name.endsWith(".pptx")) return true;
|
||||
if (name.endsWith(".xlsx")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export const getById = query({
|
||||
args: { userId: v.string(), id: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
@@ -76,6 +96,44 @@ export const listByWorkspace = query({
|
||||
},
|
||||
});
|
||||
|
||||
export const listSearchDataByWorkspace = query({
|
||||
args: {
|
||||
userId: v.string(),
|
||||
workspaceId: 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(5000, Math.floor(args.limit ?? 5000)));
|
||||
|
||||
const rows = await ctx.db
|
||||
.query("media_assets")
|
||||
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
|
||||
.order("desc")
|
||||
.take(limit * 2);
|
||||
|
||||
return rows
|
||||
.filter((r) => (includeDeleted ? true : !r.deleted_at))
|
||||
.filter((r) => !r.purged_at)
|
||||
.slice(0, limit)
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
workspace_id: r.workspace_id,
|
||||
document_id: r.document_id,
|
||||
asset_type: r.asset_type,
|
||||
file_name: r.file_name ?? null,
|
||||
mime_type: r.mime_type ?? null,
|
||||
ocr_text: r.ocr_text ?? null,
|
||||
ocr_status: r.ocr_status ?? null,
|
||||
created_at: r.created_at ?? null,
|
||||
updated_at: r.updated_at ?? null,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
export const listByDocument = query({
|
||||
args: {
|
||||
userId: v.string(),
|
||||
@@ -162,7 +220,13 @@ export const create = mutation({
|
||||
...args.asset,
|
||||
storage_id: args.asset.storage_id ?? null,
|
||||
ocr_text: null,
|
||||
ocr_status: null,
|
||||
ocr_status: shouldExtractAttachmentText({
|
||||
assetType: args.asset.asset_type,
|
||||
mimeType: args.asset.mime_type,
|
||||
fileName: args.asset.file_name,
|
||||
})
|
||||
? "queued"
|
||||
: null,
|
||||
deleted_at: null,
|
||||
deleted_by: null,
|
||||
purged_at: null,
|
||||
@@ -171,6 +235,16 @@ export const create = mutation({
|
||||
updated_at: ts,
|
||||
});
|
||||
|
||||
if (
|
||||
shouldExtractAttachmentText({
|
||||
assetType: args.asset.asset_type,
|
||||
mimeType: args.asset.mime_type,
|
||||
fileName: args.asset.file_name,
|
||||
})
|
||||
) {
|
||||
await enqueueExtractMediaAssetTextJob(ctx, { userId: args.userId, assetId: args.asset.id, debounceMs: 800 });
|
||||
}
|
||||
|
||||
return args.asset;
|
||||
},
|
||||
});
|
||||
@@ -222,7 +296,13 @@ export const createWithStorage = mutation({
|
||||
mime_type: args.asset.mime_type,
|
||||
|
||||
ocr_text: null,
|
||||
ocr_status: null,
|
||||
ocr_status: shouldExtractAttachmentText({
|
||||
assetType: args.asset.asset_type,
|
||||
mimeType: args.asset.mime_type,
|
||||
fileName: args.asset.file_name,
|
||||
})
|
||||
? "queued"
|
||||
: null,
|
||||
deleted_at: null,
|
||||
deleted_by: null,
|
||||
purged_at: null,
|
||||
@@ -232,6 +312,16 @@ export const createWithStorage = mutation({
|
||||
};
|
||||
|
||||
await ctx.db.insert("media_assets", row);
|
||||
|
||||
if (
|
||||
shouldExtractAttachmentText({
|
||||
assetType: args.asset.asset_type,
|
||||
mimeType: args.asset.mime_type,
|
||||
fileName: args.asset.file_name,
|
||||
})
|
||||
) {
|
||||
await enqueueExtractMediaAssetTextJob(ctx, { userId: args.userId, assetId: args.asset.id, debounceMs: 800 });
|
||||
}
|
||||
return row;
|
||||
},
|
||||
});
|
||||
@@ -312,13 +402,57 @@ export const replaceStorageFromUpload = mutation({
|
||||
thumbnail_url: url,
|
||||
bucket: null,
|
||||
storage_path: null,
|
||||
ocr_text: null,
|
||||
ocr_status: shouldExtractAttachmentText({
|
||||
assetType: row.asset_type,
|
||||
mimeType: row.mime_type ?? null,
|
||||
fileName: row.file_name ?? null,
|
||||
})
|
||||
? "queued"
|
||||
: row.ocr_status ?? null,
|
||||
updated_at: nowIso(),
|
||||
});
|
||||
|
||||
if (
|
||||
shouldExtractAttachmentText({
|
||||
assetType: row.asset_type,
|
||||
mimeType: row.mime_type ?? null,
|
||||
fileName: row.file_name ?? null,
|
||||
})
|
||||
) {
|
||||
await enqueueExtractMediaAssetTextJob(ctx, { userId: args.userId, assetId: args.id, debounceMs: 800 });
|
||||
}
|
||||
|
||||
return { ok: true, fileUrl: url };
|
||||
},
|
||||
});
|
||||
|
||||
export const enqueueExtractText = 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);
|
||||
|
||||
if (
|
||||
!shouldExtractAttachmentText({
|
||||
assetType: row.asset_type,
|
||||
mimeType: row.mime_type ?? null,
|
||||
fileName: row.file_name ?? null,
|
||||
})
|
||||
) {
|
||||
return { ok: false, reason: "unsupported" as const };
|
||||
}
|
||||
|
||||
await ctx.db.patch(row._id, { ocr_status: "queued", updated_at: nowIso() });
|
||||
await enqueueExtractMediaAssetTextJob(ctx, { userId: args.userId, assetId: args.id, debounceMs: 0 });
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const purgeById = mutation({
|
||||
args: { userId: v.string(), id: v.string(), expiredDeletedAt: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
|
||||
@@ -244,10 +244,14 @@ export default defineSchema({
|
||||
shared_with_user_id: v.string(),
|
||||
permission: v.union(v.literal("read"), v.literal("edit")),
|
||||
include_descendants: v.boolean(),
|
||||
disable_download: v.optional(v.boolean()),
|
||||
disable_copy: v.optional(v.boolean()),
|
||||
created_by: v.string(),
|
||||
created_at: v.string(),
|
||||
updated_at: v.string(),
|
||||
})
|
||||
.index("by_shared_with_user", ["shared_with_user_id"])
|
||||
.index("by_created_by", ["created_by"])
|
||||
.index("by_document", ["document_id"])
|
||||
.index("by_doc_user", ["document_id", "shared_with_user_id"])
|
||||
.index("by_workspace_shared_with", ["workspace_id", "shared_with_user_id"])
|
||||
@@ -275,11 +279,27 @@ export default defineSchema({
|
||||
.index("by_group_user", ["group_id", "user_id"])
|
||||
.index("by_workspace_user", ["workspace_id", "user_id"]),
|
||||
|
||||
group_invitations: defineTable({
|
||||
workspace_id: v.string(),
|
||||
group_id: v.string(),
|
||||
invited_user_id: v.string(),
|
||||
invited_by: v.string(),
|
||||
created_at: v.string(),
|
||||
updated_at: v.string(),
|
||||
})
|
||||
.index("by_invited_user", ["invited_user_id"])
|
||||
.index("by_group_user", ["group_id", "invited_user_id"])
|
||||
.index("by_workspace_invited_user", ["workspace_id", "invited_user_id"])
|
||||
.index("by_workspace_group", ["workspace_id", "group_id"])
|
||||
.index("by_workspace_invited_by", ["workspace_id", "invited_by"]),
|
||||
|
||||
document_group_shares: defineTable({
|
||||
workspace_id: v.string(),
|
||||
group_id: v.string(),
|
||||
document_id: v.string(),
|
||||
include_descendants: v.boolean(),
|
||||
disable_download: v.optional(v.boolean()),
|
||||
disable_copy: v.optional(v.boolean()),
|
||||
created_by: v.string(),
|
||||
created_at: v.string(),
|
||||
updated_at: v.string(),
|
||||
|
||||
@@ -2,6 +2,18 @@ import { mutation, query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { nowIso } from "./_utils/time";
|
||||
import { generateId } from "./_utils/id";
|
||||
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
||||
|
||||
async function assertWorkspaceMember(ctx: QueryCtx | MutationCtx, userId: string, workspaceId: string) {
|
||||
const membership = await ctx.db
|
||||
.query("workspace_members")
|
||||
.withIndex("by_workspace_user", (q) => q.eq("workspace_id", workspaceId).eq("user_id", userId))
|
||||
.first();
|
||||
if (!membership) {
|
||||
throw new Error("无权访问该工作空间");
|
||||
}
|
||||
return membership;
|
||||
}
|
||||
|
||||
// Query: 获取单个表格
|
||||
export const get = query({
|
||||
@@ -102,6 +114,40 @@ export const listByDocument = query({
|
||||
},
|
||||
});
|
||||
|
||||
export const listByWorkspaceForSearch = query({
|
||||
args: {
|
||||
userId: v.string(),
|
||||
workspaceId: v.string(),
|
||||
includeArchived: v.optional(v.boolean()),
|
||||
limit: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
|
||||
|
||||
const includeArchived = Boolean(args.includeArchived);
|
||||
const limit = Math.max(1, Math.min(3000, Math.floor(args.limit ?? 3000)));
|
||||
|
||||
const rows = await ctx.db
|
||||
.query("document_tables")
|
||||
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
|
||||
.order("desc")
|
||||
.take(limit * 2);
|
||||
|
||||
return rows
|
||||
.filter((t) => (includeArchived ? true : !t.is_archived))
|
||||
.slice(0, limit)
|
||||
.map((t) => ({
|
||||
id: t.id,
|
||||
workspace_id: t.workspace_id,
|
||||
document_id: t.document_id,
|
||||
title: t.title,
|
||||
is_archived: t.is_archived,
|
||||
created_at: t.created_at,
|
||||
updated_at: t.updated_at,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
// Mutation: 创建表格
|
||||
export const create = mutation({
|
||||
args: {
|
||||
@@ -287,3 +333,36 @@ export const getRows = query({
|
||||
.map((r) => r.row_data);
|
||||
},
|
||||
});
|
||||
|
||||
export const listRowsByWorkspaceForSearch = query({
|
||||
args: {
|
||||
userId: v.string(),
|
||||
workspaceId: v.string(),
|
||||
limit: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
|
||||
|
||||
const limit = Math.max(1, Math.min(8000, Math.floor(args.limit ?? 8000)));
|
||||
|
||||
const rows = await ctx.db
|
||||
.query("document_table_rows")
|
||||
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
|
||||
.order("desc")
|
||||
.take(limit * 2);
|
||||
|
||||
return rows
|
||||
.filter((r) => !r.is_deleted)
|
||||
.slice(0, limit)
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
workspace_id: r.workspace_id,
|
||||
document_id: r.document_id,
|
||||
table_id: r.table_id,
|
||||
row_index: r.row_index,
|
||||
row_hash: r.row_hash ?? null,
|
||||
created_at: r.created_at ?? null,
|
||||
updated_at: r.updated_at ?? null,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user