import { type PrismaClient } from "@trigger.dev/database"; import { prisma } from "~/db.server"; import { logger } from "./logger.server"; import { nanoid } from "nanoid"; import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server"; export class ArchiveBranchService { #prismaClient: PrismaClient; constructor(prismaClient: PrismaClient = prisma) { this.#prismaClient = prismaClient; } public async call( // The orgFilter approach is not ideal but we need to keep it this way for now because of how the service is used in routes and api endpoints. // Currently authorization checks are spread across the controller/route layer and the service layer. Often we check in multiple places for org/project membership. // Ideally we would take care of both the authentication and authorization checks in the controllers and routes. // That would unify how we handle authorization and org/project membership checks. Also it would make the service layer queries simpler. orgFilter: | { type: "userMembership"; userId: string } | { type: "orgId"; organizationId: string }, { environmentId, }: { environmentId: string; } ) { try { const environment = await this.#prismaClient.runtimeEnvironment.findFirstOrThrow({ where: { id: environmentId, organization: orgFilter.type === "userMembership" ? { members: { some: { userId: orgFilter.userId, }, }, } : { id: orgFilter.organizationId }, // Dev branches are per-org-member, so org membership alone isn't enough: // only the owner may archive their own dev branch. Non-dev branches (e.g. // preview) remain scoped by org membership only. ...(orgFilter.type === "userMembership" ? { OR: [ { type: { not: "DEVELOPMENT" as const } }, { orgMember: { userId: orgFilter.userId } }, ], } : {}), }, include: { organization: { select: { id: true, slug: true, maximumConcurrencyLimit: true, }, }, project: { select: { id: true, slug: true, }, }, }, }); // A branch is defined by having a parent; any root (dev/preview parent, // prod, staging) has none and can't be archived. For dev, that root is // the default branch, so give the clearer message. if (!environment.parentEnvironmentId) { return { success: false as const, error: environment.type === "DEVELOPMENT" ? "The default development branch cannot be archived." : "This isn't a branch, and cannot be archived.", }; } // Branch archive is a SOFT update — do NOT hard-delete run-ops rows here (it would destroy a // retained branch's history). Any env hard-delete/purge belongs on a dedicated purge path // (owned by the cloud env-purge runbook), which has no site today. const slug = `${environment.slug}-${nanoid(6)}`; const shortcode = slug; const updatedBranch = await this.#prismaClient.runtimeEnvironment.update({ where: { id: environmentId }, data: { archivedAt: new Date(), slug, shortcode }, }); // archivedAt/slug/shortcode changed in the control-plane; drop any cached copy. controlPlaneResolver.invalidateEnvironment(environmentId); return { success: true as const, branch: updatedBranch, organization: environment.organization, project: environment.project, }; } catch (e) { logger.error("ArchiveBranchService error", { environmentId, error: e }); return { success: false as const, error: "Failed to archive branch", }; } } }