forked from bai/curriculum-project-hub
feat: add paginated project discovery
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user