forked from bai/curriculum-project-hub
316 lines
11 KiB
TypeScript
316 lines
11 KiB
TypeScript
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;
|
|
}
|