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 { 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 { 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 { const access = accessPredicate(manageableIds); const offset = (page - 1) * pageSize; if (normalizedQuery === "") { return prisma.$queryRaw(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(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 { const access = accessPredicate(manageableIds); const queryPredicate = normalizedQuery === "" ? Prisma.empty : Prisma.sql`AND ${searchPredicate(normalizedQuery, tokens)}`; const rows = await prisma.$queryRaw>(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 { 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 { const rows = await prisma.$queryRaw>(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; }