From 530fcdd2b78ef02de22bb8c9c5f5aacfea0f3b33 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Sat, 11 Jul 2026 23:22:07 +0800 Subject: [PATCH] feat: migrate legacy projects through binding search --- hub/deploy/build_legacy_project_manifest.mjs | 56 +++ hub/package-lock.json | 4 +- hub/package.json | 2 +- .../deployment/legacy-project-import-cli.ts | 48 +++ hub/src/deployment/legacyProjectImport.ts | 369 ++++++++++++++++++ hub/src/feishu/projectOnboardingCard.ts | 10 +- hub/src/feishu/trigger.ts | 69 ++-- hub/src/projectOnboarding.ts | 6 +- .../integration/legacy-project-import.test.ts | 185 +++++++++ hub/test/integration/trigger.test.ts | 94 +++++ hub/test/unit/legacy-project-manifest.test.ts | 61 +++ 11 files changed, 873 insertions(+), 31 deletions(-) create mode 100644 hub/deploy/build_legacy_project_manifest.mjs create mode 100644 hub/src/deployment/legacy-project-import-cli.ts create mode 100644 hub/src/deployment/legacyProjectImport.ts create mode 100644 hub/test/integration/legacy-project-import.test.ts create mode 100644 hub/test/unit/legacy-project-manifest.test.ts diff --git a/hub/deploy/build_legacy_project_manifest.mjs b/hub/deploy/build_legacy_project_manifest.mjs new file mode 100644 index 0000000..8e75e52 --- /dev/null +++ b/hub/deploy/build_legacy_project_manifest.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node + +import { readdir, readFile, realpath } from "node:fs/promises"; +import { dirname, relative, resolve, sep } from "node:path"; + +const rootArgument = process.argv[2]; +if (!rootArgument) throw new Error("usage: build_legacy_project_manifest.mjs "); +const root = await realpath(rootArgument); +const projectFiles = await findProjectFiles(root); +const seenIds = new Set(); +const manifest = []; +for (const projectFile of projectFiles) { + const metadata = JSON.parse(await readFile(projectFile, "utf8")); + if (typeof metadata.id !== "string" || metadata.id.trim() === "") { + throw new Error(`project metadata has no id: ${projectFile}`); + } + if (seenIds.has(metadata.id)) throw new Error(`duplicate project id: ${metadata.id}`); + seenIds.add(metadata.id); + if (typeof metadata.name !== "string" || metadata.name.trim() === "") { + throw new Error(`project metadata has no name: ${projectFile}`); + } + const projectRoot = dirname(projectFile); + const sourceRelativePath = relative(root, projectRoot).split(sep).join("/"); + const physicalFolderPath = dirname(sourceRelativePath) === "." + ? [] + : dirname(sourceRelativePath).split("/"); + if (metadata.folderPath !== undefined && ( + !Array.isArray(metadata.folderPath) + || metadata.folderPath.some((part) => typeof part !== "string") + || JSON.stringify(metadata.folderPath) !== JSON.stringify(physicalFolderPath) + )) { + process.stderr.write(`[legacy-manifest] stale metadata folderPath; using physical path: ${projectFile}\n`); + } + manifest.push({ + legacyId: metadata.id, + name: metadata.name, + folderPath: physicalFolderPath, + sourceRelativePath, + }); +} +manifest.sort((left, right) => left.sourceRelativePath.localeCompare(right.sourceRelativePath, "zh-CN")); +process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`); + +async function findProjectFiles(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + const projectMetadata = entries.find((entry) => entry.isFile() && entry.name === "project.json"); + if (projectMetadata !== undefined) return [resolve(directory, projectMetadata.name)]; + const found = []; + for (const entry of entries) { + if (entry.name === ".trash") continue; + const path = resolve(directory, entry.name); + if (entry.isSymbolicLink()) throw new Error(`legacy manifest rejects symbolic link: ${path}`); + if (entry.isDirectory()) found.push(...await findProjectFiles(path)); + } + return found; +} diff --git a/hub/package-lock.json b/hub/package-lock.json index 0b0848c..a4fa16d 100644 --- a/hub/package-lock.json +++ b/hub/package-lock.json @@ -1,12 +1,12 @@ { "name": "@paradigm/hub", - "version": "0.0.20", + "version": "0.0.21", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@paradigm/hub", - "version": "0.0.20", + "version": "0.0.21", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.202", "@fastify/cookie": "^11.0.2", diff --git a/hub/package.json b/hub/package.json index f2f6394..5bc329c 100644 --- a/hub/package.json +++ b/hub/package.json @@ -1,6 +1,6 @@ { "name": "@paradigm/hub", - "version": "0.0.20", + "version": "0.0.21", "private": true, "type": "module", "engines": { diff --git a/hub/src/deployment/legacy-project-import-cli.ts b/hub/src/deployment/legacy-project-import-cli.ts new file mode 100644 index 0000000..965912f --- /dev/null +++ b/hub/src/deployment/legacy-project-import-cli.ts @@ -0,0 +1,48 @@ +import { readFile } from "node:fs/promises"; +import { prisma } from "../db.js"; +import { importLegacyProjects, type LegacyProjectManifestEntry } from "./legacyProjectImport.js"; +import { readSiloOrganizationId } from "./silo.js"; + +async function main(argv: readonly string[]): Promise { + const options = parseOptions(argv); + const organizationId = readSiloOrganizationId(); + const manifest = JSON.parse(await readFile(required(options, "manifest"), "utf8")) as unknown; + if (!Array.isArray(manifest)) throw new Error("legacy import manifest must be a JSON array"); + const state = await importLegacyProjects({ + prisma, + organizationId, + actorFeishuOpenId: required(options, "actor-open-id"), + workspaceRoot: required(options, "workspace-root"), + sourceRoot: required(options, "source-root"), + stateFile: required(options, "state-file"), + projects: manifest as LegacyProjectManifestEntry[], + onProgress: (message) => console.error(`[legacy-import] ${message}`), + }); + console.log(JSON.stringify({ imported: Object.keys(state.projects).length })); +} + +function parseOptions(args: readonly string[]): Map { + const options = new Map(); + for (let index = 0; index < args.length; index += 2) { + const flag = args[index]; + const value = args[index + 1]; + if (flag === undefined || !flag.startsWith("--") || value === undefined) { + throw new Error(`expected --name value, got: ${args.slice(index).join(" ")}`); + } + options.set(flag.slice(2), value); + } + return options; +} + +function required(options: ReadonlyMap, name: string): string { + const value = options.get(name)?.trim(); + if (!value) throw new Error(`--${name} is required`); + return value; +} + +main(process.argv.slice(2)) + .catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }) + .finally(async () => prisma.$disconnect()); diff --git a/hub/src/deployment/legacyProjectImport.ts b/hub/src/deployment/legacyProjectImport.ts new file mode 100644 index 0000000..f452666 --- /dev/null +++ b/hub/src/deployment/legacyProjectImport.ts @@ -0,0 +1,369 @@ +import { createHash } from "node:crypto"; +import { cp, lstat, mkdir, readFile, readdir, realpath, rename, rm, writeFile } from "node:fs/promises"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import type { PrismaClient } from "@prisma/client"; +import { createFolder, createProjectFromOrgAdmin } from "../projectOnboarding.js"; + +export interface LegacyProjectManifestEntry { + readonly legacyId: string; + readonly name: string; + readonly folderPath: readonly string[]; + readonly sourceRelativePath: string; +} + +export interface LegacyProjectImportState { + readonly version: 1; + readonly projects: Readonly>; +} + +export async function importLegacyProjects(input: { + readonly prisma: PrismaClient; + readonly organizationId: string; + readonly actorFeishuOpenId: string; + readonly workspaceRoot: string; + readonly sourceRoot: string; + readonly stateFile: string; + readonly projects: readonly LegacyProjectManifestEntry[]; + readonly onProgress?: (message: string) => void; +}): Promise { + const sourceRoot = await realpath(input.sourceRoot); + const state = await readState(input.stateFile); + const projects = { ...state.projects }; + const seen = new Set(); + for (const entry of input.projects) { + validateEntry(entry); + if (seen.has(entry.legacyId)) throw new Error(`duplicate legacy project id: ${entry.legacyId}`); + seen.add(entry.legacyId); + const sourceDir = await confinedSourceDir(sourceRoot, entry.sourceRelativePath); + const projectId = importedProjectId(input.organizationId, entry.legacyId); + const existing = await input.prisma.project.findUnique({ where: { id: projectId } }); + const recorded = projects[entry.legacyId]; + if (recorded !== undefined && (recorded.projectId !== projectId || recorded.sourceRelativePath !== entry.sourceRelativePath)) { + throw new Error(`legacy import state identity conflict: ${entry.legacyId}`); + } + if (recorded?.status === "COMPLETED" && existing === null) { + throw new Error(`legacy import state references missing project: ${entry.legacyId} -> ${recorded.projectId}`); + } + if (existing !== null) { + if (existing.organizationId !== input.organizationId || (recorded !== undefined && recorded.projectId !== existing.id)) { + throw new Error(`legacy import identity conflict: ${entry.legacyId} -> ${existing.id}`); + } + if (await hasCompletionMarker(existing.workspaceDir, entry)) { + await ensureImportAudit(input.prisma, existing.id, entry); + projects[entry.legacyId] = { + status: "COMPLETED", + projectId: existing.id, + workspaceDir: existing.workspaceDir, + importedAt: recorded?.importedAt || new Date().toISOString(), + sourceRelativePath: entry.sourceRelativePath, + }; + await writeState(input.stateFile, { version: 1, projects }); + input.onProgress?.(`skip ${entry.legacyId}: recovered completed import`); + continue; + } + if (recorded?.status !== "PENDING") { + throw new Error(`refusing to remove legacy project without a matching pending record: ${entry.legacyId}`); + } + input.onProgress?.(`recover ${entry.legacyId}: remove incomplete target`); + await removeIncompleteTarget(input.prisma, existing.id, existing.workspaceDir, input.workspaceRoot); + } + projects[entry.legacyId] = { + status: "PENDING", + projectId, + workspaceDir: "", + importedAt: "", + sourceRelativePath: entry.sourceRelativePath, + }; + await writeState(input.stateFile, { version: 1, projects }); + const folderId = await ensureFolderPath(input.prisma, input.organizationId, ["旧教学资产", ...entry.folderPath]); + input.onProgress?.(`import ${entry.legacyId}: ${entry.name}`); + const created = await createProjectFromOrgAdmin(input.prisma, { + organizationId: input.organizationId, + actorFeishuOpenId: input.actorFeishuOpenId, + name: entry.name, + workspaceRoot: input.workspaceRoot, + folderId, + projectId, + }); + try { + await copyLegacyProject(sourceDir, created.workspaceDir, entry); + } catch (error) { + try { + await removeIncompleteTarget(input.prisma, created.projectId, created.workspaceDir, input.workspaceRoot); + delete projects[entry.legacyId]; + await writeState(input.stateFile, { version: 1, projects }); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + `legacy project import and target cleanup failed: ${entry.legacyId}`, + ); + } + throw new Error(`legacy project import failed: ${entry.legacyId}: ${errorMessage(error)}`, { cause: error }); + } + await ensureImportAudit(input.prisma, created.projectId, entry); + projects[entry.legacyId] = { + status: "COMPLETED", + projectId: created.projectId, + workspaceDir: created.workspaceDir, + importedAt: new Date().toISOString(), + sourceRelativePath: entry.sourceRelativePath, + }; + await writeState(input.stateFile, { version: 1, projects }); + } + return { version: 1, projects }; +} + +async function ensureFolderPath( + prisma: PrismaClient, + organizationId: string, + parts: readonly string[], +): Promise { + let parentId: string | undefined; + for (const name of parts) { + const existing = await prisma.folder.findFirst({ + where: { organizationId, parentId: parentId ?? null, name, archivedAt: null }, + select: { id: true }, + }); + if (existing !== null) { + parentId = existing.id; + continue; + } + const created = await createFolder(prisma, { + organizationId, + name, + ...(parentId !== undefined ? { parentId } : {}), + }); + parentId = created.id; + } + if (parentId === undefined) throw new Error("legacy import folder path is empty"); + return parentId; +} + +async function copyLegacyProject( + sourceDir: string, + workspaceDir: string, + entry: LegacyProjectManifestEntry, +): Promise { + const sourceWorkspace = join(sourceDir, "workspace"); + await assertNoSymlinks(sourceWorkspace); + const names = await readdir(sourceWorkspace); + for (const name of names) { + if (name === ".claude" || name === ".cph") continue; + await cp(join(sourceWorkspace, name), join(workspaceDir, name), { + recursive: true, + force: false, + errorOnExist: true, + preserveTimestamps: true, + filter: (source) => { + const parts = relative(sourceWorkspace, source).split(sep); + return !parts.includes(".claude") && !parts.includes(".cph"); + }, + }); + } + const legacyDir = join(workspaceDir, ".legacy-source"); + await mkdir(legacyDir, { mode: 0o750 }); + await cp(join(sourceDir, "project.json"), join(legacyDir, "project.json"), { + force: false, + errorOnExist: true, + preserveTimestamps: true, + }); + const rawDir = join(sourceDir, "_raw"); + await assertNoSymlinks(rawDir).catch((error: unknown) => { + if (isMissing(error)) return; + throw error; + }); + await cp(rawDir, join(legacyDir, "raw"), { + recursive: true, + force: false, + errorOnExist: true, + preserveTimestamps: true, + }).catch((error: unknown) => { + if (isMissing(error)) return; + throw error; + }); + await writeFile(join(legacyDir, "migration.json"), `${JSON.stringify({ + source: "teaching-material-host-service", + legacyProjectId: entry.legacyId, + legacyPath: entry.sourceRelativePath, + migratedAt: new Date().toISOString(), + }, null, 2)}\n`, { mode: 0o640 }); +} + +function importedProjectId(organizationId: string, legacyId: string): string { + const digest = createHash("sha256") + .update("teaching-material-host-service\0") + .update(organizationId) + .update("\0") + .update(legacyId) + .digest("hex") + .slice(0, 32); + return `legacy_${digest}`; +} + +async function hasCompletionMarker( + workspaceDir: string, + entry: LegacyProjectManifestEntry, +): Promise { + try { + const marker = JSON.parse(await readFile(join(workspaceDir, ".legacy-source", "migration.json"), "utf8")) as unknown; + return typeof marker === "object" && marker !== null + && "legacyProjectId" in marker && marker.legacyProjectId === entry.legacyId + && "legacyPath" in marker && marker.legacyPath === entry.sourceRelativePath; + } catch (error) { + if (isMissing(error)) return false; + if (error instanceof SyntaxError) { + throw new Error(`invalid legacy completion marker: ${workspaceDir}`, { cause: error }); + } + throw error; + } +} + +async function ensureImportAudit( + prisma: PrismaClient, + projectId: string, + entry: LegacyProjectManifestEntry, +): Promise { + const existing = await prisma.auditEntry.findFirst({ + where: { projectId, action: "legacy_project.imported" }, + select: { id: true }, + }); + if (existing !== null) return; + await prisma.auditEntry.create({ + data: { + projectId, + action: "legacy_project.imported", + metadata: { + source: "teaching-material-host-service", + legacyProjectId: entry.legacyId, + legacyPath: entry.sourceRelativePath, + }, + }, + }); +} + +async function removeIncompleteTarget( + prisma: PrismaClient, + projectId: string, + workspaceDir: string, + workspaceRoot: string, +): Promise { + await assertConfinedExistingPath(workspaceRoot, workspaceDir); + const failures: unknown[] = []; + try { + await prisma.project.delete({ where: { id: projectId } }); + } catch (error) { + failures.push(error); + } + try { + await rm(workspaceDir, { recursive: true, force: true }); + } catch (error) { + failures.push(error); + } + if (failures.length > 0) throw new AggregateError(failures, `failed to remove incomplete legacy target: ${projectId}`); +} + +async function assertConfinedExistingPath(root: string, path: string): Promise { + const trustedRoot = await realpath(root); + const candidate = await realpath(path); + const rel = relative(trustedRoot, candidate); + if (rel === "" || rel === ".." || rel.startsWith("../") || isAbsolute(rel)) { + throw new Error(`refusing to remove path outside workspace root: ${path}`); + } +} + +async function assertNoSymlinks(path: string): Promise { + const metadata = await lstat(path); + if (metadata.isSymbolicLink()) throw new Error(`legacy import rejects symbolic link: ${path}`); + if (!metadata.isDirectory()) return; + for (const entry of await readdir(path)) await assertNoSymlinks(join(path, entry)); +} + +async function confinedSourceDir(sourceRoot: string, relativePath: string): Promise { + const candidate = await realpath(resolve(sourceRoot, relativePath)); + const rel = relative(sourceRoot, candidate); + if (rel === "" || rel === ".." || rel.startsWith("../") || isAbsolute(rel)) { + throw new Error(`legacy source path escapes source root: ${relativePath}`); + } + return candidate; +} + +function validateEntry(entry: LegacyProjectManifestEntry): void { + if (typeof entry !== "object" || entry === null) throw new Error("invalid legacy project entry"); + if (typeof entry.legacyId !== "string") throw new Error("legacy project id must be a string"); + if (!/^[A-Za-z0-9_-]+$/.test(entry.legacyId)) throw new Error(`invalid legacy project id: ${entry.legacyId}`); + if (typeof entry.name !== "string") throw new Error(`legacy project name must be a string: ${entry.legacyId}`); + if (entry.name.trim() === "") throw new Error(`legacy project name is empty: ${entry.legacyId}`); + if (typeof entry.sourceRelativePath !== "string") { + throw new Error(`legacy source path must be a string: ${entry.legacyId}`); + } + if (entry.sourceRelativePath === "" || resolve("/", entry.sourceRelativePath) === "/") { + throw new Error(`invalid legacy source path: ${entry.legacyId}`); + } + if (!Array.isArray(entry.folderPath)) throw new Error(`legacy folder path must be an array: ${entry.legacyId}`); + for (const part of entry.folderPath) { + if (typeof part !== "string" || part.trim() === "" || part === "." || part === ".." || part.includes("/") || part.includes("\\")) { + throw new Error(`invalid legacy folder part for ${entry.legacyId}: ${part}`); + } + } +} + +async function readState(path: string): Promise { + try { + const parsed = JSON.parse(await readFile(path, "utf8")) as LegacyProjectImportState; + if (parsed.version !== 1 || typeof parsed.projects !== "object" || parsed.projects === null) { + throw new Error(`invalid legacy import state: ${path}`); + } + for (const [legacyId, record] of Object.entries(parsed.projects)) validateStateRecord(path, legacyId, record); + return parsed; + } catch (error) { + if (isMissing(error)) return { version: 1, projects: {} }; + throw error; + } +} + +function validateStateRecord(path: string, legacyId: string, record: unknown): void { + if (typeof record !== "object" || record === null || Array.isArray(record)) { + throw new Error(`invalid legacy import state record: ${path}#${legacyId}`); + } + const values = record as Record; + const expectedKeys = ["importedAt", "projectId", "sourceRelativePath", "status", "workspaceDir"]; + if (Object.keys(values).sort().join("\0") !== expectedKeys.join("\0")) { + throw new Error(`invalid legacy import state fields: ${path}#${legacyId}`); + } + if (values.status !== "PENDING" && values.status !== "COMPLETED") { + throw new Error(`invalid legacy import state status: ${path}#${legacyId}`); + } + for (const field of ["projectId", "workspaceDir", "importedAt", "sourceRelativePath"] as const) { + if (typeof values[field] !== "string") throw new Error(`invalid legacy import state ${field}: ${path}#${legacyId}`); + } + if (values.projectId === "" || values.sourceRelativePath === "") { + throw new Error(`invalid legacy import state identity: ${path}#${legacyId}`); + } + if (values.status === "PENDING" && (values.workspaceDir !== "" || values.importedAt !== "")) { + throw new Error(`invalid pending legacy import state: ${path}#${legacyId}`); + } + if (values.status === "COMPLETED" && (values.workspaceDir === "" || values.importedAt === "")) { + throw new Error(`invalid completed legacy import state: ${path}#${legacyId}`); + } +} + +async function writeState(path: string, state: LegacyProjectImportState): Promise { + await mkdir(dirname(path), { recursive: true, mode: 0o750 }); + const temporary = `${path}.tmp`; + await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 }); + await rename(temporary, path); +} + +function isMissing(error: unknown): boolean { + return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT"; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/hub/src/feishu/projectOnboardingCard.ts b/hub/src/feishu/projectOnboardingCard.ts index 75f0657..66f27e4 100644 --- a/hub/src/feishu/projectOnboardingCard.ts +++ b/hub/src/feishu/projectOnboardingCard.ts @@ -22,6 +22,7 @@ export function buildUnboundChatOnboardingCard(params: { readonly folders: readonly OnboardingFolderOption[]; readonly projects: readonly OnboardingProjectOption[]; readonly canCreateProject: boolean; + readonly searchQuery?: string | undefined; }): Record { const actions: unknown[] = []; if (params.canCreateProject) { @@ -64,7 +65,14 @@ export function buildUnboundChatOnboardingCard(params: { `这个飞书群还没有绑定项目。`, ``, `组织: **${escapeMarkdown(params.organizationName)}**`, - `可以选择 folder 新建项目并绑定到本群,或绑定你已经有管理权限的未绑定项目。`, + ...(params.searchQuery === undefined || params.searchQuery === "" + ? [`可以选择 folder 新建项目并绑定到本群,或绑定你已经有管理权限的未绑定项目。`] + : [ + `项目搜索: **${escapeMarkdown(params.searchQuery)}**`, + params.projects.length === 0 + ? `没有匹配的未绑定项目。请 @bot 后换一个项目关键词。` + : `请选择匹配项目绑定到本群;如未找到,请 @bot 后换一个更具体的关键词。`, + ]), ].join("\n"), }, ]; diff --git a/hub/src/feishu/trigger.ts b/hub/src/feishu/trigger.ts index afbeca8..d7ec7f0 100644 --- a/hub/src/feishu/trigger.ts +++ b/hub/src/feishu/trigger.ts @@ -1070,10 +1070,12 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { const settings = await ensureOrganizationProjectSettings(deps.prisma, organization.organizationId); const canCreateProject = settings.membersCanCreateProjects || isOrgAdminRole(organization.role); + const searchQuery = (extractPrompt(msg) ?? "").trim().slice(0, 100); const projects = await listBindableProjectsForActor({ organizationId: organization.organizationId, actorFeishuOpenId: senderOpenId, isOrgAdmin: isOrgAdminRole(organization.role), + searchQuery, }); const folders = await listCreatableRootFolders(organization.organizationId); @@ -1086,6 +1088,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { folders, projects, canCreateProject, + searchQuery, }), sendOptionsForTriggerMessage(msg), ); @@ -1182,37 +1185,51 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { readonly organizationId: string; readonly actorFeishuOpenId: string; readonly isOrgAdmin: boolean; + readonly searchQuery: string; }): Promise { - const candidates = await deps.prisma.project.findMany({ - where: { + const allowed: OnboardingProjectOption[] = []; + let cursor: string | undefined; + while (allowed.length < 5) { + const candidates = await deps.prisma.project.findMany({ + where: { organizationId: input.organizationId, archivedAt: null, groupBindings: { none: { archivedAt: null } }, - }, - select: { - id: true, - name: true, - folder: { select: { name: true } }, - }, - orderBy: { updatedAt: "desc" }, - take: 20, - }); - const allowed: OnboardingProjectOption[] = []; - for (const project of candidates) { - if (!input.isOrgAdmin) { - const decision = await authorizer.can({ - actor: { feishuOpenId: input.actorFeishuOpenId }, - action: "collaborator.manage", - resource: { type: "PROJECT", id: project.id }, - }); - if (!decision.allowed) continue; - } - allowed.push({ - projectId: project.id, - name: project.name, - ...(project.folder?.name !== undefined ? { folderName: project.folder.name } : {}), + ...(input.searchQuery === "" ? {} : { + OR: [ + { name: { contains: input.searchQuery, mode: "insensitive" } }, + { folder: { name: { contains: input.searchQuery, mode: "insensitive" } } }, + ], + }), + }, + select: { + id: true, + name: true, + folder: { select: { name: true } }, + }, + orderBy: [{ updatedAt: "desc" }, { id: "desc" }], + take: 20, + ...(cursor === undefined ? {} : { cursor: { id: cursor }, skip: 1 }), }); - if (allowed.length >= 5) break; + for (const project of candidates) { + if (!input.isOrgAdmin) { + const decision = await authorizer.can({ + actor: { feishuOpenId: input.actorFeishuOpenId }, + action: "collaborator.manage", + resource: { type: "PROJECT", id: project.id }, + }); + if (!decision.allowed) continue; + } + allowed.push({ + projectId: project.id, + name: project.name, + ...(project.folder?.name !== undefined ? { folderName: project.folder.name } : {}), + }); + if (allowed.length >= 5) break; + } + if (candidates.length < 20) break; + cursor = candidates.at(-1)?.id; + if (cursor === undefined) break; } return allowed; } diff --git a/hub/src/projectOnboarding.ts b/hub/src/projectOnboarding.ts index bf65864..c958830 100644 --- a/hub/src/projectOnboarding.ts +++ b/hub/src/projectOnboarding.ts @@ -27,6 +27,8 @@ export interface CreateOrgAdminProjectInput { readonly workspaceRoot: string; readonly folderId?: string | undefined; readonly sortKey?: string | undefined; + /** Stable internal identifier for resumable imports; ordinary callers must omit it. */ + readonly projectId?: string | undefined; } export interface CreateFeishuChatProjectInput { @@ -147,6 +149,7 @@ export async function createProjectFromOrgAdmin( workspaceRoot: input.workspaceRoot, folderId: input.folderId, sortKey: input.sortKey, + projectId: input.projectId, chatId: undefined, }); } @@ -291,6 +294,7 @@ async function createManagedProject( readonly workspaceRoot: string; readonly folderId: string | undefined; readonly sortKey?: string | undefined; + readonly projectId?: string | undefined; readonly chatId: string | undefined; }, ): Promise { @@ -301,7 +305,7 @@ async function createManagedProject( if (organization === null) throw new Error(`organization not found: ${input.organizationId}`); requireActiveOrganizationStatus(organization.id, organization.status); - const projectId = createProjectId(); + const projectId = input.projectId ?? createProjectId(); const workspaceDir = projectWorkspaceDir({ workspaceRoot: input.workspaceRoot, organizationSlug: organization.slug, diff --git a/hub/test/integration/legacy-project-import.test.ts b/hub/test/integration/legacy-project-import.test.ts new file mode 100644 index 0000000..1973f8f --- /dev/null +++ b/hub/test/integration/legacy-project-import.test.ts @@ -0,0 +1,185 @@ +import { mkdir, mkdtemp, readFile, rm, symlink, unlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest"; +import { importLegacyProjects } from "../../src/deployment/legacyProjectImport.js"; +import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js"; + +const temporaryRoots: string[] = []; + +describe("legacy teaching-material project import", () => { + beforeEach(async () => { + await resetDb(); + await prisma.user.create({ + data: { + id: "legacy-import-owner", + feishuOpenId: "ou_legacy_owner", + displayName: "Legacy Import Owner", + organizationMemberships: { create: { organizationId: DEFAULT_ORG_ID, role: "OWNER" } }, + }, + }); + }); + + afterEach(async () => { + while (temporaryRoots.length > 0) { + const root = temporaryRoots.pop(); + if (root !== undefined) await rm(root, { recursive: true, force: true }); + } + }); + + afterAll(async () => prisma.$disconnect()); + + it("imports each legacy project as an unbound resumable project under its old folder path", async () => { + const sourceRoot = await temporaryRoot("cph-legacy-source-"); + const workspaceRoot = await temporaryRoot("cph-legacy-target-"); + const projectSource = join(sourceRoot, "物理", "M-243-牛顿力学"); + await mkdir(join(projectSource, "workspace", "chapters"), { recursive: true }); + await mkdir(join(projectSource, "workspace", ".claude"), { recursive: true }); + await mkdir(join(projectSource, "workspace", "chapters", ".cph"), { recursive: true }); + await mkdir(join(projectSource, "_raw"), { recursive: true }); + await writeFile(join(projectSource, "workspace", "project.toml"), "title = \"牛顿力学\"\n"); + await writeFile(join(projectSource, "workspace", "chapters", "lesson.typ"), "= 牛顿第二定律\n"); + await writeFile(join(projectSource, "workspace", ".claude", "session.json"), "{}\n"); + await writeFile(join(projectSource, "workspace", "chapters", ".cph", "runtime.json"), "{}\n"); + await writeFile(join(projectSource, "project.json"), "{\"id\":\"M-243\"}\n"); + await writeFile(join(projectSource, "_raw", "source.txt"), "legacy source\n"); + const stateFile = join(workspaceRoot, "migration-state", "state.json"); + const manifest = [{ + legacyId: "M-243", + name: "牛顿力学", + folderPath: ["物理"], + sourceRelativePath: "物理/M-243-牛顿力学", + }]; + + const first = await importLegacyProjects({ + prisma, + organizationId: DEFAULT_ORG_ID, + actorFeishuOpenId: "ou_legacy_owner", + workspaceRoot, + sourceRoot, + stateFile, + projects: manifest, + }); + const imported = first.projects["M-243"]; + expect(imported).toBeDefined(); + if (imported === undefined) throw new Error("missing imported project state"); + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: imported.projectId }, + include: { folder: { include: { parent: true } }, groupBindings: true }, + }); + expect(project.name).toBe("牛顿力学"); + expect(project.folder?.name).toBe("物理"); + expect(project.folder?.parent?.name).toBe("旧教学资产"); + expect(project.groupBindings).toEqual([]); + await expect(readFile(join(imported.workspaceDir, "chapters", "lesson.typ"), "utf8")) + .resolves.toBe("= 牛顿第二定律\n"); + await expect(readFile(join(imported.workspaceDir, ".claude", "session.json"), "utf8")) + .rejects.toMatchObject({ code: "ENOENT" }); + await expect(readFile(join(imported.workspaceDir, "chapters", ".cph", "runtime.json"), "utf8")) + .rejects.toMatchObject({ code: "ENOENT" }); + await expect(readFile(join(imported.workspaceDir, ".legacy-source", "project.json"), "utf8")) + .resolves.toContain("M-243"); + await expect(readFile(join(imported.workspaceDir, ".legacy-source", "raw", "source.txt"), "utf8")) + .resolves.toBe("legacy source\n"); + + await unlink(stateFile); + const second = await importLegacyProjects({ + prisma, + organizationId: DEFAULT_ORG_ID, + actorFeishuOpenId: "ou_legacy_owner", + workspaceRoot, + sourceRoot, + stateFile, + projects: manifest, + }); + expect(second.projects["M-243"]?.projectId).toBe(imported.projectId); + await expect(prisma.project.count({ where: { organizationId: DEFAULT_ORG_ID } })).resolves.toBe(1); + expect(JSON.parse(await readFile(stateFile, "utf8"))).toMatchObject({ + version: 1, + projects: { "M-243": { projectId: imported.projectId } }, + }); + await expect(prisma.auditEntry.count({ + where: { projectId: imported.projectId, action: "legacy_project.imported" }, + })).resolves.toBe(1); + + await writeFile(join(imported.workspaceDir, ".legacy-source", "migration.json"), "not json\n"); + await expect(importLegacyProjects({ + prisma, + organizationId: DEFAULT_ORG_ID, + actorFeishuOpenId: "ou_legacy_owner", + workspaceRoot, + sourceRoot, + stateFile, + projects: manifest, + })).rejects.toThrow(/invalid legacy completion marker/); + await expect(prisma.project.count({ where: { id: imported.projectId } })).resolves.toBe(1); + }); + + it("rejects symlinks instead of importing paths outside the staged project", async () => { + const sourceRoot = await temporaryRoot("cph-legacy-symlink-"); + const workspaceRoot = await temporaryRoot("cph-legacy-target-"); + const projectSource = join(sourceRoot, "legacy"); + await mkdir(join(projectSource, "workspace"), { recursive: true }); + await writeFile(join(projectSource, "project.json"), "{}\n"); + await symlink("/etc/passwd", join(projectSource, "workspace", "outside")); + + await expect(importLegacyProjects({ + prisma, + organizationId: DEFAULT_ORG_ID, + actorFeishuOpenId: "ou_legacy_owner", + workspaceRoot, + sourceRoot, + stateFile: join(workspaceRoot, "state.json"), + projects: [{ legacyId: "symlink", name: "Symlink", folderPath: [], sourceRelativePath: "legacy" }], + })).rejects.toThrow(/rejects symbolic link/); + await expect(prisma.project.count()).resolves.toBe(0); + }); + + it("rejects a manifest path that escapes the staged source root", async () => { + const parent = await temporaryRoot("cph-legacy-escape-"); + const sourceRoot = join(parent, "source"); + const outside = join(parent, "outside"); + await mkdir(sourceRoot); + await mkdir(outside); + + await expect(importLegacyProjects({ + prisma, + organizationId: DEFAULT_ORG_ID, + actorFeishuOpenId: "ou_legacy_owner", + workspaceRoot: await temporaryRoot("cph-legacy-target-"), + sourceRoot, + stateFile: join(parent, "state.json"), + projects: [{ + legacyId: "escape", + name: "Escape", + folderPath: [], + sourceRelativePath: "../outside", + }], + })).rejects.toThrow(/escapes source root/); + }); + + it("rejects malformed resume state before creating a project", async () => { + const sourceRoot = await temporaryRoot("cph-legacy-state-source-"); + const workspaceRoot = await temporaryRoot("cph-legacy-state-target-"); + const stateFile = join(workspaceRoot, "state.json"); + await writeFile(stateFile, JSON.stringify({ version: 1, projects: { broken: { status: "MAYBE" } } })); + + await expect(importLegacyProjects({ + prisma, + organizationId: DEFAULT_ORG_ID, + actorFeishuOpenId: "ou_legacy_owner", + workspaceRoot, + sourceRoot, + stateFile, + projects: [], + })).rejects.toThrow(/invalid legacy import state fields/); + await expect(prisma.project.count()).resolves.toBe(0); + }); +}); + +async function temporaryRoot(prefix: string): Promise { + const root = await mkdtemp(join(tmpdir(), prefix)); + temporaryRoots.push(root); + return root; +} diff --git a/hub/test/integration/trigger.test.ts b/hub/test/integration/trigger.test.ts index e70f840..318bcbc 100644 --- a/hub/test/integration/trigger.test.ts +++ b/hub/test/integration/trigger.test.ts @@ -361,6 +361,100 @@ describe("trigger full lifecycle (integration)", () => { expect(cardHeaderTitle(rt.sentPatches.at(-1))).toBe("已绑定项目"); }); + it("uses unbound-chat mention text to search bindable projects", async () => { + await seedOnboardingUser("u-onboard-search", "ou_onboard_search", "OWNER"); + const folder = await prisma.folder.create({ + data: { organizationId: DEFAULT_ORG_ID, name: "物理竞赛" }, + }); + await prisma.project.createMany({ + data: [ + { + id: "p-search-newton", + organizationId: DEFAULT_ORG_ID, + folderId: folder.id, + name: "牛顿力学专题", + workspaceDir: join(await tempWorkspaceRoot(), "p-search-newton"), + }, + { + id: "p-search-optics", + organizationId: DEFAULT_ORG_ID, + folderId: folder.id, + name: "几何光学专题", + workspaceDir: join(await tempWorkspaceRoot(), "p-search-optics"), + }, + ], + }); + const trigger = makeTriggerHandler({ + prisma, + settings, + logger: silentLogger, + runAgent, + projectWorkspaceRoot: await tempWorkspaceRoot(), + }); + + await trigger(makeEvent("chat-onboard-search", "@_user_1 牛顿", "ou_onboard_search"), rt); + + expect(cardActionValues(rt.sentCards[0])).toContainEqual({ + project_onboarding: { + action: "bind_project", + organization_id: DEFAULT_ORG_ID, + project_id: "p-search-newton", + }, + }); + expect(cardActionValues(rt.sentCards[0])).not.toContainEqual(expect.objectContaining({ + project_onboarding: expect.objectContaining({ project_id: "p-search-optics" }), + })); + expect(JSON.stringify(rt.sentCards[0])).toContain("牛顿"); + expect(runAgentCalls).toHaveLength(0); + }); + + it("continues searching past unauthorized matches for a manageable project", async () => { + await seedOnboardingUser("u-onboard-page", "ou_onboard_page", "MEMBER"); + const workspaceRoot = await tempWorkspaceRoot(); + await prisma.project.createMany({ + data: [ + ...Array.from({ length: 20 }, (_, index) => ({ + id: `z-search-denied-${String(index).padStart(2, "0")}`, + organizationId: DEFAULT_ORG_ID, + name: `迁移项目 ${index}`, + workspaceDir: join(workspaceRoot, `denied-${index}`), + })), + { + id: "a-search-allowed", + organizationId: DEFAULT_ORG_ID, + name: "迁移项目 可管理", + workspaceDir: join(workspaceRoot, "allowed"), + }, + ], + }); + await prisma.permissionGrant.create({ + data: { + resourceType: "PROJECT", + resourceId: "a-search-allowed", + principalType: "USER", + principalId: "ou_onboard_page", + role: "MANAGE", + }, + }); + const trigger = makeTriggerHandler({ + prisma, + settings, + logger: silentLogger, + runAgent, + projectWorkspaceRoot: workspaceRoot, + }); + + await trigger(makeEvent("chat-onboard-page", "@_user_1 迁移", "ou_onboard_page"), rt); + + expect(cardActionValues(rt.sentCards[0])).toContainEqual({ + project_onboarding: { + action: "bind_project", + organization_id: DEFAULT_ORG_ID, + project_id: "a-search-allowed", + }, + }); + }); + it("batches quick text messages from the same chat and sender into one run", async () => { await seedProject("proj-1b", "chat-1b"); const trigger = makeTriggerHandler({ diff --git a/hub/test/unit/legacy-project-manifest.test.ts b/hub/test/unit/legacy-project-manifest.test.ts new file mode 100644 index 0000000..f2b29b9 --- /dev/null +++ b/hub/test/unit/legacy-project-manifest.test.ts @@ -0,0 +1,61 @@ +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { afterEach, describe, expect, it } from "vitest"; + +const execute = promisify(execFile); +const temporaryRoots: string[] = []; + +describe("legacy project manifest builder", () => { + afterEach(async () => { + while (temporaryRoots.length > 0) { + const root = temporaryRoots.pop(); + if (root !== undefined) await rm(root, { recursive: true, force: true }); + } + }); + + it("stops at a project root and excludes trash projects", async () => { + const root = await mkdtemp(join(tmpdir(), "cph-legacy-manifest-")); + temporaryRoots.push(root); + const projectRoot = join(root, "物理", "legacy__牛顿力学"); + await mkdir(join(projectRoot, "workspace", "nested"), { recursive: true }); + await mkdir(join(projectRoot, "_raw"), { recursive: true }); + await mkdir(join(root, ".trash", "deleted"), { recursive: true }); + await writeFile(join(projectRoot, "project.json"), JSON.stringify({ + id: "legacy", + name: "牛顿力学", + folderPath: ["物理"], + })); + await writeFile(join(projectRoot, "workspace", "nested", "project.json"), "not metadata"); + await writeFile(join(projectRoot, "_raw", "project.json"), "not metadata"); + await writeFile(join(root, ".trash", "deleted", "project.json"), JSON.stringify({ + id: "deleted", + name: "Deleted", + })); + + const { stdout } = await execute(process.execPath, [ + resolve("deploy/build_legacy_project_manifest.mjs"), + root, + ]); + + expect(JSON.parse(stdout)).toEqual([{ + legacyId: "legacy", + name: "牛顿力学", + folderPath: ["物理"], + sourceRelativePath: "物理/legacy__牛顿力学", + }]); + }); + + it("rejects symlinked entries instead of silently omitting them", async () => { + const root = await mkdtemp(join(tmpdir(), "cph-legacy-manifest-link-")); + temporaryRoots.push(root); + await symlink("/tmp", join(root, "linked-project")); + + await expect(execute(process.execPath, [ + resolve("deploy/build_legacy_project_manifest.mjs"), + root, + ])).rejects.toMatchObject({ stderr: expect.stringContaining("rejects symbolic link") }); + }); +});