113 lines
4.0 KiB
TypeScript
113 lines
4.0 KiB
TypeScript
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",
|
|
};
|
|
}
|
|
}
|
|
}
|