feat: add paginated project discovery

This commit is contained in:
2026-07-12 01:45:15 +08:00
parent 53d372e29b
commit 816af1abdb
14 changed files with 1505 additions and 237 deletions
+5
View File
@@ -32,6 +32,11 @@
tools 与已安装 skillskill 版本进入 content-addressed 持久存储,run 只读加载所选快照。 tools 与已安装 skillskill 版本进入 content-addressed 持久存储,run 只读加载所选快照。
`settingSources: []` 继续禁用项目/用户配置加载,不得把任意 workspace `.claude` 配置变成 `settingSources: []` 继续禁用项目/用户配置加载,不得把任意 workspace `.claude` 配置变成
运行时能力(见 ADR-0018)。 运行时能力(见 ADR-0018)。
- 项目发现由 `ProjectDiscovery` 模块统一承载:PostgreSQL `pg_trgm` 搜索派生文档、项目编号
归一化、完整 Folder breadcrumb、MANAGE 授权过滤与分页都在该模块内;飞书卡片只是 adapter。
`Project`/`Folder` 仍是事实来源,搜索文档必须可重建且由数据库触发器同步,禁止调用方双写。
系统 `Inbox` 只作为未分类项目的内部落点,不作为业务 folder 暴露;已绑定群通过
`@bot /project` 随时打开项目管理卡片,重命名仍走 org-scoped MANAGE 授权与审计。
## 纪律 ## 纪律
@@ -0,0 +1,191 @@
-- Derived project discovery projection. Project/Folder remain authoritative;
-- triggers prevent index drift through ordinary database mutations.
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE TYPE "FolderKind" AS ENUM ('REGULAR', 'SYSTEM_INBOX');
ALTER TABLE "Folder" ADD COLUMN "kind" "FolderKind" NOT NULL DEFAULT 'REGULAR';
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM "Folder"
WHERE "parentId" IS NULL AND "name" = 'Inbox' AND "archivedAt" IS NULL
GROUP BY "organizationId" HAVING count(*) > 1
) THEN
RAISE EXCEPTION 'cannot identify system Inbox: organization has multiple active root Inbox folders';
END IF;
END $$;
UPDATE "Folder" f SET "kind" = 'SYSTEM_INBOX'
WHERE f."parentId" IS NULL AND f."name" = 'Inbox' AND f."archivedAt" IS NULL;
INSERT INTO "Folder" ("id", "organizationId", "parentId", "name", "kind", "sortKey", "createdAt", "updatedAt")
SELECT o."id" || ':system-inbox', o."id", NULL, 'Inbox', 'SYSTEM_INBOX', '000000', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM "Organization" o
WHERE NOT EXISTS (
SELECT 1 FROM "Folder" f
WHERE f."organizationId" = o."id" AND f."kind" = 'SYSTEM_INBOX' AND f."archivedAt" IS NULL
);
CREATE UNIQUE INDEX "Folder_one_active_system_inbox_per_org"
ON "Folder"("organizationId") WHERE "kind" = 'SYSTEM_INBOX' AND "archivedAt" IS NULL;
CREATE OR REPLACE FUNCTION cph_protect_system_inbox() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'UPDATE' AND OLD."kind" = 'SYSTEM_INBOX' AND (
NEW."id" IS DISTINCT FROM OLD."id" OR
NEW."organizationId" IS DISTINCT FROM OLD."organizationId" OR
NEW."kind" IS DISTINCT FROM OLD."kind" OR
NEW."name" IS DISTINCT FROM OLD."name" OR
NEW."parentId" IS DISTINCT FROM OLD."parentId" OR
NEW."archivedAt" IS DISTINCT FROM OLD."archivedAt"
) THEN
RAISE EXCEPTION 'system Inbox identity cannot be changed';
END IF;
IF TG_OP IN ('INSERT', 'UPDATE') AND NEW."kind" = 'SYSTEM_INBOX' AND (
NEW."name" <> 'Inbox' OR NEW."parentId" IS NOT NULL OR NEW."archivedAt" IS NOT NULL
) THEN
RAISE EXCEPTION 'system Inbox must be an active root folder named Inbox';
END IF;
IF TG_OP = 'DELETE' AND OLD."kind" = 'SYSTEM_INBOX' AND EXISTS (
SELECT 1 FROM "Organization" WHERE "id" = OLD."organizationId"
) THEN
RAISE EXCEPTION 'system Inbox cannot be deleted while its organization exists';
END IF;
RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
END;
$$;
CREATE TRIGGER cph_protect_system_inbox
BEFORE INSERT OR UPDATE OR DELETE ON "Folder"
FOR EACH ROW EXECUTE FUNCTION cph_protect_system_inbox();
ALTER TABLE "Project" ADD COLUMN "code" TEXT;
CREATE TABLE "ProjectSearchDocument" (
"projectId" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"code" TEXT,
"normalizedCode" TEXT NOT NULL,
"normalizedName" TEXT NOT NULL,
"breadcrumb" TEXT NOT NULL,
"normalizedBreadcrumb" TEXT NOT NULL,
"normalizedSearchText" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ProjectSearchDocument_pkey" PRIMARY KEY ("projectId")
);
CREATE INDEX "ProjectSearchDocument_organizationId_idx" ON "ProjectSearchDocument"("organizationId");
CREATE INDEX "ProjectSearchDocument_normalizedName_trgm_idx"
ON "ProjectSearchDocument" USING GIN ("normalizedName" gin_trgm_ops);
CREATE INDEX "ProjectSearchDocument_normalizedCode_trgm_idx"
ON "ProjectSearchDocument" USING GIN ("normalizedCode" gin_trgm_ops);
CREATE INDEX "ProjectSearchDocument_normalizedBreadcrumb_trgm_idx"
ON "ProjectSearchDocument" USING GIN ("normalizedBreadcrumb" gin_trgm_ops);
CREATE INDEX "ProjectSearchDocument_normalizedSearchText_trgm_idx"
ON "ProjectSearchDocument" USING GIN ("normalizedSearchText" gin_trgm_ops);
ALTER TABLE "ProjectSearchDocument" ADD CONSTRAINT "ProjectSearchDocument_projectId_fkey"
FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ProjectSearchDocument" ADD CONSTRAINT "ProjectSearchDocument_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
CREATE OR REPLACE FUNCTION cph_project_search_normalize(value TEXT) RETURNS TEXT
LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS $$
SELECT lower(regexp_replace(normalize(value, NFKC), '[[:space:]_.:/\\-]+', '', 'g'))
$$;
CREATE OR REPLACE FUNCTION cph_folder_breadcrumb(folder_id TEXT) RETURNS TEXT
LANGUAGE sql STABLE PARALLEL SAFE AS $$
WITH RECURSIVE ancestors AS (
SELECT f."id", f."parentId", f."name", f."kind", 0 AS depth
FROM "Folder" f
WHERE f."id" = folder_id
UNION ALL
SELECT parent."id", parent."parentId", parent."name", parent."kind", child.depth + 1
FROM "Folder" parent
JOIN ancestors child ON parent."id" = child."parentId"
)
SELECT CASE
WHEN count(*) = 1 AND bool_and("kind" = 'SYSTEM_INBOX') THEN '未分类'
ELSE coalesce(string_agg("name", ' / ' ORDER BY depth DESC), '')
END
FROM ancestors
$$;
CREATE OR REPLACE FUNCTION cph_project_search_code(explicit_code TEXT, project_name TEXT) RETURNS TEXT
LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
SELECT CASE
WHEN explicit_code IS NOT NULL AND btrim(explicit_code) <> ''
THEN cph_project_search_normalize(explicit_code)
ELSE coalesce(substring(cph_project_search_normalize(project_name) FROM '^[a-z]+[0-9]+'), '')
END
$$;
CREATE OR REPLACE FUNCTION cph_refresh_project_search_document(target_project_id TEXT) RETURNS void
LANGUAGE plpgsql AS $$
BEGIN
INSERT INTO "ProjectSearchDocument" (
"projectId", "organizationId", "name", "code", "normalizedCode", "normalizedName",
"breadcrumb", "normalizedBreadcrumb", "normalizedSearchText", "updatedAt"
)
SELECT p."id", p."organizationId", p."name", p."code",
cph_project_search_code(p."code", p."name"),
cph_project_search_normalize(p."name"),
cph_folder_breadcrumb(p."folderId"),
cph_project_search_normalize(cph_folder_breadcrumb(p."folderId")),
cph_project_search_normalize(cph_folder_breadcrumb(p."folderId") || ' ' || p."name"),
CURRENT_TIMESTAMP
FROM "Project" p WHERE p."id" = target_project_id
ON CONFLICT ("projectId") DO UPDATE SET
"organizationId" = EXCLUDED."organizationId",
"name" = EXCLUDED."name",
"code" = EXCLUDED."code",
"normalizedCode" = EXCLUDED."normalizedCode",
"normalizedName" = EXCLUDED."normalizedName",
"breadcrumb" = EXCLUDED."breadcrumb",
"normalizedBreadcrumb" = EXCLUDED."normalizedBreadcrumb",
"normalizedSearchText" = EXCLUDED."normalizedSearchText",
"updatedAt" = CURRENT_TIMESTAMP;
END;
$$;
CREATE OR REPLACE FUNCTION cph_project_search_project_trigger() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
PERFORM cph_refresh_project_search_document(NEW."id");
RETURN NEW;
END;
$$;
CREATE TRIGGER cph_project_search_project_changed
AFTER INSERT OR UPDATE OF "name", "code", "folderId", "organizationId", "archivedAt" ON "Project"
FOR EACH ROW EXECUTE FUNCTION cph_project_search_project_trigger();
CREATE OR REPLACE FUNCTION cph_project_search_folder_trigger() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE project_id TEXT;
BEGIN
FOR project_id IN
WITH RECURSIVE descendants AS (
SELECT NEW."id"
UNION ALL
SELECT child."id" FROM "Folder" child
JOIN descendants parent ON child."parentId" = parent."id"
)
SELECT p."id" FROM "Project" p
WHERE p."folderId" IN (SELECT "id" FROM descendants)
LOOP
PERFORM cph_refresh_project_search_document(project_id);
END LOOP;
RETURN NEW;
END;
$$;
CREATE TRIGGER cph_project_search_folder_changed
AFTER UPDATE OF "name", "kind", "parentId", "archivedAt" ON "Folder"
FOR EACH ROW EXECUTE FUNCTION cph_project_search_folder_trigger();
SELECT cph_refresh_project_search_document("id") FROM "Project";
+30
View File
@@ -46,6 +46,7 @@ model Organization {
agentSkills OrganizationAgentSkill[] agentSkills OrganizationAgentSkill[]
agentRoles OrganizationAgentRole[] agentRoles OrganizationAgentRole[]
auditEntries AuditEntry[] @relation("organizationAudit") auditEntries AuditEntry[] @relation("organizationAudit")
projectSearchDocuments ProjectSearchDocument[]
@@index([status]) @@index([status])
} }
@@ -437,11 +438,17 @@ model ExternalPrincipalMembership {
/// ADR-0021: transparent project explorer folder. Folders are org-scoped /// ADR-0021: transparent project explorer folder. Folders are org-scoped
/// navigation/aggregation nodes, not permission resources; project grants stay /// navigation/aggregation nodes, not permission resources; project grants stay
/// attached to PROJECT resources. /// attached to PROJECT resources.
enum FolderKind {
REGULAR
SYSTEM_INBOX
}
model Folder { model Folder {
id String @id @default(cuid()) id String @id @default(cuid())
organizationId String organizationId String
parentId String? parentId String?
name String name String
kind FolderKind @default(REGULAR)
sortKey String @default("") sortKey String @default("")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -460,6 +467,7 @@ model Project {
id String @id @default(cuid()) id String @id @default(cuid())
organizationId String organizationId String
folderId String? folderId String?
code String?
name String name String
workspaceDir String workspaceDir String
createdByUserId String? createdByUserId String?
@@ -477,12 +485,34 @@ model Project {
roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants") roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants")
auditEntries AuditEntry[] @relation("projectAudit") auditEntries AuditEntry[] @relation("projectAudit")
fileChanges AgentFileChange[] @relation("projectFileChanges") fileChanges AgentFileChange[] @relation("projectFileChanges")
searchDocument ProjectSearchDocument?
@@index([organizationId, archivedAt]) @@index([organizationId, archivedAt])
@@index([folderId, archivedAt]) @@index([folderId, archivedAt])
@@index([archivedAt]) @@index([archivedAt])
} }
/// Derived, rebuildable search projection for project discovery. PostgreSQL
/// triggers keep it synchronized with Project and Folder mutations; Project
/// remains the source of truth and authorization remains outside this table.
model ProjectSearchDocument {
projectId String @id
organizationId String
name String
code String?
normalizedCode String
normalizedName String
breadcrumb String
normalizedBreadcrumb String
normalizedSearchText String
updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
@@index([organizationId])
}
/// ADR-0001 + ADR-0021: active bindings are one project ↔ one Feishu chat /// ADR-0001 + ADR-0021: active bindings are one project ↔ one Feishu chat
/// (1:1). Historical archived bindings are retained for audit; partial unique /// (1:1). Historical archived bindings are retained for audit; partial unique
/// indexes in migrations enforce one active binding per project and per chat. /// indexes in migrations enforce one active binding per project and per chat.
+1
View File
@@ -100,6 +100,7 @@ export interface CardActionEvent {
readonly value?: unknown; readonly value?: unknown;
readonly tag?: string; readonly tag?: string;
readonly option?: string; readonly option?: string;
readonly form_value?: Readonly<Record<string, unknown>>;
}; };
readonly context?: { readonly open_message_id?: string; readonly open_chat_id?: string }; readonly context?: { readonly open_message_id?: string; readonly open_chat_id?: string };
readonly token?: string; readonly token?: string;
+238 -106
View File
@@ -1,97 +1,46 @@
export interface OnboardingProjectOption { import type { ProjectDiscoveryPage, ProjectFolderPage } from "../projectDiscovery.js";
readonly projectId: string;
readonly name: string;
readonly folderName?: string | undefined;
}
export interface OnboardingFolderOption { export type ProjectOnboardingView =
readonly folderId: string; | { readonly mode: "search"; readonly result: ProjectDiscoveryPage }
readonly name: string; | { readonly mode: "browse"; readonly result: ProjectFolderPage };
}
export interface ProjectOnboardingActionValue { export interface ProjectOnboardingActionValue {
readonly action: "create_project_from_chat" | "bind_project"; readonly action: "create_project_from_chat" | "bind_project" | "browse_folder" | "search_page" | "rename_project";
readonly organization_id: string; readonly organization_id: string;
readonly project_id?: string | undefined; readonly project_id?: string | undefined;
readonly folder_id?: string | undefined; readonly folder_id?: string | undefined;
readonly search_query?: string | undefined;
readonly page?: number | undefined;
} }
export function buildUnboundChatOnboardingCard(params: { export function buildUnboundChatOnboardingCard(params: {
readonly organizationId: string; readonly organizationId: string;
readonly organizationName: string; readonly organizationName: string;
readonly folders: readonly OnboardingFolderOption[];
readonly projects: readonly OnboardingProjectOption[];
readonly canCreateProject: boolean; readonly canCreateProject: boolean;
readonly searchQuery?: string | undefined; readonly view: ProjectOnboardingView;
}): Record<string, unknown> { }): Record<string, unknown> {
const actions: unknown[] = []; const elements: unknown[] = [{
if (params.canCreateProject) { tag: "markdown",
const folders = params.folders.length === 0 ? [{ folderId: undefined, name: "默认位置" }] : params.folders.slice(0, 3); content: onboardingSummary(params.organizationName, params.view),
for (const folder of folders) { }];
actions.push({
tag: "button", if (params.view.mode === "browse") {
text: { tag: "plain_text", content: `新建到 ${buttonLabel(folder.name, 14)}` }, appendFolderNavigation(elements, params.organizationId, params.view.result);
type: "primary",
value: {
project_onboarding: {
action: "create_project_from_chat",
organization_id: params.organizationId,
...(folder.folderId !== undefined ? { folder_id: folder.folderId } : {}),
},
},
});
}
} }
appendProjectResults(
elements,
params.organizationId,
params.view.mode === "search" ? params.view.result.items : params.view.result.projects,
);
appendPagination(elements, params.organizationId, params.view);
appendCreation(elements, params.organizationId, params.canCreateProject, params.view);
for (const project of params.projects.slice(0, 5)) { if (elements.length === 1) {
actions.push({ elements.push({ tag: "markdown", content: "当前目录没有可浏览内容。" });
tag: "button",
text: { tag: "plain_text", content: buttonProjectLabel(project) },
type: "default",
value: {
project_onboarding: {
action: "bind_project",
organization_id: params.organizationId,
project_id: project.projectId,
},
},
});
} }
const elements: unknown[] = [
{
tag: "markdown",
content: [
`这个飞书群还没有绑定项目。`,
``,
`组织: **${escapeMarkdown(params.organizationName)}**`,
...(params.searchQuery === undefined || params.searchQuery === ""
? [`可以选择 folder 新建项目并绑定到本群,或绑定你已经有管理权限的未绑定项目。`]
: [
`项目搜索: **${escapeMarkdown(params.searchQuery)}**`,
params.projects.length === 0
? `没有匹配的未绑定项目。请 @bot 后换一个项目关键词。`
: `请选择匹配项目绑定到本群;如未找到,请 @bot 后换一个更具体的关键词。`,
]),
].join("\n"),
},
];
if (actions.length > 0) {
elements.push({ tag: "action", actions });
} else {
elements.push({
tag: "markdown",
content: "你当前没有可绑定项目,也没有新建项目权限。请联系组织管理员。",
});
}
return { return {
config: { wide_screen_mode: true }, config: { wide_screen_mode: true },
header: { header: { title: { tag: "plain_text", content: "绑定项目" }, template: "blue" },
title: { tag: "plain_text", content: "绑定项目" },
template: "blue",
},
elements, elements,
}; };
} }
@@ -103,54 +52,237 @@ export function buildProjectOnboardingResolvedCard(params: {
}): Record<string, unknown> { }): Record<string, unknown> {
return { return {
config: { wide_screen_mode: true }, config: { wide_screen_mode: true },
header: { header: { title: { tag: "plain_text", content: params.title }, template: params.template },
title: { tag: "plain_text", content: params.title },
template: params.template,
},
elements: [{ tag: "markdown", content: params.body }], elements: [{ tag: "markdown", content: params.body }],
}; };
} }
export function buildProjectManagementCard(params: {
readonly organizationId: string;
readonly projectId: string;
readonly projectName: string;
readonly title?: string | undefined;
}): Record<string, unknown> {
return {
config: { wide_screen_mode: true },
header: {
title: { tag: "plain_text", content: params.title ?? "项目管理" },
template: "green",
},
elements: [
{ tag: "markdown", content: `当前项目: **${escapeMarkdown(params.projectName)}**` },
{
tag: "form",
name: "project_rename_form",
fallback: {
tag: "fallback_text",
text: { tag: "plain_text", content: "请升级飞书客户端后修改项目名称。" },
},
elements: [
{
tag: "input",
name: "project_name",
required: true,
max_length: 100,
default_value: params.projectName,
placeholder: { tag: "plain_text", content: "请输入项目名称" },
},
{
tag: "button",
name: "project_rename_submit",
text: { tag: "plain_text", content: "保存项目名称" },
type: "primary",
complex_interaction: true,
action_type: "form_submit",
value: {
project_onboarding: {
action: "rename_project",
organization_id: params.organizationId,
project_id: params.projectId,
},
},
},
],
},
],
};
}
export function projectOnboardingActionFromValue(value: unknown): ProjectOnboardingActionValue | null { export function projectOnboardingActionFromValue(value: unknown): ProjectOnboardingActionValue | null {
const raw = unwrapValue(value); const raw = unwrapValue(value);
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null; if (typeof raw !== "object" || raw === null || Array.isArray(raw) || !("project_onboarding" in raw)) return null;
if (!("project_onboarding" in raw)) return null;
const action = raw.project_onboarding; const action = raw.project_onboarding;
if (typeof action !== "object" || action === null || Array.isArray(action)) return null; if (typeof action !== "object" || action === null || Array.isArray(action)) return null;
const rawAction = (action as { action?: unknown }).action; const fields = action as Record<string, unknown>;
const organizationId = (action as { organization_id?: unknown }).organization_id; const rawAction = fields.action;
const projectId = (action as { project_id?: unknown }).project_id; const organizationId = fields.organization_id;
const folderId = (action as { folder_id?: unknown }).folder_id; const projectId = fields.project_id;
if ((rawAction !== "create_project_from_chat" && rawAction !== "bind_project") || typeof organizationId !== "string" || organizationId === "") { const folderId = fields.folder_id;
return null; const searchQuery = fields.search_query;
} const page = fields.page;
if (rawAction === "bind_project" && (typeof projectId !== "string" || projectId === "")) { if (!isAction(rawAction) || typeof organizationId !== "string" || organizationId === "") return null;
return null; if ((rawAction === "bind_project" || rawAction === "rename_project") && (typeof projectId !== "string" || projectId === "")) return null;
} if (rawAction === "search_page" && (typeof searchQuery !== "string" || !validPage(page))) return null;
if (folderId !== undefined && (typeof folderId !== "string" || folderId === "")) { if (folderId !== undefined && (typeof folderId !== "string" || folderId === "")) return null;
return null; if (page !== undefined && !validPage(page)) return null;
}
return { return {
action: rawAction, action: rawAction,
organization_id: organizationId, organization_id: organizationId,
...(typeof projectId === "string" && projectId !== "" ? { project_id: projectId } : {}), ...(typeof projectId === "string" && projectId !== "" ? { project_id: projectId } : {}),
...(typeof folderId === "string" && folderId !== "" ? { folder_id: folderId } : {}), ...(typeof folderId === "string" && folderId !== "" ? { folder_id: folderId } : {}),
...(typeof searchQuery === "string" ? { search_query: searchQuery.slice(0, 100) } : {}),
...(typeof page === "number" ? { page } : {}),
}; };
} }
function onboardingSummary(organizationName: string, view: ProjectOnboardingView): string {
if (view.mode === "search") {
const result = view.result;
return [
"这个飞书群还没有绑定项目。",
"",
`组织: **${escapeMarkdown(organizationName)}**`,
`搜索: **${escapeMarkdown(result.query)}**`,
result.totalItems === 0
? "没有匹配的可绑定项目,请换一个关键词。"
: `共 **${result.totalItems}** 个匹配结果 · 第 **${result.page}/${result.totalPages}** 页`,
].join("\n");
}
const location = view.result.breadcrumb === "" ? "根目录" : view.result.breadcrumb;
return [
"这个飞书群还没有绑定项目。",
"",
`组织: **${escapeMarkdown(organizationName)}**`,
`当前位置: **${escapeMarkdown(location)}**`,
"可以进入 folder 浏览,或选择有管理权限的未绑定项目。",
].join("\n");
}
function appendFolderNavigation(elements: unknown[], organizationId: string, result: ProjectFolderPage): void {
const navigation: unknown[] = [];
if (result.folderId !== null) {
navigation.push(actionButton("⬆ 上一级", {
action: "browse_folder",
organization_id: organizationId,
...(result.parentFolderId !== null ? { folder_id: result.parentFolderId } : {}),
page: 1,
}));
}
for (const folder of result.childFolders) {
navigation.push(actionButton(`📁 ${buttonLabel(folder.name, 22)}`, {
action: "browse_folder",
organization_id: organizationId,
folder_id: folder.folderId,
page: 1,
}));
}
for (let index = 0; index < navigation.length; index += 5) {
elements.push({ tag: "action", actions: navigation.slice(index, index + 5) });
}
}
function appendProjectResults(
elements: unknown[],
organizationId: string,
projects: readonly ProjectDiscoveryPage["items"][number][],
): void {
for (const project of projects) {
const path = project.breadcrumb === "" ? "根目录" : project.breadcrumb;
elements.push({
tag: "markdown",
content: `**${escapeMarkdown(project.name)}**\n${escapeMarkdown(path)}`,
});
elements.push({
tag: "action",
actions: [actionButton("绑定这个项目", {
action: "bind_project",
organization_id: organizationId,
project_id: project.projectId,
})],
});
}
}
function appendPagination(elements: unknown[], organizationId: string, view: ProjectOnboardingView): void {
const result = projectPage(view);
if (result.totalPages <= 1) return;
const actions: unknown[] = [];
if (result.page > 1) actions.push(pageButton("上一页", organizationId, view, result.page - 1));
if (result.page < result.totalPages) actions.push(pageButton("下一页", organizationId, view, result.page + 1));
elements.push({ tag: "action", actions });
}
function appendCreation(
elements: unknown[],
organizationId: string,
canCreateProject: boolean,
view: ProjectOnboardingView,
): void {
if (!canCreateProject || view.mode !== "browse") return;
elements.push({
tag: "action",
actions: [actionButton(view.result.folderId === null ? "新建项目" : "在此 folder 新建项目", {
action: "create_project_from_chat",
organization_id: organizationId,
...(view.result.folderId !== null ? { folder_id: view.result.folderId } : {}),
}, "primary")],
});
}
function pageButton(label: string, organizationId: string, view: ProjectOnboardingView, page: number): unknown {
if (view.mode === "search") {
return actionButton(label, {
action: "search_page",
organization_id: organizationId,
search_query: view.result.query,
page,
});
}
return actionButton(label, {
action: "browse_folder",
organization_id: organizationId,
...(view.result.folderId !== null ? { folder_id: view.result.folderId } : {}),
page,
});
}
function projectPage(view: ProjectOnboardingView): ProjectDiscoveryPage {
if (view.mode === "search") return view.result;
return {
query: "",
page: view.result.page,
pageSize: view.result.pageSize,
totalItems: view.result.totalFolders + view.result.totalProjects,
totalPages: view.result.totalPages,
items: view.result.projects,
};
}
function actionButton(
label: string,
value: ProjectOnboardingActionValue,
type: "default" | "primary" = "default",
): unknown {
return {
tag: "button",
text: { tag: "plain_text", content: label },
type,
value: { project_onboarding: value },
};
}
function isAction(value: unknown): value is ProjectOnboardingActionValue["action"] {
return value === "create_project_from_chat" || value === "bind_project"
|| value === "browse_folder" || value === "search_page" || value === "rename_project";
}
function validPage(value: unknown): value is number {
return typeof value === "number" && Number.isSafeInteger(value) && value >= 1 && value <= 10_000;
}
function unwrapValue(value: unknown): unknown { function unwrapValue(value: unknown): unknown {
if (typeof value !== "string") return value; if (typeof value !== "string") return value;
try { try { return JSON.parse(value); } catch { return value; }
return JSON.parse(value);
} catch {
return value;
}
}
function buttonProjectLabel(project: OnboardingProjectOption): string {
const folderPrefix = project.folderName === undefined ? "" : `${project.folderName} / `;
const label = `${folderPrefix}${project.name}`;
return buttonLabel(label, 24);
} }
function buttonLabel(label: string, maxLength: number): string { function buttonLabel(label: string, maxLength: number): string {
+13
View File
@@ -99,6 +99,19 @@ export function createSlashCommandRegistry(deps: SlashCommandRegistryDeps): Read
}, },
}); });
add({
name: "project",
usage: "/project",
summary: "打开当前项目管理卡片,可修改项目名称。",
details: [
"要求操作者拥有当前项目的 MANAGE 权限。",
"实际卡片由飞书 trigger adapter 渲染,不创建 agent run。",
],
run: async ({ chatId, rt, sendOptions }) => {
await sendText(rt, chatId, "请在飞书项目群中使用 @bot /project 打开项目管理卡片。", sendOptions);
},
});
add({ add({
name: "resume", name: "resume",
usage: "/resume", usage: "/resume",
+181 -82
View File
@@ -58,16 +58,18 @@ import {
bindFeishuChatToProject, bindFeishuChatToProject,
createProjectFromFeishuChat, createProjectFromFeishuChat,
ensureOrganizationProjectSettings, ensureOrganizationProjectSettings,
renameProjectForActor,
} from "../projectOnboarding.js"; } from "../projectOnboarding.js";
import { import {
buildProjectManagementCard,
buildProjectOnboardingResolvedCard, buildProjectOnboardingResolvedCard,
buildUnboundChatOnboardingCard, buildUnboundChatOnboardingCard,
projectOnboardingActionFromValue, projectOnboardingActionFromValue,
type OnboardingFolderOption,
type OnboardingProjectOption,
type ProjectOnboardingActionValue, type ProjectOnboardingActionValue,
type ProjectOnboardingView,
} from "./projectOnboardingCard.js"; } from "./projectOnboardingCard.js";
import { SiloFixedWindowRateLimiter } from "../deployment/siloRateLimit.js"; import { SiloFixedWindowRateLimiter } from "../deployment/siloRateLimit.js";
import { browseBindableFolder, discoverBindableProjects } from "../projectDiscovery.js";
export { ApprovalManager } from "./approval.js"; export { ApprovalManager } from "./approval.js";
export type { ApprovalResult, PendingApproval } from "./approval.js"; export type { ApprovalResult, PendingApproval } from "./approval.js";
@@ -754,21 +756,91 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
} }
try { try {
const organization = await resolveSingleActiveOrganizationForFeishuUser(operatorOpenId);
if (organization.status !== "ok") throw new Error(organization.message);
if (organization.organizationId !== action.organization_id) {
throw new Error("project onboarding organization mismatch");
}
if (action.action === "rename_project") {
const projectId = action.project_id;
if (projectId === undefined) throw new Error("rename_project action requires project_id");
const binding = await deps.prisma.projectGroupBinding.findFirst({
where: { chatId, projectId, archivedAt: null },
select: { id: true },
});
if (binding === null) throw new Error("project rename requires the project to be bound to this chat");
const submittedName = event.action.form_value?.["project_name"];
if (typeof submittedName !== "string") throw new Error("project rename form is missing project_name");
const renamed = await renameProjectForActor(deps.prisma, {
organizationId: organization.organizationId,
projectId,
actorFeishuOpenId: operatorOpenId,
name: submittedName.slice(0, 100),
});
if (messageId === undefined) throw new Error("project rename requires message id");
await patchCard(rt, messageId, buildProjectManagementCard({
organizationId: organization.organizationId,
projectId: renamed.projectId,
projectName: renamed.name,
title: "项目名称已更新",
}));
deps.logger.info({ chatId, projectId, operatorOpenId }, "project onboarding: project renamed");
return;
}
if (action.action === "browse_folder" || action.action === "search_page") {
const activeBinding = await deps.prisma.projectGroupBinding.findFirst({
where: { chatId, archivedAt: null },
select: { projectId: true },
});
if (activeBinding !== null) throw new Error(`chat is already bound to project ${activeBinding.projectId}`);
const settings = await ensureOrganizationProjectSettings(deps.prisma, organization.organizationId);
const view = action.action === "search_page"
? await searchOnboardingView({
organizationId: organization.organizationId,
actorFeishuOpenId: operatorOpenId,
isOrgAdmin: isOrgAdminRole(organization.role),
query: action.search_query ?? "",
page: action.page ?? 1,
})
: await browseOnboardingView({
organizationId: organization.organizationId,
actorFeishuOpenId: operatorOpenId,
isOrgAdmin: isOrgAdminRole(organization.role),
folderId: action.folder_id ?? null,
page: action.page ?? 1,
});
if (messageId === undefined) throw new Error("project onboarding navigation requires message id");
await patchCard(rt, messageId, buildUnboundChatOnboardingCard({
organizationId: organization.organizationId,
organizationName: organization.organizationName,
canCreateProject: settings.membersCanCreateProjects || isOrgAdminRole(organization.role),
view,
}));
deps.logger.info(
{ chatId, operatorOpenId, action: action.action, folderId: action.folder_id, page: action.page },
"project onboarding: navigated discovery card",
);
return;
}
if (action.action === "create_project_from_chat") { if (action.action === "create_project_from_chat") {
const name = defaultProjectNameForChat(chatId); const name = defaultProjectNameForChat(chatId);
const project = await createProjectFromFeishuChat(deps.prisma, { const project = await createProjectFromFeishuChat(deps.prisma, {
organizationId: action.organization_id, organizationId: organization.organizationId,
actorFeishuOpenId: operatorOpenId, actorFeishuOpenId: operatorOpenId,
chatId, chatId,
name, name,
workspaceRoot: projectWorkspaceRoot, workspaceRoot: projectWorkspaceRoot,
folderId: action.folder_id, folderId: action.folder_id,
}); });
await resolveProjectOnboardingCard(rt, messageId, { if (messageId !== undefined) {
title: "已创建并绑定项目", await patchCard(rt, messageId, buildProjectManagementCard({
body: `项目 **${escapeCardMarkdown(name)}** 已绑定到本群。后续 @bot 会进入这个项目的 session。`, organizationId: organization.organizationId,
template: "green", projectId: project.projectId,
}); projectName: name,
title: "已创建并绑定项目",
}));
}
deps.logger.info({ chatId, projectId: project.projectId, operatorOpenId }, "project onboarding: created project from chat"); deps.logger.info({ chatId, projectId: project.projectId, operatorOpenId }, "project onboarding: created project from chat");
return; return;
} }
@@ -777,16 +849,31 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
if (projectId === undefined) { if (projectId === undefined) {
throw new Error("bind_project action requires project_id"); throw new Error("bind_project action requires project_id");
} }
const target = await deps.prisma.project.findUnique({
where: { id: projectId },
select: { organizationId: true },
});
if (target === null) throw new Error(`project not found: ${projectId}`);
if (target.organizationId !== organization.organizationId) {
throw new Error("project onboarding project organization mismatch");
}
const project = await bindFeishuChatToProject(deps.prisma, { const project = await bindFeishuChatToProject(deps.prisma, {
projectId, projectId,
actorFeishuOpenId: operatorOpenId, actorFeishuOpenId: operatorOpenId,
chatId, chatId,
}); });
await resolveProjectOnboardingCard(rt, messageId, { if (messageId !== undefined) {
title: "已绑定项目", const boundProject = await deps.prisma.project.findUniqueOrThrow({
body: `本群已绑定到项目 \`${project.projectId}\`。后续 @bot 会进入这个项目的 session。`, where: { id: project.projectId },
template: "green", select: { name: true },
}); });
await patchCard(rt, messageId, buildProjectManagementCard({
organizationId: organization.organizationId,
projectId: project.projectId,
projectName: boundProject.name,
title: "已绑定项目",
}));
}
deps.logger.info({ chatId, projectId: project.projectId, operatorOpenId }, "project onboarding: bound existing project"); deps.logger.info({ chatId, projectId: project.projectId, operatorOpenId }, "project onboarding: bound existing project");
} catch (e) { } catch (e) {
const reason = e instanceof Error ? e.message : String(e); const reason = e instanceof Error ? e.message : String(e);
@@ -1011,6 +1098,39 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
} }
if (invocation !== null) { if (invocation !== null) {
if (invocation.name === "project") {
if (invocation.args.length > 0) {
await sendText(rt, chatId, "用法: /project(打开当前项目管理卡片)", sendOptionsForTriggerMessage(msg));
return;
}
const project = await deps.prisma.project.findUniqueOrThrow({
where: { id: projectId },
select: { id: true, name: true, organizationId: true },
});
const organization = await resolveSingleActiveOrganizationForFeishuUser(senderOpenId);
if (organization.status !== "ok" || organization.organizationId !== project.organizationId) {
await sendText(rt, chatId, "当前身份不属于该项目组织。", sendOptionsForTriggerMessage(msg));
return;
}
if (!isOrgAdminRole(organization.role)) {
const decision = await authorizer.can({
actor,
action: "collaborator.manage",
resource: { type: "PROJECT", id: projectId },
});
if (!decision.allowed) {
await sendText(rt, chatId, "你没有管理当前项目的权限。", sendOptionsForTriggerMessage(msg));
return;
}
}
await sendCard(rt, chatId, buildProjectManagementCard({
organizationId: project.organizationId,
projectId: project.id,
projectName: project.name,
}), sendOptionsForTriggerMessage(msg));
deps.logger.info({ chatId, projectId, senderOpenId }, "feishu project management card opened");
return;
}
const slashCommand = slashCommands.get(invocation.name); const slashCommand = slashCommands.get(invocation.name);
if (slashCommand !== undefined) { if (slashCommand !== undefined) {
try { try {
@@ -1071,13 +1191,21 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
const settings = await ensureOrganizationProjectSettings(deps.prisma, organization.organizationId); const settings = await ensureOrganizationProjectSettings(deps.prisma, organization.organizationId);
const canCreateProject = settings.membersCanCreateProjects || isOrgAdminRole(organization.role); const canCreateProject = settings.membersCanCreateProjects || isOrgAdminRole(organization.role);
const searchQuery = (extractPrompt(msg) ?? "").trim().slice(0, 100); const searchQuery = (extractPrompt(msg) ?? "").trim().slice(0, 100);
const projects = await listBindableProjectsForActor({ const view = searchQuery === ""
organizationId: organization.organizationId, ? await browseOnboardingView({
actorFeishuOpenId: senderOpenId, organizationId: organization.organizationId,
isOrgAdmin: isOrgAdminRole(organization.role), actorFeishuOpenId: senderOpenId,
searchQuery, isOrgAdmin: isOrgAdminRole(organization.role),
}); folderId: null,
const folders = await listCreatableRootFolders(organization.organizationId); page: 1,
})
: await searchOnboardingView({
organizationId: organization.organizationId,
actorFeishuOpenId: senderOpenId,
isOrgAdmin: isOrgAdminRole(organization.role),
query: searchQuery,
page: 1,
});
await sendCard( await sendCard(
rt, rt,
@@ -1085,10 +1213,8 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
buildUnboundChatOnboardingCard({ buildUnboundChatOnboardingCard({
organizationId: organization.organizationId, organizationId: organization.organizationId,
organizationName: organization.organizationName, organizationName: organization.organizationName,
folders,
projects,
canCreateProject, canCreateProject,
searchQuery, view,
}), }),
sendOptionsForTriggerMessage(msg), sendOptionsForTriggerMessage(msg),
); );
@@ -1181,67 +1307,44 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
}; };
} }
async function listBindableProjectsForActor(input: { async function searchOnboardingView(input: {
readonly organizationId: string; readonly organizationId: string;
readonly actorFeishuOpenId: string; readonly actorFeishuOpenId: string;
readonly isOrgAdmin: boolean; readonly isOrgAdmin: boolean;
readonly searchQuery: string; readonly query: string;
}): Promise<readonly OnboardingProjectOption[]> { readonly page: number;
const allowed: OnboardingProjectOption[] = []; }): Promise<ProjectOnboardingView> {
let cursor: string | undefined; return {
while (allowed.length < 5) { mode: "search",
const candidates = await deps.prisma.project.findMany({ result: await discoverBindableProjects({
where: { prisma: deps.prisma,
organizationId: input.organizationId, organizationId: input.organizationId,
archivedAt: null, actorFeishuOpenId: input.actorFeishuOpenId,
groupBindings: { none: { archivedAt: null } }, isOrgAdmin: input.isOrgAdmin,
...(input.searchQuery === "" ? {} : { query: input.query,
OR: [ page: input.page,
{ 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 }),
});
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;
} }
async function listCreatableRootFolders(organizationId: string): Promise<readonly OnboardingFolderOption[]> { async function browseOnboardingView(input: {
const folders = await deps.prisma.folder.findMany({ readonly organizationId: string;
where: { organizationId, parentId: null, archivedAt: null }, readonly actorFeishuOpenId: string;
select: { id: true, name: true }, readonly isOrgAdmin: boolean;
orderBy: [{ sortKey: "asc" }, { name: "asc" }], readonly folderId: string | null;
take: 3, readonly page: number;
}); }): Promise<ProjectOnboardingView> {
return folders.map((folder) => ({ folderId: folder.id, name: folder.name })); return {
mode: "browse",
result: await browseBindableFolder({
prisma: deps.prisma,
organizationId: input.organizationId,
actorFeishuOpenId: input.actorFeishuOpenId,
isOrgAdmin: input.isOrgAdmin,
folderId: input.folderId,
page: input.page,
}),
};
} }
return Object.assign(onMessage, { onCardAction, approvalManager }); return Object.assign(onMessage, { onCardAction, approvalManager });
@@ -1505,10 +1608,6 @@ function shortId(value: string): string {
return value.length <= 8 ? value : value.slice(-8); return value.length <= 8 ? value : value.slice(-8);
} }
function escapeCardMarkdown(value: string): string {
return value.replace(/([`*_{}[\]<>])/g, "\\$1");
}
/** Lark text-message content: `{"text":"@_user_1 do something"}`. */ /** Lark text-message content: `{"text":"@_user_1 do something"}`. */
const TextMessageContentSchema = z.object({ text: z.string() }); const TextMessageContentSchema = z.object({ text: z.string() });
+18 -3
View File
@@ -4,7 +4,7 @@
* Folders are transparent navigation nodes (not ACL resources). Project grants * Folders are transparent navigation nodes (not ACL resources). Project grants
* stay on PROJECT. Archive folder refuses when active children/projects remain. * stay on PROJECT. Archive folder refuses when active children/projects remain.
*/ */
import type { Prisma, PrismaClient } from "@prisma/client"; import { Prisma, type PrismaClient } from "@prisma/client";
import { import {
archiveFeishuChatBinding, archiveFeishuChatBinding,
createFolder, createFolder,
@@ -123,11 +123,25 @@ export async function renameFolder(
return prisma.$transaction(async (tx) => { return prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, input.organizationId); await lockActiveOrganization(tx, input.organizationId);
const folder = await requireActiveFolder(tx, input.folderId, input.organizationId); const folder = await requireActiveFolder(tx, input.folderId, input.organizationId);
if (folder.kind === "SYSTEM_INBOX" && (input.name !== undefined || input.parentId !== undefined)) {
throw new Error("system Inbox cannot be renamed or moved");
}
if (input.parentId !== undefined && input.parentId !== null) { if (input.parentId !== undefined && input.parentId !== null) {
if (input.parentId === folder.id) { if (input.parentId === folder.id) {
throw new Error("folder cannot be its own parent"); throw new Error("folder cannot be its own parent");
} }
await requireActiveFolder(tx, input.parentId, input.organizationId); await requireActiveFolder(tx, input.parentId, input.organizationId);
const descendant = await tx.$queryRaw<Array<{ found: boolean }>>(Prisma.sql`
WITH RECURSIVE descendants AS (
SELECT "id" FROM "Folder" WHERE "parentId" = ${folder.id} AND "archivedAt" IS NULL
UNION ALL
SELECT child."id" FROM "Folder" child
JOIN descendants parent ON child."parentId" = parent."id"
WHERE child."archivedAt" IS NULL
)
SELECT EXISTS(SELECT 1 FROM descendants WHERE "id" = ${input.parentId}) AS found
`);
if (descendant[0]?.found === true) throw new Error("folder cannot be moved below its descendant");
} }
const name = const name =
input.name !== undefined ? requireNonEmpty(input.name, "folder name") : undefined; input.name !== undefined ? requireNonEmpty(input.name, "folder name") : undefined;
@@ -152,6 +166,7 @@ export async function archiveFolder(
return prisma.$transaction(async (tx) => { return prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, input.organizationId); await lockActiveOrganization(tx, input.organizationId);
const folder = await requireActiveFolder(tx, input.folderId, input.organizationId); const folder = await requireActiveFolder(tx, input.folderId, input.organizationId);
if (folder.kind === "SYSTEM_INBOX") throw new Error("system Inbox cannot be archived");
const childFolders = await tx.folder.count({ const childFolders = await tx.folder.count({
where: { parentId: folder.id, archivedAt: null }, where: { parentId: folder.id, archivedAt: null },
}); });
@@ -324,10 +339,10 @@ async function requireActiveFolder(
prisma: PrismaClient | Prisma.TransactionClient, prisma: PrismaClient | Prisma.TransactionClient,
folderId: string, folderId: string,
organizationId: string, organizationId: string,
): Promise<{ readonly id: string; readonly organizationId: string }> { ): Promise<{ readonly id: string; readonly organizationId: string; readonly kind: "REGULAR" | "SYSTEM_INBOX" }> {
const folder = await prisma.folder.findUnique({ const folder = await prisma.folder.findUnique({
where: { id: folderId }, where: { id: folderId },
select: { id: true, organizationId: true, archivedAt: true }, select: { id: true, organizationId: true, kind: true, archivedAt: true },
}); });
if (folder === null || folder.archivedAt !== null || folder.organizationId !== organizationId) { if (folder === null || folder.archivedAt !== null || folder.organizationId !== organizationId) {
throw new Error(`active folder not found: ${folderId}`); throw new Error(`active folder not found: ${folderId}`);
+315
View File
@@ -0,0 +1,315 @@
import { Prisma, type PrismaClient } from "@prisma/client";
import { PrismaPrincipalResolver } from "./permission.js";
const DEFAULT_PAGE_SIZE = 8;
const MAX_PAGE_SIZE = 10;
export interface ProjectDiscoveryItem {
readonly projectId: string;
readonly name: string;
readonly breadcrumb: string;
}
export interface ProjectDiscoveryPage {
readonly query: string;
readonly page: number;
readonly pageSize: number;
readonly totalItems: number;
readonly totalPages: number;
readonly items: readonly ProjectDiscoveryItem[];
}
export interface ProjectFolderPage {
readonly folderId: string | null;
readonly parentFolderId: string | null;
readonly breadcrumb: string;
readonly page: number;
readonly pageSize: number;
readonly totalPages: number;
readonly totalFolders: number;
readonly totalProjects: number;
readonly childFolders: readonly { readonly folderId: string; readonly name: string }[];
readonly projects: readonly ProjectDiscoveryItem[];
}
interface DiscoveryCandidate extends ProjectDiscoveryItem {
readonly updatedAt: Date;
}
export function normalizeProjectSearchQuery(value: string): string {
return value.normalize("NFKC").toLocaleLowerCase("und").replace(/[\s_.:/\\-]+/gu, "");
}
export async function discoverBindableProjects(input: {
readonly prisma: PrismaClient;
readonly organizationId: string;
readonly actorFeishuOpenId: string;
readonly isOrgAdmin: boolean;
readonly query: string;
readonly page?: number | undefined;
readonly pageSize?: number | undefined;
}): Promise<ProjectDiscoveryPage> {
const query = input.query.trim().slice(0, 100);
const normalizedQuery = normalizeProjectSearchQuery(query);
const manageableIds = input.isOrgAdmin
? null
: await listManageableProjectIds(input.prisma, input.organizationId, input.actorFeishuOpenId);
const pageSize = normalizedPageSize(input.pageSize);
const totalItems = await countSearchCandidates(
input.prisma,
input.organizationId,
normalizedQuery,
searchTokens(query),
manageableIds,
);
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
const page = normalizedPage(input.page, totalPages);
const items = await searchCandidates(
input.prisma,
input.organizationId,
normalizedQuery,
searchTokens(query),
manageableIds,
page,
pageSize,
);
return { query, page, pageSize, totalItems, totalPages, items };
}
export async function browseBindableFolder(input: {
readonly prisma: PrismaClient;
readonly organizationId: string;
readonly actorFeishuOpenId: string;
readonly isOrgAdmin: boolean;
readonly folderId: string | null;
readonly page?: number | undefined;
readonly pageSize?: number | undefined;
}): Promise<ProjectFolderPage> {
const folder = input.folderId === null
? null
: await input.prisma.folder.findFirst({
where: { id: input.folderId, organizationId: input.organizationId, archivedAt: null },
select: { id: true, parentId: true },
});
if (input.folderId !== null && folder === null) throw new Error(`folder not found: ${input.folderId}`);
const manageableIds = input.isOrgAdmin
? null
: await listManageableProjectIds(input.prisma, input.organizationId, input.actorFeishuOpenId);
const inbox = input.folderId === null
? await input.prisma.folder.findFirst({
where: { organizationId: input.organizationId, kind: "SYSTEM_INBOX", archivedAt: null },
select: { id: true },
})
: null;
const projectWhere: Prisma.ProjectSearchDocumentWhereInput = {
organizationId: input.organizationId,
...(manageableIds === null ? {} : { projectId: { in: [...manageableIds] } }),
project: {
...(input.folderId === null
? { OR: [{ folderId: null }, ...(inbox === null ? [] : [{ folderId: inbox.id }])] }
: { folderId: input.folderId }),
archivedAt: null,
groupBindings: { none: { archivedAt: null } },
},
};
const folderWhere: Prisma.FolderWhereInput = {
organizationId: input.organizationId,
parentId: input.folderId,
archivedAt: null,
...(input.folderId === null ? { kind: { not: "SYSTEM_INBOX" } } : {}),
};
const [breadcrumb, totalFolders, totalProjects] = await Promise.all([
input.folderId === null ? Promise.resolve("") : folderBreadcrumb(input.prisma, input.folderId),
input.prisma.folder.count({ where: folderWhere }),
input.prisma.projectSearchDocument.count({ where: projectWhere }),
]);
const pageSize = normalizedPageSize(input.pageSize);
const totalPages = Math.max(1, Math.ceil((totalFolders + totalProjects) / pageSize));
const page = normalizedPage(input.page, totalPages);
const offset = (page - 1) * pageSize;
const folderSkip = Math.min(offset, totalFolders);
const folderTake = Math.min(pageSize, Math.max(0, totalFolders - folderSkip));
const projectSkip = Math.max(0, offset - totalFolders);
const projectTake = pageSize - folderTake;
const [childFolders, candidates] = await Promise.all([
folderTake === 0 ? Promise.resolve([]) : input.prisma.folder.findMany({
where: folderWhere,
select: { id: true, name: true },
orderBy: [{ sortKey: "asc" }, { name: "asc" }, { id: "asc" }],
skip: folderSkip,
take: folderTake,
}),
projectTake === 0 ? Promise.resolve([]) : input.prisma.projectSearchDocument.findMany({
where: projectWhere,
select: { projectId: true, name: true, breadcrumb: true },
orderBy: [{ name: "asc" }, { projectId: "asc" }],
skip: projectSkip,
take: projectTake,
}),
]);
return {
folderId: input.folderId,
parentFolderId: folder?.parentId ?? null,
breadcrumb,
page,
pageSize,
totalPages,
totalFolders,
totalProjects,
childFolders: childFolders.map((child) => ({ folderId: child.id, name: child.name })),
projects: candidates,
};
}
async function searchCandidates(
prisma: PrismaClient,
organizationId: string,
normalizedQuery: string,
tokens: readonly string[],
manageableIds: readonly string[] | null,
page: number,
pageSize: number,
): Promise<readonly DiscoveryCandidate[]> {
const access = accessPredicate(manageableIds);
const offset = (page - 1) * pageSize;
if (normalizedQuery === "") {
return prisma.$queryRaw<DiscoveryCandidate[]>(Prisma.sql`
SELECT d."projectId", d."name", d."breadcrumb", p."updatedAt"
FROM "ProjectSearchDocument" d
JOIN "Project" p ON p."id" = d."projectId"
WHERE d."organizationId" = ${organizationId}
AND p."archivedAt" IS NULL
AND NOT EXISTS (
SELECT 1 FROM "ProjectGroupBinding" b
WHERE b."projectId" = p."id" AND b."archivedAt" IS NULL
)
${access}
ORDER BY p."updatedAt" DESC, p."id" DESC
LIMIT ${pageSize} OFFSET ${offset}
`);
}
const matches = searchPredicate(normalizedQuery, tokens);
return prisma.$queryRaw<DiscoveryCandidate[]>(Prisma.sql`
SELECT d."projectId", d."name", d."breadcrumb", p."updatedAt"
FROM "ProjectSearchDocument" d
JOIN "Project" p ON p."id" = d."projectId"
WHERE d."organizationId" = ${organizationId}
AND p."archivedAt" IS NULL
AND NOT EXISTS (
SELECT 1 FROM "ProjectGroupBinding" b
WHERE b."projectId" = p."id" AND b."archivedAt" IS NULL
)
${access}
AND ${matches}
ORDER BY
CASE
WHEN d."normalizedCode" = ${normalizedQuery} THEN 0
WHEN d."normalizedName" = ${normalizedQuery} THEN 1
WHEN strpos(d."normalizedCode", ${normalizedQuery}) = 1 THEN 2
WHEN strpos(d."normalizedName", ${normalizedQuery}) = 1 THEN 3
WHEN strpos(d."normalizedName", ${normalizedQuery}) > 0 THEN 4
WHEN strpos(d."normalizedBreadcrumb", ${normalizedQuery}) > 0 THEN 5
ELSE 6
END,
similarity(d."normalizedName", ${normalizedQuery}) DESC,
p."updatedAt" DESC,
p."id" ASC
LIMIT ${pageSize} OFFSET ${offset}
`);
}
async function countSearchCandidates(
prisma: PrismaClient,
organizationId: string,
normalizedQuery: string,
tokens: readonly string[],
manageableIds: readonly string[] | null,
): Promise<number> {
const access = accessPredicate(manageableIds);
const queryPredicate = normalizedQuery === "" ? Prisma.empty : Prisma.sql`AND ${searchPredicate(normalizedQuery, tokens)}`;
const rows = await prisma.$queryRaw<Array<{ count: number }>>(Prisma.sql`
SELECT count(*)::int AS count
FROM "ProjectSearchDocument" d
JOIN "Project" p ON p."id" = d."projectId"
WHERE d."organizationId" = ${organizationId}
AND p."archivedAt" IS NULL
AND NOT EXISTS (
SELECT 1 FROM "ProjectGroupBinding" b
WHERE b."projectId" = p."id" AND b."archivedAt" IS NULL
)
${access}
${queryPredicate}
`);
return rows[0]?.count ?? 0;
}
async function listManageableProjectIds(
prisma: PrismaClient,
organizationId: string,
actorFeishuOpenId: string,
): Promise<readonly string[]> {
const resolution = await new PrismaPrincipalResolver(prisma).resolveActor(
{ feishuOpenId: actorFeishuOpenId },
{ organizationId },
);
const grants = await prisma.permissionGrant.findMany({
where: {
resourceType: "PROJECT",
role: "MANAGE",
revokedAt: null,
OR: resolution.principals.map((principal) => ({
principalType: principal.type,
principalId: principal.id,
})),
},
select: { resourceId: true },
});
const projectIds = [...new Set(grants.map((grant) => grant.resourceId))];
if (projectIds.length === 0) return [];
const projects = await prisma.project.findMany({
where: { id: { in: projectIds }, organizationId, archivedAt: null },
select: { id: true },
});
return projects.map((project) => project.id);
}
function searchPredicate(normalizedQuery: string, tokens: readonly string[]): Prisma.Sql {
const tokenMatch = tokens.length <= 1
? Prisma.sql`FALSE`
: Prisma.sql`(${Prisma.join(tokens.map((token) => Prisma.sql`strpos(d."normalizedSearchText", ${token}) > 0`), " AND ")})`;
return Prisma.sql`(
strpos(d."normalizedCode", ${normalizedQuery}) > 0
OR strpos(d."normalizedName", ${normalizedQuery}) > 0
OR strpos(d."normalizedBreadcrumb", ${normalizedQuery}) > 0
OR d."normalizedName" % ${normalizedQuery}
OR ${tokenMatch}
)`;
}
function accessPredicate(manageableIds: readonly string[] | null): Prisma.Sql {
if (manageableIds === null) return Prisma.empty;
if (manageableIds.length === 0) return Prisma.sql`AND FALSE`;
return Prisma.sql`AND p."id" IN (${Prisma.join(manageableIds)})`;
}
function searchTokens(query: string): readonly string[] {
return query.normalize("NFKC").trim().split(/\s+/u).map(normalizeProjectSearchQuery).filter(Boolean);
}
function normalizedPageSize(value = DEFAULT_PAGE_SIZE): number {
return Math.min(MAX_PAGE_SIZE, Math.max(1, Math.trunc(value)));
}
function normalizedPage(value: number | undefined, totalPages: number): number {
return Math.min(totalPages, Math.max(1, Math.trunc(value ?? 1)));
}
async function folderBreadcrumb(prisma: PrismaClient, folderId: string): Promise<string> {
const rows = await prisma.$queryRaw<Array<{ breadcrumb: string }>>(Prisma.sql`
SELECT cph_folder_breadcrumb(${folderId}) AS breadcrumb
`);
const breadcrumb = rows[0]?.breadcrumb;
if (breadcrumb === undefined) throw new Error(`failed to resolve folder breadcrumb: ${folderId}`);
return breadcrumb;
}
+47 -2
View File
@@ -53,6 +53,13 @@ export interface ArchiveFeishuChatBindingInput {
readonly actorFeishuOpenId: string; readonly actorFeishuOpenId: string;
} }
export interface RenameProjectForActorInput {
readonly organizationId: string;
readonly projectId: string;
readonly actorFeishuOpenId: string;
readonly name: string;
}
export interface CreateFolderInput { export interface CreateFolderInput {
readonly organizationId: string; readonly organizationId: string;
readonly name: string; readonly name: string;
@@ -131,6 +138,44 @@ export async function moveProjectToFolder(
}); });
} }
export async function renameProjectForActor(
prisma: PrismaClient,
input: RenameProjectForActorInput,
): Promise<{ readonly projectId: string; readonly name: string }> {
const name = requireNonEmpty(input.name, "project name");
const project = await prisma.project.findUnique({
where: { id: input.projectId },
select: { id: true, organizationId: true, name: true },
});
if (project === null) throw new Error(`project not found: ${input.projectId}`);
if (project.organizationId !== input.organizationId) {
throw new Error(`project ${project.id} does not belong to organization ${input.organizationId}`);
}
const actor = await requireProjectManager(prisma, project.id, project.organizationId, input.actorFeishuOpenId);
const updated = await prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, project.organizationId);
const current = await tx.project.findUniqueOrThrow({
where: { id: project.id },
select: { name: true },
});
const renamed = await tx.project.update({
where: { id: project.id },
data: { name },
select: { id: true, name: true },
});
await tx.auditEntry.create({
data: {
projectId: project.id,
actorUserId: actor.userId,
action: "project.renamed",
metadata: { oldName: current.name, newName: name, actorVia: actor.via },
},
});
return renamed;
});
return { projectId: updated.id, name: updated.name };
}
export async function createProjectFromOrgAdmin( export async function createProjectFromOrgAdmin(
prisma: PrismaClient, prisma: PrismaClient,
input: CreateOrgAdminProjectInput, input: CreateOrgAdminProjectInput,
@@ -494,12 +539,12 @@ async function ensureOrganizationProjectSettingsTx(
async function ensureInboxFolder(prisma: Prisma.TransactionClient, organizationId: string): Promise<{ readonly id: string }> { async function ensureInboxFolder(prisma: Prisma.TransactionClient, organizationId: string): Promise<{ readonly id: string }> {
const existing = await prisma.folder.findFirst({ const existing = await prisma.folder.findFirst({
where: { organizationId, parentId: null, name: "Inbox", archivedAt: null }, where: { organizationId, kind: "SYSTEM_INBOX", archivedAt: null },
select: { id: true }, select: { id: true },
}); });
if (existing !== null) return existing; if (existing !== null) return existing;
return prisma.folder.create({ return prisma.folder.create({
data: { organizationId, name: "Inbox", sortKey: "000000" }, data: { organizationId, name: "Inbox", kind: "SYSTEM_INBOX", sortKey: "000000" },
select: { id: true }, select: { id: true },
}); });
} }
@@ -284,4 +284,59 @@ describe("admin explorer API", () => {
}); });
}, },
); );
it("rejects moving a folder below one of its descendants", async () => {
const parent = await prisma.folder.create({
data: { id: "folder-cycle-parent", organizationId: DEFAULT_ORG_ID, name: "Parent" },
});
const child = await prisma.folder.create({
data: { id: "folder-cycle-child", organizationId: DEFAULT_ORG_ID, parentId: parent.id, name: "Child" },
});
await expect(renameFolder(prisma, {
organizationId: DEFAULT_ORG_ID,
folderId: parent.id,
parentId: child.id,
})).rejects.toThrow("folder cannot be moved below its descendant");
});
it("keeps the system Inbox identity immutable", async () => {
const inbox = await prisma.folder.findFirstOrThrow({
where: { organizationId: DEFAULT_ORG_ID, kind: "SYSTEM_INBOX" },
});
await expect(renameFolder(prisma, {
organizationId: DEFAULT_ORG_ID,
folderId: inbox.id,
name: "课程",
})).rejects.toThrow("system Inbox cannot be renamed or moved");
await expect(renameFolder(prisma, {
organizationId: DEFAULT_ORG_ID,
folderId: inbox.id,
parentId: null,
})).rejects.toThrow("system Inbox cannot be renamed or moved");
await expect(archiveFolder(prisma, {
organizationId: DEFAULT_ORG_ID,
folderId: inbox.id,
})).rejects.toThrow("system Inbox cannot be archived");
await expect(prisma.folder.update({
where: { id: inbox.id },
data: { kind: "REGULAR" },
})).rejects.toThrow("system Inbox identity cannot be changed");
await expect(prisma.folder.update({
where: { id: inbox.id },
data: { id: `${inbox.id}-moved` },
})).rejects.toThrow("system Inbox identity cannot be changed");
await expect(prisma.folder.delete({ where: { id: inbox.id } }))
.rejects.toThrow("system Inbox cannot be deleted while its organization exists");
await expect(prisma.folder.create({
data: {
organizationId: DEFAULT_ORG_ID,
name: "Malformed",
kind: "SYSTEM_INBOX",
parentId: inbox.id,
},
})).rejects.toThrow("system Inbox must be an active root folder named Inbox");
});
}); });
+16 -36
View File
@@ -34,41 +34,20 @@ export const prisma = new PrismaClient({
/** Truncate all tables before each test for isolation. */ /** Truncate all tables before each test for isolation. */
export async function resetDb(): Promise<void> { export async function resetDb(): Promise<void> {
const tables = [ // User and Organization are the aggregate roots for all domain rows; their
"OrganizationAgentRoleSkill", // declared FK cascades clear projects, search documents, permissions,
"OrganizationAgentRole", // sessions and connections without repeatedly truncating pg_trgm indexes.
"OrganizationAgentSkill", // Event receipts and global audit rows are independent roots.
"FeishuEventReceipt", await prisma.$transaction([
"FeishuUserIdentity", prisma.feishuEventReceipt.deleteMany(),
"FeishuApplicationCredentialVersion", prisma.auditEntry.deleteMany(),
"OrganizationFeishuApplicationConnection", // Permission resource ids are intentionally polymorphic strings, so these
"ProviderCredentialVersion", // two tables have no FK to Project and must be cleared explicitly.
"OrganizationProviderConnection", prisma.permissionGrant.deleteMany(),
"AgentFileChange", prisma.permissionSettings.deleteMany(),
"AgentMessage", prisma.user.deleteMany(),
"AuditEntry", prisma.organization.deleteMany(),
"PermissionSettings", ]);
"PermissionGrant",
"RoleTriggerGrant",
"ExternalPrincipalMembership",
"ExternalDirectoryConnection",
"TeamExternalBinding",
"TeamMembership",
"Team",
"ProjectAgentLock",
"AgentRun",
"AgentSession",
"ProjectGroupBinding",
"Folder",
"OrganizationProjectSettings",
"OrganizationMembership",
"PlatformRoleAssignment",
"User",
"Project",
"Organization",
];
// Truncate with CASCADE to wipe dependent rows in one shot.
await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${tables.map((t) => `"${t}"`).join(", ")} RESTART IDENTITY CASCADE`);
await seedTestOrganization(); await seedTestOrganization();
} }
@@ -91,7 +70,7 @@ export async function seedTestOrganization(
create: { organizationId: id, membersCanCreateProjects: true }, create: { organizationId: id, membersCanCreateProjects: true },
}); });
const inbox = await prisma.folder.findFirst({ const inbox = await prisma.folder.findFirst({
where: { organizationId: id, parentId: null, name: "Inbox", archivedAt: null }, where: { organizationId: id, kind: "SYSTEM_INBOX", archivedAt: null },
select: { id: true }, select: { id: true },
}); });
if (inbox === null) { if (inbox === null) {
@@ -100,6 +79,7 @@ export async function seedTestOrganization(
id: `folder_inbox_${id}`, id: `folder_inbox_${id}`,
organizationId: id, organizationId: id,
name: "Inbox", name: "Inbox",
kind: "SYSTEM_INBOX",
sortKey: "000000", sortKey: "000000",
}, },
}); });
@@ -0,0 +1,228 @@
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import {
browseBindableFolder,
discoverBindableProjects,
normalizeProjectSearchQuery,
} from "../../src/projectDiscovery.js";
import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
describe("project discovery", () => {
beforeEach(async () => {
await resetDb();
await seedUser("owner", "ou_owner", "OWNER");
await seedUser("member", "ou_member", "MEMBER");
});
afterAll(async () => prisma.$disconnect());
it("normalizes project codes across punctuation, width and case", () => {
expect(normalizeProjectSearchQuery(" TH-141 ")).toBe("th141");
expect(normalizeProjectSearchQuery("th_141")).toBe("th141");
});
it("searches normalized codes, Chinese names and complete folder breadcrumbs", async () => {
const root = await createFolder("root-legacy", null, "旧教学资产");
const subject = await createFolder("folder-physics", root.id, "物理竞赛教研");
const thermal = await createFolder("folder-thermal", subject.id, "TH_热学专题");
await createProject("project-th141", thermal.id, "TH-141_表面张力的严肃理论");
await createProject("project-fullwidth", thermal.id, "TH-142_全角编号");
await createProject("project-explicit-code", thermal.id, "表面课程", "PHY-001");
await createProject("project-other", thermal.id, "TH-211_理想气体静力学");
const byCode = await discover("TH141");
expect(byCode.items[0]).toMatchObject({
projectId: "project-th141",
breadcrumb: "旧教学资产 / 物理竞赛教研 / TH_热学专题",
});
await expect(discover("表面张力")).resolves.toMatchObject({
totalItems: 1,
items: [{ projectId: "project-th141" }],
});
await expect(discover("TH142")).resolves.toMatchObject({
items: [expect.objectContaining({ projectId: "project-fullwidth" })],
});
await expect(discover("PHY001")).resolves.toMatchObject({
items: [expect.objectContaining({ projectId: "project-explicit-code" })],
});
const byPath = await discover("物理竞赛教研");
expect(byPath.items.map((item) => item.projectId)).toEqual(expect.arrayContaining([
"project-th141",
"project-other",
]));
await expect(discover("物理竞赛 TH141")).resolves.toMatchObject({
items: [expect.objectContaining({ projectId: "project-th141" })],
});
await expect(discover("%")).resolves.toMatchObject({ totalItems: 0 });
});
it("refreshes descendant breadcrumbs after a folder rename", async () => {
const root = await createFolder("root-before", null, "旧目录");
const child = await createFolder("child", root.id, "子目录");
await createProject("project-refresh", child.id, "项目");
await prisma.folder.update({ where: { id: root.id }, data: { name: "新目录" } });
await expect(discover("新目录")).resolves.toMatchObject({
totalItems: 1,
items: [{ projectId: "project-refresh", breadcrumb: "新目录 / 子目录" }],
});
await expect(discover("旧目录")).resolves.toMatchObject({ totalItems: 0 });
});
it("filters authorization before counting and paginating", async () => {
for (let index = 0; index < 12; index += 1) {
await createProject(`project-${index}`, null, `TH-${index}`);
}
await prisma.permissionGrant.create({
data: {
resourceType: "PROJECT",
resourceId: "project-11",
principalType: "USER",
principalId: "ou_member",
role: "MANAGE",
},
});
const result = await discoverBindableProjects({
prisma,
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_member",
isOrgAdmin: false,
query: "TH",
page: 2,
pageSize: 5,
});
expect(result).toMatchObject({ page: 1, totalItems: 1, totalPages: 1 });
expect(result.items.map((item) => item.projectId)).toEqual(["project-11"]);
});
it("browses the preserved folder tree and paginates projects", async () => {
const root = await createFolder("root", null, "旧教学资产");
const child = await createFolder("child", root.id, "物理竞赛教研");
await createProject("project-root", root.id, "根目录项目");
const result = await browseBindableFolder({
prisma,
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_owner",
isOrgAdmin: true,
folderId: root.id,
});
expect(result.breadcrumb).toBe("旧教学资产");
expect(result.parentFolderId).toBeNull();
expect(result.childFolders).toEqual([{ folderId: child.id, name: "物理竞赛教研" }]);
expect(result.projects).toEqual([{
projectId: "project-root",
name: "根目录项目",
breadcrumb: "旧教学资产",
}]);
});
it("hides the system Inbox at root and presents its projects as unclassified", async () => {
const inbox = await prisma.folder.findFirstOrThrow({
where: { organizationId: DEFAULT_ORG_ID, kind: "SYSTEM_INBOX" },
});
const businessRoot = await createFolder("business-root", null, "业务目录");
const businessInbox = await createFolder("business-inbox", businessRoot.id, "Inbox");
await createProject("project-inbox", inbox.id, "新项目");
const result = await browseBindableFolder({
prisma,
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_owner",
isOrgAdmin: true,
folderId: null,
});
expect(result.childFolders.map((folder) => folder.folderId)).not.toContain(inbox.id);
expect(result.projects).toContainEqual({
projectId: "project-inbox",
name: "新项目",
breadcrumb: "未分类",
});
const businessResult = await browseBindableFolder({
prisma,
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_owner",
isOrgAdmin: true,
folderId: businessRoot.id,
});
expect(businessResult.childFolders).toContainEqual({ folderId: businessInbox.id, name: "Inbox" });
});
it("shares one card page budget across folders and projects", async () => {
const root = await createFolder("folder-page-root", null, "目录分页");
for (let index = 0; index < 6; index += 1) {
await createFolder(`folder-page-${index}`, root.id, `目录-${index}`);
await createProject(`project-page-${index}`, root.id, `项目-${index}`);
}
const first = await browseBindableFolder({
prisma,
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_owner",
isOrgAdmin: true,
folderId: root.id,
pageSize: 8,
});
const second = await browseBindableFolder({
prisma,
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_owner",
isOrgAdmin: true,
folderId: root.id,
page: 2,
pageSize: 8,
});
expect(first).toMatchObject({ page: 1, totalPages: 2, totalFolders: 6, totalProjects: 6 });
expect(first.childFolders.length + first.projects.length).toBe(8);
expect(second.childFolders.length + second.projects.length).toBe(4);
expect([...first.projects, ...second.projects].map((project) => project.projectId).sort()).toEqual(
Array.from({ length: 6 }, (_, index) => `project-page-${index}`),
);
});
});
async function seedUser(id: string, feishuOpenId: string, role: "OWNER" | "MEMBER"): Promise<void> {
await prisma.user.create({
data: {
id,
feishuOpenId,
displayName: id,
organizationMemberships: { create: { organizationId: DEFAULT_ORG_ID, role } },
},
});
}
async function createFolder(id: string, parentId: string | null, name: string) {
return prisma.folder.create({
data: { id, organizationId: DEFAULT_ORG_ID, parentId, name },
});
}
async function createProject(id: string, folderId: string | null, name: string, code?: string) {
return prisma.project.create({
data: {
id,
organizationId: DEFAULT_ORG_ID,
folderId,
...(code !== undefined ? { code } : {}),
name,
workspaceDir: `/tmp/${id}`,
},
});
}
function discover(query: string) {
return discoverBindableProjects({
prisma,
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_owner",
isOrgAdmin: true,
query,
});
}
+167 -8
View File
@@ -249,19 +249,18 @@ describe("trigger full lifecycle (integration)", () => {
projectWorkspaceRoot: await tempWorkspaceRoot(), projectWorkspaceRoot: await tempWorkspaceRoot(),
}); });
await trigger(makeEvent("chat-unbound-card", "@_user_1 开始项目", "ou_onboard_card"), rt); await trigger(makeEvent("chat-unbound-card", "@_user_1", "ou_onboard_card"), rt);
expect(rt.sentCards).toHaveLength(1); expect(rt.sentCards).toHaveLength(1);
expect(rt.sentTexts.at(-1)).toContain("这个飞书群还没有绑定项目"); expect(rt.sentTexts.at(-1)).toContain("这个飞书群还没有绑定项目");
const values = cardActionValues(rt.sentCards[0]); const values = cardActionValues(rt.sentCards[0]);
expect(values).toEqual(expect.arrayContaining([ expect(values).toEqual(expect.arrayContaining([
expect.objectContaining({ {
project_onboarding: expect.objectContaining({ project_onboarding: {
action: "create_project_from_chat", action: "create_project_from_chat",
organization_id: DEFAULT_ORG_ID, organization_id: DEFAULT_ORG_ID,
folder_id: expect.any(String), },
}), },
}),
])); ]));
expect(runAgentCalls).toHaveLength(0); expect(runAgentCalls).toHaveLength(0);
}); });
@@ -297,6 +296,34 @@ describe("trigger full lifecycle (integration)", () => {
{ principalType: "FEISHU_CHAT", principalId: "chat-onboard-create", role: "EDIT" }, { principalType: "FEISHU_CHAT", principalId: "chat-onboard-create", role: "EDIT" },
])); ]));
expect(cardHeaderTitle(rt.sentPatches.at(-1))).toBe("已创建并绑定项目"); expect(cardHeaderTitle(rt.sentPatches.at(-1))).toBe("已创建并绑定项目");
expect(JSON.stringify(rt.sentPatches.at(-1))).toContain("project_rename_form");
});
it("opens project management at any time and renames through a validated card form", async () => {
await seedProject("project-management", "chat-management", { role: "MANAGE" });
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
await trigger(makeEvent("chat-management", "@_user_1 /project"), rt);
expect(cardHeaderTitle(rt.sentCards.at(-1))).toBe("项目管理");
expect(JSON.stringify(rt.sentCards.at(-1))).toContain("project_rename_form");
await trigger.onCardAction(makeOnboardingEvent("chat-management", {
project_onboarding: {
action: "rename_project",
organization_id: DEFAULT_ORG_ID,
project_id: "project-management",
},
}, "ou_test_user", { project_name: "表面张力课程" }), rt);
await expect(prisma.project.findUniqueOrThrow({ where: { id: "project-management" } }))
.resolves.toMatchObject({ name: "表面张力课程" });
await expect(prisma.auditEntry.findFirstOrThrow({
where: { projectId: "project-management", action: "project.renamed" },
})).resolves.toMatchObject({
metadata: { oldName: "Test project-management", newName: "表面张力课程" },
});
expect(cardHeaderTitle(rt.sentPatches.at(-1))).toBe("项目名称已更新");
}); });
it("binds an existing manageable project from the unbound-chat onboarding card", async () => { it("binds an existing manageable project from the unbound-chat onboarding card", async () => {
@@ -361,6 +388,40 @@ describe("trigger full lifecycle (integration)", () => {
expect(cardHeaderTitle(rt.sentPatches.at(-1))).toBe("已绑定项目"); expect(cardHeaderTitle(rt.sentPatches.at(-1))).toBe("已绑定项目");
}); });
it("rejects a forged bind card targeting a project in another organization", async () => {
await seedOnboardingUser("u-onboard-cross-org", "ou_onboard_cross_org", "MEMBER");
await seedTestOrganization("org-other-card", "other-card");
await prisma.project.create({
data: {
id: "project-other-card",
organizationId: "org-other-card",
name: "Other project",
workspaceDir: join(await tempWorkspaceRoot(), "project-other-card"),
},
});
await prisma.permissionGrant.create({
data: {
resourceType: "PROJECT",
resourceId: "project-other-card",
principalType: "USER",
principalId: "ou_onboard_cross_org",
role: "MANAGE",
},
});
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
await trigger.onCardAction(makeOnboardingEvent("chat-cross-org", {
project_onboarding: {
action: "bind_project",
organization_id: DEFAULT_ORG_ID,
project_id: "project-other-card",
},
}, "ou_onboard_cross_org"), rt);
await expect(prisma.projectGroupBinding.count({ where: { chatId: "chat-cross-org" } })).resolves.toBe(0);
expect(cardHeaderTitle(rt.sentPatches.at(-1))).toBe("绑定失败");
});
it("uses unbound-chat mention text to search bindable projects", async () => { it("uses unbound-chat mention text to search bindable projects", async () => {
await seedOnboardingUser("u-onboard-search", "ou_onboard_search", "OWNER"); await seedOnboardingUser("u-onboard-search", "ou_onboard_search", "OWNER");
const folder = await prisma.folder.create({ const folder = await prisma.folder.create({
@@ -408,6 +469,98 @@ describe("trigger full lifecycle (integration)", () => {
expect(runAgentCalls).toHaveLength(0); expect(runAgentCalls).toHaveLength(0);
}); });
it("paginates every manageable search result through card actions", async () => {
await seedOnboardingUser("u-onboard-pages", "ou_onboard_pages", "OWNER");
const workspaceRoot = await tempWorkspaceRoot();
await prisma.project.createMany({
data: Array.from({ length: 9 }, (_, index) => ({
id: `project-page-${index}`,
organizationId: DEFAULT_ORG_ID,
name: `分页项目-${index}`,
workspaceDir: join(workspaceRoot, `project-page-${index}`),
})),
});
const trigger = makeTriggerHandler({
prisma,
settings,
logger: silentLogger,
runAgent,
projectWorkspaceRoot: workspaceRoot,
});
await trigger(makeEvent("chat-onboard-pages", "@_user_1 分页项目", "ou_onboard_pages"), rt);
const firstPageValues = cardActionValues(rt.sentCards[0]);
expect(firstPageValues.filter((value) => JSON.stringify(value).includes('"bind_project"'))).toHaveLength(8);
expect(firstPageValues).toContainEqual({
project_onboarding: {
action: "search_page",
organization_id: DEFAULT_ORG_ID,
search_query: "分页项目",
page: 2,
},
});
await trigger.onCardAction(makeOnboardingEvent("chat-onboard-pages", {
project_onboarding: {
action: "search_page",
organization_id: DEFAULT_ORG_ID,
search_query: "分页项目",
page: 2,
},
}, "ou_onboard_pages"), rt);
const secondPageValues = cardActionValues(rt.sentPatches.at(-1));
expect(secondPageValues.filter((value) => JSON.stringify(value).includes('"bind_project"'))).toHaveLength(1);
expect(JSON.stringify(rt.sentPatches.at(-1))).toContain("第 **2/2** 页");
});
it("navigates the preserved folder tree from the onboarding card", async () => {
await seedOnboardingUser("u-onboard-folders", "ou_onboard_folders", "OWNER");
const root = await prisma.folder.create({
data: { id: "folder-legacy-root", organizationId: DEFAULT_ORG_ID, name: "旧教学资产" },
});
await prisma.folder.create({
data: { id: "folder-physics-child", organizationId: DEFAULT_ORG_ID, parentId: root.id, name: "物理竞赛教研" },
});
const trigger = makeTriggerHandler({
prisma,
settings,
logger: silentLogger,
runAgent,
projectWorkspaceRoot: await tempWorkspaceRoot(),
});
await trigger(makeEvent("chat-onboard-folders", "@_user_1", "ou_onboard_folders"), rt);
expect(cardActionValues(rt.sentCards[0])).toContainEqual({
project_onboarding: {
action: "browse_folder",
organization_id: DEFAULT_ORG_ID,
folder_id: root.id,
page: 1,
},
});
await trigger.onCardAction(makeOnboardingEvent("chat-onboard-folders", {
project_onboarding: {
action: "browse_folder",
organization_id: DEFAULT_ORG_ID,
folder_id: root.id,
page: 1,
},
}, "ou_onboard_folders"), rt);
expect(JSON.stringify(rt.sentPatches.at(-1))).toContain("旧教学资产");
expect(cardActionValues(rt.sentPatches.at(-1))).toContainEqual({
project_onboarding: {
action: "browse_folder",
organization_id: DEFAULT_ORG_ID,
folder_id: "folder-physics-child",
page: 1,
},
});
});
it("continues searching past unauthorized matches for a manageable project", async () => { it("continues searching past unauthorized matches for a manageable project", async () => {
await seedOnboardingUser("u-onboard-page", "ou_onboard_page", "MEMBER"); await seedOnboardingUser("u-onboard-page", "ou_onboard_page", "MEMBER");
const workspaceRoot = await tempWorkspaceRoot(); const workspaceRoot = await tempWorkspaceRoot();
@@ -551,6 +704,7 @@ describe("trigger full lifecycle (integration)", () => {
expect(helpText).toContain("可用 slash 命令"); expect(helpText).toContain("可用 slash 命令");
expect(helpText).toContain("/new"); expect(helpText).toContain("/new");
expect(helpText).toContain("/reset"); expect(helpText).toContain("/reset");
expect(helpText).toContain("/project");
expect(helpText).toContain("/draft <需求>"); expect(helpText).toContain("/draft <需求>");
expect(helpText).toContain("/review <需求>"); expect(helpText).toContain("/review <需求>");
expect(runAgentCalls).toHaveLength(0); expect(runAgentCalls).toHaveLength(0);
@@ -1632,10 +1786,15 @@ function makeInterruptEvent(chatId: string, runId: string, openId: string): Card
}; };
} }
function makeOnboardingEvent(chatId: string, value: unknown, openId: string): CardActionEvent { function makeOnboardingEvent(
chatId: string,
value: unknown,
openId: string,
formValue?: Readonly<Record<string, unknown>>,
): CardActionEvent {
return { return {
operator: { open_id: openId }, operator: { open_id: openId },
action: { value, tag: "button" }, action: { value, tag: "button", ...(formValue !== undefined ? { form_value: formValue } : {}) },
context: { open_chat_id: chatId, open_message_id: "card-message-1" }, context: { open_chat_id: chatId, open_message_id: "card-message-1" },
}; };
} }