Files
triggerdotdev--trigger.dev/apps/webapp/app/services/projectSettings.server.ts
Oskar Otwinowski 69dc7bcde8 feat(webapp): Vercel / Slack integrations improvements (#3108)
##  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"
/>

💯
2026-02-23 13:48:09 +00:00

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);
}
}