forked from bai/curriculum-project-hub
feat: migrate legacy projects through binding search
This commit is contained in:
@@ -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<void> {
|
||||
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<string, string> {
|
||||
const options = new Map<string, string>();
|
||||
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<string, string>, 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());
|
||||
@@ -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<Record<string, {
|
||||
readonly status: "PENDING" | "COMPLETED";
|
||||
readonly projectId: string;
|
||||
readonly workspaceDir: string;
|
||||
readonly importedAt: string;
|
||||
readonly sourceRelativePath: string;
|
||||
}>>;
|
||||
}
|
||||
|
||||
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<LegacyProjectImportState> {
|
||||
const sourceRoot = await realpath(input.sourceRoot);
|
||||
const state = await readState(input.stateFile);
|
||||
const projects = { ...state.projects };
|
||||
const seen = new Set<string>();
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
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<LegacyProjectImportState> {
|
||||
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<string, unknown>;
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export function buildUnboundChatOnboardingCard(params: {
|
||||
readonly folders: readonly OnboardingFolderOption[];
|
||||
readonly projects: readonly OnboardingProjectOption[];
|
||||
readonly canCreateProject: boolean;
|
||||
readonly searchQuery?: string | undefined;
|
||||
}): Record<string, unknown> {
|
||||
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"),
|
||||
},
|
||||
];
|
||||
|
||||
+43
-26
@@ -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<readonly OnboardingProjectOption[]> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<ProjectOnboardingResult> {
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user