69dc7bcde8
## ✅ Checklist - [x] I have followed every step in the [contributing guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md) - [x] The PR title follows the convention. - [x] I ran and tested the code works --- ## Testing Slack + GitHub + Vercel + Builds + Deployments --- ## Changelog Settings changes: - Split general from integrations - Add new Slack section to org level integrations Vercel improvements: - bugfix for TRIGGER_SECRET_KEY collision - onboarding improvements for connecting to projects - new loops event Slack improvements: - nicer alerts Webhook/Email alerts: - rich events with Github & Vercel integration data --- ## Screenshots <img width="2550" height="652" alt="Screenshot 2026-02-20 at 21 53 34" src="https://github.com/user-attachments/assets/8d7c9f1d-5fe9-4516-8fb3-885460b4207f" /> <img width="843" height="710" alt="Screenshot 2026-02-23 at 10 55 54" src="https://github.com/user-attachments/assets/8ea72c1f-431b-493c-b9a9-8076cce12262" /> <img width="765" height="466" alt="Screenshot 2026-02-20 at 21 52 46" src="https://github.com/user-attachments/assets/157fafb8-b7bf-499d-8953-c2aed5e44ce0" /> <img width="691" height="261" alt="Screenshot 2026-02-20 at 22 04 24" src="https://github.com/user-attachments/assets/3aea7369-2008-4af8-a9c0-5fbfa2cc381d" /> <img width="2032" height="1114" alt="Screenshot 2026-02-19 at 14 48 49" src="https://github.com/user-attachments/assets/dc10c14e-cd15-445a-b5be-d694d29d20e5" /> <img width="2032" height="1114" alt="Screenshot 2026-02-19 at 14 49 04" src="https://github.com/user-attachments/assets/1ef591fd-fd00-430a-9649-8b18cff9586d" /> <img width="1583" height="1115" alt="Screenshot 2026-02-19 at 17 32 56" src="https://github.com/user-attachments/assets/c5c8f318-d193-4dd4-86f7-1cc4bbcc4e0c" /> <img width="422" height="187" alt="Screenshot 2026-02-20 at 21 57 41" src="https://github.com/user-attachments/assets/37865cb6-4c0d-40ef-9c60-7b057d546c61" /> <img width="1583" height="1115" alt="Screenshot 2026-02-19 at 17 33 06" src="https://github.com/user-attachments/assets/e9180e8e-e611-4734-9232-80c62ff863ad" /> 💯
330 lines
9.3 KiB
TypeScript
330 lines
9.3 KiB
TypeScript
import { type PrismaClient } from "@trigger.dev/database";
|
|
import { prisma } from "~/db.server";
|
|
import { DeleteProjectService } from "~/services/deleteProject.server";
|
|
import { BranchTrackingConfigSchema, type BranchTrackingConfig } from "~/v3/github";
|
|
import { checkGitHubBranchExists } from "~/services/gitHub.server";
|
|
import { errAsync, fromPromise, okAsync, ResultAsync } from "neverthrow";
|
|
import { type BuildSettings } from "~/v3/buildSettings";
|
|
|
|
export class ProjectSettingsService {
|
|
#prismaClient: PrismaClient;
|
|
|
|
constructor(prismaClient: PrismaClient = prisma) {
|
|
this.#prismaClient = prismaClient;
|
|
}
|
|
|
|
renameProject(projectId: string, newName: string) {
|
|
return fromPromise(
|
|
this.#prismaClient.project.update({
|
|
where: {
|
|
id: projectId,
|
|
},
|
|
data: {
|
|
name: newName,
|
|
},
|
|
}),
|
|
(error) => ({
|
|
type: "other" as const,
|
|
cause: error,
|
|
})
|
|
);
|
|
}
|
|
|
|
deleteProject(projectId: string, userId: string) {
|
|
const deleteProjectService = new DeleteProjectService(this.#prismaClient);
|
|
|
|
return fromPromise(deleteProjectService.call({ projectId, userId }), (error) => ({
|
|
type: "other" as const,
|
|
cause: error,
|
|
}));
|
|
}
|
|
|
|
connectGitHubRepo(
|
|
projectId: string,
|
|
organizationId: string,
|
|
repositoryId: string,
|
|
installationId: string
|
|
) {
|
|
const getRepository = () =>
|
|
fromPromise(
|
|
this.#prismaClient.githubRepository.findFirst({
|
|
where: {
|
|
id: repositoryId,
|
|
installationId,
|
|
installation: {
|
|
organizationId: organizationId,
|
|
},
|
|
},
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
defaultBranch: true,
|
|
},
|
|
}),
|
|
(error) => ({
|
|
type: "other" as const,
|
|
cause: error,
|
|
})
|
|
).andThen((repository) => {
|
|
if (!repository) {
|
|
return errAsync({ type: "gh_repository_not_found" as const });
|
|
}
|
|
return okAsync(repository);
|
|
});
|
|
|
|
const findExistingConnection = () =>
|
|
fromPromise(
|
|
this.#prismaClient.connectedGithubRepository.findFirst({
|
|
where: {
|
|
projectId: projectId,
|
|
},
|
|
}),
|
|
(error) => ({ type: "other" as const, cause: error })
|
|
);
|
|
|
|
const createConnectedRepo = (defaultBranch: string, previewDeploymentsEnabled: boolean) =>
|
|
fromPromise(
|
|
this.#prismaClient.connectedGithubRepository.create({
|
|
data: {
|
|
projectId: projectId,
|
|
repositoryId: repositoryId,
|
|
branchTracking: {
|
|
prod: { branch: defaultBranch },
|
|
staging: {},
|
|
} satisfies BranchTrackingConfig,
|
|
previewDeploymentsEnabled,
|
|
},
|
|
}),
|
|
(error) => ({ type: "other" as const, cause: error })
|
|
);
|
|
|
|
return ResultAsync.combine([
|
|
getRepository(),
|
|
findExistingConnection(),
|
|
this.isPreviewEnvironmentEnabled(projectId),
|
|
]).andThen(([repository, existingConnection, previewEnvironmentEnabled]) => {
|
|
if (existingConnection) {
|
|
return errAsync({ type: "project_already_has_connected_repository" as const });
|
|
}
|
|
|
|
return createConnectedRepo(repository.defaultBranch, previewEnvironmentEnabled);
|
|
});
|
|
}
|
|
|
|
disconnectGitHubRepo(projectId: string) {
|
|
return fromPromise(
|
|
this.#prismaClient.connectedGithubRepository.delete({
|
|
where: {
|
|
projectId: projectId,
|
|
},
|
|
}),
|
|
(error) => ({ type: "other" as const, cause: error })
|
|
);
|
|
}
|
|
|
|
updateGitSettings(
|
|
projectId: string,
|
|
productionBranch?: string,
|
|
stagingBranch?: string,
|
|
previewDeploymentsEnabled?: boolean
|
|
) {
|
|
const getExistingConnectedRepo = () =>
|
|
fromPromise(
|
|
this.#prismaClient.connectedGithubRepository.findFirst({
|
|
where: {
|
|
projectId: projectId,
|
|
},
|
|
include: {
|
|
repository: {
|
|
include: {
|
|
installation: true,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
(error) => ({ type: "other" as const, cause: error })
|
|
)
|
|
.andThen((connectedRepo) => {
|
|
if (!connectedRepo) {
|
|
return errAsync({ type: "connected_gh_repository_not_found" as const });
|
|
}
|
|
return okAsync(connectedRepo);
|
|
})
|
|
.map((connectedRepo) => {
|
|
const branchTrackingOrFailure = BranchTrackingConfigSchema.safeParse(
|
|
connectedRepo.branchTracking
|
|
);
|
|
const branchTracking = branchTrackingOrFailure.success
|
|
? branchTrackingOrFailure.data
|
|
: undefined;
|
|
|
|
return {
|
|
...connectedRepo,
|
|
branchTracking,
|
|
};
|
|
});
|
|
|
|
const validateProductionBranch = ({
|
|
installationId,
|
|
fullRepoName,
|
|
oldProductionBranch,
|
|
}: {
|
|
installationId: number;
|
|
fullRepoName: string;
|
|
oldProductionBranch?: string;
|
|
}) => {
|
|
if (productionBranch && oldProductionBranch !== productionBranch) {
|
|
return checkGitHubBranchExists(installationId, fullRepoName, productionBranch).andThen(
|
|
(exists) => {
|
|
if (!exists) {
|
|
return errAsync({ type: "production_tracking_branch_not_found" as const });
|
|
}
|
|
return okAsync(productionBranch);
|
|
}
|
|
);
|
|
}
|
|
|
|
return okAsync(productionBranch);
|
|
};
|
|
|
|
const validateStagingBranch = ({
|
|
installationId,
|
|
fullRepoName,
|
|
oldStagingBranch,
|
|
}: {
|
|
installationId: number;
|
|
fullRepoName: string;
|
|
oldStagingBranch?: string;
|
|
}) => {
|
|
if (stagingBranch && oldStagingBranch !== stagingBranch) {
|
|
return checkGitHubBranchExists(installationId, fullRepoName, stagingBranch).andThen(
|
|
(exists) => {
|
|
if (!exists) {
|
|
return errAsync({ type: "staging_tracking_branch_not_found" as const });
|
|
}
|
|
return okAsync(stagingBranch);
|
|
}
|
|
);
|
|
}
|
|
|
|
return okAsync(stagingBranch);
|
|
};
|
|
|
|
const updateConnectedRepo = (data: {
|
|
productionBranch: string | undefined;
|
|
stagingBranch: string | undefined;
|
|
previewDeploymentsEnabled: boolean | undefined;
|
|
}) =>
|
|
fromPromise(
|
|
this.#prismaClient.connectedGithubRepository.update({
|
|
where: {
|
|
projectId: projectId,
|
|
},
|
|
data: {
|
|
branchTracking: {
|
|
prod: data.productionBranch ? { branch: data.productionBranch } : {},
|
|
staging: data.stagingBranch ? { branch: data.stagingBranch } : {},
|
|
} satisfies BranchTrackingConfig,
|
|
previewDeploymentsEnabled: data.previewDeploymentsEnabled,
|
|
},
|
|
}),
|
|
(error) => ({ type: "other" as const, cause: error })
|
|
);
|
|
|
|
return getExistingConnectedRepo()
|
|
.andThen((connectedRepo) => {
|
|
const installationId = Number(connectedRepo.repository.installation.appInstallationId);
|
|
|
|
return ResultAsync.combine([
|
|
validateProductionBranch({
|
|
installationId,
|
|
fullRepoName: connectedRepo.repository.fullName,
|
|
oldProductionBranch: connectedRepo.branchTracking?.prod?.branch,
|
|
}),
|
|
validateStagingBranch({
|
|
installationId,
|
|
fullRepoName: connectedRepo.repository.fullName,
|
|
oldStagingBranch: connectedRepo.branchTracking?.staging?.branch,
|
|
}),
|
|
this.isPreviewEnvironmentEnabled(projectId),
|
|
]);
|
|
})
|
|
.map(([productionBranch, stagingBranch, previewEnvironmentEnabled]) => ({
|
|
productionBranch,
|
|
stagingBranch,
|
|
previewDeploymentsEnabled: previewDeploymentsEnabled && previewEnvironmentEnabled,
|
|
}))
|
|
.andThen(updateConnectedRepo);
|
|
}
|
|
|
|
updateBuildSettings(projectId: string, buildSettings: BuildSettings) {
|
|
return fromPromise(
|
|
this.#prismaClient.project.update({
|
|
where: {
|
|
id: projectId,
|
|
},
|
|
data: {
|
|
buildSettings: buildSettings,
|
|
},
|
|
}),
|
|
(error) => ({
|
|
type: "other" as const,
|
|
cause: error,
|
|
})
|
|
);
|
|
}
|
|
|
|
verifyProjectMembership(organizationSlug: string, projectSlug: string, userId: string) {
|
|
const findProject = () =>
|
|
fromPromise(
|
|
this.#prismaClient.project.findFirst({
|
|
where: {
|
|
slug: projectSlug,
|
|
organization: {
|
|
slug: organizationSlug,
|
|
members: {
|
|
some: {
|
|
userId,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
select: {
|
|
id: true,
|
|
organizationId: true,
|
|
},
|
|
}),
|
|
(error) => ({ type: "other" as const, cause: error })
|
|
);
|
|
|
|
return findProject().andThen((project) => {
|
|
if (!project) {
|
|
return errAsync({ type: "user_not_in_project" as const });
|
|
}
|
|
|
|
return okAsync({
|
|
projectId: project.id,
|
|
organizationId: project.organizationId,
|
|
});
|
|
});
|
|
}
|
|
|
|
private isPreviewEnvironmentEnabled(projectId: string) {
|
|
return fromPromise(
|
|
this.#prismaClient.runtimeEnvironment.findFirst({
|
|
select: {
|
|
id: true,
|
|
},
|
|
where: {
|
|
projectId: projectId,
|
|
slug: "preview",
|
|
},
|
|
}),
|
|
(error) => ({
|
|
type: "other" as const,
|
|
cause: error,
|
|
})
|
|
).map((previewEnvironment) => previewEnvironment !== null);
|
|
}
|
|
}
|