forked from EduCraft/curriculum-project-hub
672 lines
24 KiB
TypeScript
672 lines
24 KiB
TypeScript
import { createHash, randomUUID } from "node:crypto";
|
|
import { mkdir, rm } from "node:fs/promises";
|
|
import { dirname, relative, resolve } from "node:path";
|
|
import type { Folder, OrganizationMemberRole, PermissionRole, Prisma, PrismaClient } from "@prisma/client";
|
|
import { createPermissionAuthorizer } from "./permission.js";
|
|
import { writeAudit } from "./audit.js";
|
|
import { lockActiveOrganization, requireActiveOrganizationStatus } from "./org/status.js";
|
|
import { scopedFeishuPrincipalId } from "./feishu/identityNamespace.js";
|
|
|
|
export interface OrganizationProjectPolicy {
|
|
readonly organizationId: string;
|
|
readonly membersCanCreateProjects: boolean;
|
|
}
|
|
|
|
export interface ProjectOnboardingResult {
|
|
readonly projectId: string;
|
|
readonly organizationId: string;
|
|
readonly folderId: string | null;
|
|
readonly workspaceDir: string;
|
|
readonly chatId?: string | undefined;
|
|
}
|
|
|
|
export interface CreateOrgAdminProjectInput {
|
|
readonly organizationId: string;
|
|
readonly actorFeishuOpenId: string;
|
|
readonly name: string;
|
|
readonly workspaceRoot: string;
|
|
readonly folderId?: string | undefined;
|
|
readonly sortKey?: string | undefined;
|
|
/** Stable internal identifier for resumable imports; ordinary callers must omit it. */
|
|
readonly projectId?: string | undefined;
|
|
}
|
|
|
|
export interface CreateFeishuChatProjectInput {
|
|
readonly organizationId: string;
|
|
readonly actorFeishuOpenId: string;
|
|
readonly chatId: string;
|
|
readonly name: string;
|
|
readonly workspaceRoot: string;
|
|
readonly folderId?: string | undefined;
|
|
readonly sortKey?: string | undefined;
|
|
}
|
|
|
|
export interface BindFeishuChatToProjectInput {
|
|
readonly projectId: string;
|
|
readonly actorFeishuOpenId: string;
|
|
readonly chatId: string;
|
|
}
|
|
|
|
export interface ArchiveFeishuChatBindingInput {
|
|
readonly projectId?: string | undefined;
|
|
readonly chatId?: string | undefined;
|
|
readonly actorFeishuOpenId: string;
|
|
}
|
|
|
|
export interface RenameProjectForActorInput {
|
|
readonly organizationId: string;
|
|
readonly projectId: string;
|
|
readonly actorFeishuOpenId: string;
|
|
readonly name: string;
|
|
}
|
|
|
|
export interface CreateFolderInput {
|
|
readonly organizationId: string;
|
|
readonly name: string;
|
|
readonly parentId?: string | undefined;
|
|
readonly sortKey?: string | undefined;
|
|
}
|
|
|
|
export interface MoveProjectToFolderInput {
|
|
readonly projectId: string;
|
|
readonly folderId: string | null;
|
|
}
|
|
|
|
export async function ensureOrganizationProjectSettings(
|
|
prisma: PrismaClient,
|
|
organizationId: string,
|
|
): Promise<OrganizationProjectPolicy> {
|
|
return prisma.$transaction(async (tx) => ensureOrganizationProjectSettingsTx(tx, organizationId));
|
|
}
|
|
|
|
export async function setMembersCanCreateProjects(
|
|
prisma: PrismaClient,
|
|
input: { readonly organizationId: string; readonly enabled: boolean },
|
|
): Promise<OrganizationProjectPolicy> {
|
|
return prisma.$transaction(async (tx) => {
|
|
await requireActiveOrganizationTx(tx, input.organizationId);
|
|
return tx.organizationProjectSettings.upsert({
|
|
where: { organizationId: input.organizationId },
|
|
update: { membersCanCreateProjects: input.enabled },
|
|
create: {
|
|
organizationId: input.organizationId,
|
|
membersCanCreateProjects: input.enabled,
|
|
},
|
|
select: { organizationId: true, membersCanCreateProjects: true },
|
|
});
|
|
});
|
|
}
|
|
|
|
export async function createFolder(prisma: PrismaClient, input: CreateFolderInput): Promise<Folder> {
|
|
const name = requireNonEmpty(input.name, "folder name");
|
|
return prisma.$transaction(async (tx) => {
|
|
await requireActiveOrganizationTx(tx, input.organizationId);
|
|
if (input.parentId !== undefined) {
|
|
await assertFolderInOrganization(tx, input.parentId, input.organizationId);
|
|
}
|
|
return tx.folder.create({
|
|
data: {
|
|
organizationId: input.organizationId,
|
|
name,
|
|
sortKey: input.sortKey ?? "",
|
|
...(input.parentId !== undefined ? { parentId: input.parentId } : {}),
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
export async function moveProjectToFolder(
|
|
prisma: PrismaClient,
|
|
input: MoveProjectToFolderInput,
|
|
): Promise<{ readonly projectId: string; readonly folderId: string | null }> {
|
|
return prisma.$transaction(async (tx) => {
|
|
const project = await tx.project.findUnique({
|
|
where: { id: input.projectId },
|
|
select: { id: true, organizationId: true },
|
|
});
|
|
if (project === null) throw new Error(`project not found: ${input.projectId}`);
|
|
await requireActiveOrganizationTx(tx, project.organizationId);
|
|
if (input.folderId !== null) {
|
|
await assertFolderInOrganization(tx, input.folderId, project.organizationId);
|
|
}
|
|
const updated = await tx.project.update({
|
|
where: { id: input.projectId },
|
|
data: { folderId: input.folderId },
|
|
select: { id: true, folderId: true },
|
|
});
|
|
return { projectId: updated.id, folderId: updated.folderId };
|
|
});
|
|
}
|
|
|
|
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(
|
|
prisma: PrismaClient,
|
|
input: CreateOrgAdminProjectInput,
|
|
): Promise<ProjectOnboardingResult> {
|
|
const name = requireNonEmpty(input.name, "project name");
|
|
const actor = await requireActiveOrgMember(prisma, input.organizationId, input.actorFeishuOpenId);
|
|
if (!isOrgAdminRole(actor.role)) {
|
|
throw new Error(`org admin project creation requires OWNER or ADMIN in organization ${input.organizationId}`);
|
|
}
|
|
return createManagedProject(prisma, {
|
|
organizationId: input.organizationId,
|
|
actorUserId: actor.userId,
|
|
actorPrincipalId: actor.principalId,
|
|
feishuConnectionId: actor.connectionId,
|
|
name,
|
|
workspaceRoot: input.workspaceRoot,
|
|
folderId: input.folderId,
|
|
sortKey: input.sortKey,
|
|
projectId: input.projectId,
|
|
chatId: undefined,
|
|
});
|
|
}
|
|
|
|
export async function createProjectFromFeishuChat(
|
|
prisma: PrismaClient,
|
|
input: CreateFeishuChatProjectInput,
|
|
): Promise<ProjectOnboardingResult> {
|
|
const chatId = requireNonEmpty(input.chatId, "chat id");
|
|
const name = requireNonEmpty(input.name, "project name");
|
|
const actor = await requireActiveOrgMember(prisma, input.organizationId, input.actorFeishuOpenId);
|
|
const settings = await ensureOrganizationProjectSettings(prisma, input.organizationId);
|
|
if (!settings.membersCanCreateProjects && !isOrgAdminRole(actor.role)) {
|
|
throw new Error(`members cannot create projects in organization ${input.organizationId}`);
|
|
}
|
|
const activeBinding = await prisma.projectGroupBinding.findFirst({
|
|
where: { chatId, archivedAt: null },
|
|
select: { projectId: true },
|
|
});
|
|
if (activeBinding !== null) {
|
|
throw new Error(`Feishu chat ${chatId} is already bound to project ${activeBinding.projectId}`);
|
|
}
|
|
return createManagedProject(prisma, {
|
|
organizationId: input.organizationId,
|
|
actorUserId: actor.userId,
|
|
actorPrincipalId: actor.principalId,
|
|
feishuConnectionId: actor.connectionId,
|
|
name,
|
|
workspaceRoot: input.workspaceRoot,
|
|
folderId: input.folderId,
|
|
sortKey: input.sortKey,
|
|
chatId,
|
|
});
|
|
}
|
|
|
|
export async function bindFeishuChatToProject(
|
|
prisma: PrismaClient,
|
|
input: BindFeishuChatToProjectInput,
|
|
): Promise<ProjectOnboardingResult> {
|
|
const chatId = requireNonEmpty(input.chatId, "chat id");
|
|
const project = await prisma.project.findUnique({
|
|
where: { id: input.projectId },
|
|
select: { id: true, organizationId: true, folderId: true, workspaceDir: true },
|
|
});
|
|
if (project === null) throw new Error(`project not found: ${input.projectId}`);
|
|
const actor = await requireProjectManager(prisma, project.id, project.organizationId, input.actorFeishuOpenId);
|
|
await prisma.$transaction(async (tx) => {
|
|
await requireActiveOrganizationTx(tx, project.organizationId);
|
|
const activeProjectBinding = await tx.projectGroupBinding.findFirst({
|
|
where: { projectId: project.id, archivedAt: null },
|
|
select: { chatId: true },
|
|
});
|
|
if (activeProjectBinding !== null) {
|
|
throw new Error(`project ${project.id} is already bound to Feishu chat ${activeProjectBinding.chatId}`);
|
|
}
|
|
const activeChatBinding = await tx.projectGroupBinding.findFirst({
|
|
where: { chatId, archivedAt: null },
|
|
select: { projectId: true },
|
|
});
|
|
if (activeChatBinding !== null) {
|
|
throw new Error(`Feishu chat ${chatId} is already bound to project ${activeChatBinding.projectId}`);
|
|
}
|
|
await tx.projectGroupBinding.create({
|
|
data: { projectId: project.id, chatId, createdByUserId: actor.userId },
|
|
});
|
|
await replaceProjectGrant(tx, {
|
|
projectId: project.id,
|
|
principalType: "FEISHU_CHAT",
|
|
principalId: scopedChatPrincipalId(actor.connectionId, chatId),
|
|
role: "EDIT",
|
|
createdByUserId: actor.userId,
|
|
});
|
|
});
|
|
await writeAudit(prisma, {
|
|
projectId: project.id,
|
|
actorUserId: actor.userId,
|
|
action: "project.chat_bound",
|
|
metadata: { chatId, actorVia: actor.via },
|
|
});
|
|
return {
|
|
projectId: project.id,
|
|
organizationId: project.organizationId,
|
|
folderId: project.folderId,
|
|
workspaceDir: project.workspaceDir,
|
|
chatId,
|
|
};
|
|
}
|
|
|
|
export async function archiveFeishuChatBinding(
|
|
prisma: PrismaClient,
|
|
input: ArchiveFeishuChatBindingInput,
|
|
): Promise<{ readonly archived: boolean; readonly projectId?: string | undefined; readonly chatId?: string | undefined }> {
|
|
if ((input.projectId === undefined || input.projectId === "") && (input.chatId === undefined || input.chatId === "")) {
|
|
throw new Error("archiveFeishuChatBinding requires projectId or chatId");
|
|
}
|
|
if (input.projectId !== undefined && input.projectId !== "" && input.chatId !== undefined && input.chatId !== "") {
|
|
throw new Error("archiveFeishuChatBinding accepts only one of projectId or chatId");
|
|
}
|
|
const binding = await prisma.projectGroupBinding.findFirst({
|
|
where: {
|
|
archivedAt: null,
|
|
...(input.projectId !== undefined && input.projectId !== "" ? { projectId: input.projectId } : { chatId: input.chatId! }),
|
|
},
|
|
select: { id: true, projectId: true, chatId: true, project: { select: { organizationId: true } } },
|
|
});
|
|
if (binding === null) return { archived: false };
|
|
const actor = await requireProjectManager(prisma, binding.projectId, binding.project.organizationId, input.actorFeishuOpenId);
|
|
await prisma.$transaction(async (tx) => {
|
|
await requireActiveOrganizationTx(tx, binding.project.organizationId);
|
|
await tx.projectGroupBinding.update({
|
|
where: { id: binding.id },
|
|
data: { archivedAt: new Date() },
|
|
});
|
|
await tx.permissionGrant.updateMany({
|
|
where: {
|
|
resourceType: "PROJECT",
|
|
resourceId: binding.projectId,
|
|
principalType: "FEISHU_CHAT",
|
|
principalId: scopedChatPrincipalId(actor.connectionId, binding.chatId),
|
|
revokedAt: null,
|
|
},
|
|
data: { revokedAt: new Date() },
|
|
});
|
|
});
|
|
await writeAudit(prisma, {
|
|
projectId: binding.projectId,
|
|
actorUserId: actor.userId,
|
|
action: "project.chat_binding_archived",
|
|
metadata: { chatId: binding.chatId, actorVia: actor.via },
|
|
});
|
|
return { archived: true, projectId: binding.projectId, chatId: binding.chatId };
|
|
}
|
|
|
|
async function createManagedProject(
|
|
prisma: PrismaClient,
|
|
input: {
|
|
readonly organizationId: string;
|
|
readonly actorUserId: string;
|
|
readonly actorPrincipalId: string;
|
|
readonly feishuConnectionId: string | undefined;
|
|
readonly name: string;
|
|
readonly workspaceRoot: string;
|
|
readonly folderId: string | undefined;
|
|
readonly sortKey?: string | undefined;
|
|
readonly projectId?: string | undefined;
|
|
readonly chatId: string | undefined;
|
|
},
|
|
): Promise<ProjectOnboardingResult> {
|
|
const organization = await prisma.organization.findUnique({
|
|
where: { id: input.organizationId },
|
|
select: { id: true, slug: true, status: true },
|
|
});
|
|
if (organization === null) throw new Error(`organization not found: ${input.organizationId}`);
|
|
requireActiveOrganizationStatus(organization.id, organization.status);
|
|
|
|
const projectId = input.projectId ?? createProjectId();
|
|
const workspaceDir = projectWorkspaceDir({
|
|
workspaceRoot: input.workspaceRoot,
|
|
organizationSlug: organization.slug,
|
|
projectId,
|
|
});
|
|
let workspaceCreated = false;
|
|
let project: { readonly id: string; readonly organizationId: string; readonly folderId: string | null; readonly workspaceDir: string };
|
|
try {
|
|
project = await prisma.$transaction(async (tx) => {
|
|
await ensureOrganizationProjectSettingsTx(tx, organization.id);
|
|
const folderId = input.folderId ?? (await ensureInboxFolder(tx, organization.id)).id;
|
|
await assertFolderInOrganization(tx, folderId, organization.id);
|
|
|
|
await mkdir(dirname(workspaceDir), { recursive: true });
|
|
await mkdir(workspaceDir);
|
|
workspaceCreated = true;
|
|
|
|
const created = await tx.project.create({
|
|
data: {
|
|
id: projectId,
|
|
organizationId: organization.id,
|
|
folderId,
|
|
name: input.name,
|
|
workspaceDir,
|
|
createdByUserId: input.actorUserId,
|
|
},
|
|
select: { id: true, organizationId: true, folderId: true, workspaceDir: true },
|
|
});
|
|
await tx.permissionSettings.create({
|
|
data: defaultProjectPermissionSettings(created.id),
|
|
});
|
|
await replaceProjectGrant(tx, {
|
|
projectId: created.id,
|
|
principalType: "USER",
|
|
principalId: input.actorPrincipalId,
|
|
role: "MANAGE",
|
|
createdByUserId: input.actorUserId,
|
|
});
|
|
if (input.chatId !== undefined) {
|
|
await tx.projectGroupBinding.create({
|
|
data: { projectId: created.id, chatId: input.chatId, createdByUserId: input.actorUserId },
|
|
});
|
|
await replaceProjectGrant(tx, {
|
|
projectId: created.id,
|
|
principalType: "FEISHU_CHAT",
|
|
principalId: scopedChatPrincipalId(input.feishuConnectionId, input.chatId),
|
|
role: "EDIT",
|
|
createdByUserId: input.actorUserId,
|
|
});
|
|
}
|
|
return created;
|
|
});
|
|
} catch (error) {
|
|
if (workspaceCreated) {
|
|
try {
|
|
await rm(workspaceDir, { recursive: true });
|
|
} catch (cleanupError) {
|
|
throw new AggregateError(
|
|
[error, cleanupError],
|
|
`project creation failed and workspace cleanup also failed: ${workspaceDir}`,
|
|
);
|
|
}
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
await writeAudit(prisma, {
|
|
projectId: project.id,
|
|
actorUserId: input.actorUserId,
|
|
action: input.chatId === undefined ? "project.created" : "project.created_from_feishu_chat",
|
|
metadata: { folderId: project.folderId, workspaceDir, chatId: input.chatId ?? null },
|
|
});
|
|
|
|
return {
|
|
projectId: project.id,
|
|
organizationId: project.organizationId,
|
|
folderId: project.folderId,
|
|
workspaceDir: project.workspaceDir,
|
|
...(input.chatId !== undefined ? { chatId: input.chatId } : {}),
|
|
};
|
|
}
|
|
|
|
async function requireProjectManager(
|
|
prisma: PrismaClient,
|
|
projectId: string,
|
|
organizationId: string,
|
|
actorFeishuOpenId: string,
|
|
): Promise<{
|
|
readonly userId: string;
|
|
readonly role: OrganizationMemberRole;
|
|
readonly principalId: string;
|
|
readonly connectionId: string | undefined;
|
|
readonly via: "org-admin" | "project-manage";
|
|
}> {
|
|
const actor = await requireActiveOrgMember(prisma, organizationId, actorFeishuOpenId);
|
|
if (isOrgAdminRole(actor.role)) {
|
|
return { ...actor, via: "org-admin" };
|
|
}
|
|
const decision = await createPermissionAuthorizer(prisma).can({
|
|
actor: { feishuOpenId: actorFeishuOpenId },
|
|
action: "collaborator.manage",
|
|
resource: { type: "PROJECT", id: projectId },
|
|
});
|
|
if (!decision.allowed) {
|
|
throw new Error(`project ${projectId} requires MANAGE for ${actorFeishuOpenId}: ${decision.reason}`);
|
|
}
|
|
return { ...actor, via: "project-manage" };
|
|
}
|
|
|
|
async function requireActiveOrgMember(
|
|
prisma: PrismaClient,
|
|
organizationId: string,
|
|
feishuOpenId: string,
|
|
): Promise<{
|
|
readonly userId: string;
|
|
readonly role: OrganizationMemberRole;
|
|
readonly principalId: string;
|
|
readonly connectionId: string | undefined;
|
|
}> {
|
|
await requireActiveOrganization(prisma, organizationId);
|
|
const identity = await prisma.feishuUserIdentity.findFirst({
|
|
where: { openId: feishuOpenId, connection: { organizationId, status: "ACTIVE" } },
|
|
select: {
|
|
connectionId: true,
|
|
user: { select: {
|
|
id: true,
|
|
organizationMemberships: {
|
|
where: { organizationId, revokedAt: null },
|
|
select: { role: true },
|
|
take: 1,
|
|
},
|
|
} },
|
|
},
|
|
});
|
|
if (identity !== null) {
|
|
const membership = identity.user.organizationMemberships[0];
|
|
if (membership === undefined) {
|
|
throw new Error(`Feishu user ${feishuOpenId} is not an active member of organization ${organizationId}`);
|
|
}
|
|
return {
|
|
userId: identity.user.id,
|
|
role: membership.role,
|
|
principalId: scopedFeishuPrincipalId("USER", identity.connectionId, feishuOpenId),
|
|
connectionId: identity.connectionId,
|
|
};
|
|
}
|
|
// Explicit compatibility path for pre-ADR-0024 rows while the Silo branch
|
|
// completes migration. New bootstrap/member writes always create identities.
|
|
const user = process.env["NODE_ENV"] === "production" ? null : await prisma.user.findUnique({
|
|
where: { feishuOpenId },
|
|
select: {
|
|
id: true,
|
|
organizationMemberships: {
|
|
where: { organizationId, revokedAt: null },
|
|
select: { role: true },
|
|
take: 1,
|
|
},
|
|
},
|
|
});
|
|
if (user === null) {
|
|
throw new Error(`Feishu user ${feishuOpenId} must log in before project onboarding`);
|
|
}
|
|
const membership = user.organizationMemberships[0];
|
|
if (membership === undefined) {
|
|
throw new Error(`Feishu user ${feishuOpenId} is not an active member of organization ${organizationId}`);
|
|
}
|
|
return { userId: user.id, role: membership.role, principalId: feishuOpenId, connectionId: undefined };
|
|
}
|
|
|
|
function scopedChatPrincipalId(connectionId: string | undefined, chatId: string): string {
|
|
return connectionId === undefined ? chatId : scopedFeishuPrincipalId("CHAT", connectionId, chatId);
|
|
}
|
|
|
|
async function ensureOrganizationProjectSettingsTx(
|
|
prisma: Prisma.TransactionClient,
|
|
organizationId: string,
|
|
): Promise<OrganizationProjectPolicy> {
|
|
await requireActiveOrganizationTx(prisma, organizationId);
|
|
return prisma.organizationProjectSettings.upsert({
|
|
where: { organizationId },
|
|
update: {},
|
|
create: { organizationId, membersCanCreateProjects: true },
|
|
select: { organizationId: true, membersCanCreateProjects: true },
|
|
});
|
|
}
|
|
|
|
async function ensureInboxFolder(prisma: Prisma.TransactionClient, organizationId: string): Promise<{ readonly id: string }> {
|
|
const existing = await prisma.folder.findFirst({
|
|
where: { organizationId, kind: "SYSTEM_INBOX", archivedAt: null },
|
|
select: { id: true },
|
|
});
|
|
if (existing !== null) return existing;
|
|
return prisma.folder.create({
|
|
data: { organizationId, name: "Inbox", kind: "SYSTEM_INBOX", sortKey: "000000" },
|
|
select: { id: true },
|
|
});
|
|
}
|
|
|
|
async function assertFolderInOrganization(
|
|
prisma: Prisma.TransactionClient,
|
|
folderId: string,
|
|
organizationId: string,
|
|
): Promise<void> {
|
|
const folder = await prisma.folder.findUnique({
|
|
where: { id: folderId },
|
|
select: { organizationId: true, archivedAt: true },
|
|
});
|
|
if (folder === null || folder.archivedAt !== null) {
|
|
throw new Error(`active folder not found: ${folderId}`);
|
|
}
|
|
if (folder.organizationId !== organizationId) {
|
|
throw new Error(`folder ${folderId} is in ${folder.organizationId}, not organization ${organizationId}`);
|
|
}
|
|
}
|
|
|
|
async function requireActiveOrganization(prisma: PrismaClient, organizationId: string): Promise<void> {
|
|
const organization = await prisma.organization.findUnique({
|
|
where: { id: organizationId },
|
|
select: { id: true, status: true },
|
|
});
|
|
if (organization === null) throw new Error(`organization not found: ${organizationId}`);
|
|
requireActiveOrganizationStatus(organization.id, organization.status);
|
|
}
|
|
|
|
async function requireActiveOrganizationTx(prisma: Prisma.TransactionClient, organizationId: string): Promise<void> {
|
|
await lockActiveOrganization(prisma, organizationId);
|
|
}
|
|
|
|
async function replaceProjectGrant(
|
|
prisma: Prisma.TransactionClient,
|
|
input: {
|
|
readonly projectId: string;
|
|
readonly principalType: "USER" | "FEISHU_CHAT";
|
|
readonly principalId: string;
|
|
readonly role: PermissionRole;
|
|
readonly createdByUserId: string;
|
|
},
|
|
): Promise<void> {
|
|
await prisma.permissionGrant.updateMany({
|
|
where: {
|
|
resourceType: "PROJECT",
|
|
resourceId: input.projectId,
|
|
principalType: input.principalType,
|
|
principalId: input.principalId,
|
|
revokedAt: null,
|
|
},
|
|
data: { revokedAt: new Date() },
|
|
});
|
|
await prisma.permissionGrant.create({
|
|
data: {
|
|
resourceType: "PROJECT",
|
|
resourceId: input.projectId,
|
|
principalType: input.principalType,
|
|
principalId: input.principalId,
|
|
role: input.role,
|
|
createdByUserId: input.createdByUserId,
|
|
},
|
|
});
|
|
}
|
|
|
|
function defaultProjectPermissionSettings(projectId: string): Prisma.PermissionSettingsCreateInput {
|
|
return {
|
|
resourceType: "PROJECT",
|
|
resourceId: projectId,
|
|
externalShare: "DISABLED",
|
|
comment: "ROLE",
|
|
copyDownload: "ROLE",
|
|
collaboratorMgmt: "MANAGE_ONLY",
|
|
agentTrigger: "ROLE",
|
|
agentCancel: "MANAGE_ONLY",
|
|
};
|
|
}
|
|
|
|
function isOrgAdminRole(role: OrganizationMemberRole): boolean {
|
|
return role === "OWNER" || role === "ADMIN";
|
|
}
|
|
|
|
function requireNonEmpty(value: string, label: string): string {
|
|
const trimmed = value.trim();
|
|
if (trimmed === "") throw new Error(`${label} is required`);
|
|
return trimmed;
|
|
}
|
|
|
|
function createProjectId(): string {
|
|
return `project_${randomUUID().replaceAll("-", "")}`;
|
|
}
|
|
|
|
function projectWorkspaceDir(input: {
|
|
readonly workspaceRoot: string;
|
|
readonly organizationSlug: string;
|
|
readonly projectId: string;
|
|
}): string {
|
|
const root = resolve(requireNonEmpty(input.workspaceRoot, "workspace root"));
|
|
const dir = resolve(
|
|
root,
|
|
compactWorkspaceSegment("o", input.organizationSlug, "organization slug"),
|
|
compactWorkspaceSegment("p", input.projectId, "project id"),
|
|
);
|
|
const rel = relative(root, dir);
|
|
if (rel === "" || rel.startsWith("..")) {
|
|
throw new Error(`allocated workspace escapes root: ${dir}`);
|
|
}
|
|
return dir;
|
|
}
|
|
|
|
function compactWorkspaceSegment(prefix: "o" | "p", value: string, label: string): string {
|
|
const normalized = safePathSegment(value, label);
|
|
const digest = createHash("sha256").update(normalized).digest("base64url").slice(0, 16);
|
|
return `${prefix}_${digest}`;
|
|
}
|
|
|
|
function safePathSegment(value: string, label: string): string {
|
|
const segment = requireNonEmpty(value, label).replace(/[^A-Za-z0-9._-]+/g, "_");
|
|
if (segment === "." || segment === ".." || segment === "") {
|
|
throw new Error(`${label} is not a safe path segment`);
|
|
}
|
|
return segment;
|
|
}
|