Files
Oskar Otwinowski 4c16387426 fix(webapp): project integrations page — Staging gating, unreachable code, and follow-ups (#4784)
Three bugs on the project integrations page, one commit each for the two
reported ones and four for the follow-ups found while fixing them.

## `chore`: remove unreachable code on the integrations page (TRI-12645)

Two notification panels in `VercelSettingsPanel` could never render:

1. The **"Failed to load Vercel settings"** panel was gated on a
`hasError` state whose setter is never called anywhere, so it was
permanently `false`.
2. The **"connection expired"** banner *inside* the `connectedProject`
branch was unreachable: `VercelSettingsPresenter` only populates
`connectedProject` on its success exit, which hardcodes `authInvalid:
false`, while both `authInvalid: true` exits return `connectedProject:
undefined`.

Removing them makes the surrounding `!showAuthInvalid` guards vacuous,
and the `onboardingData?.authInvalid` disjunct redundant — the loader
already folds onboarding auth state into `authInvalid` before it reaches
the component.

**No behaviour change.** An org with a connected project and an expired
token still gets the banner, from the branch below (untouched).

## `fix`: gate Staging settings on plans without a Staging environment
(TRI-12646)

The ticket's premise was inverted, and I've corrected it there. In Git
settings, **Preview** is the row that's correctly gated; **Staging** is
the one with no gate at all:

- Preview swaps its switch for an Upgrade button, and
`projectSettings.server.ts` neutralises a forged
`previewDeploymentsEnabled=on`.
- Staging was a plain always-editable `Input`, and
`validateStagingBranch` only checked the branch existed on GitHub. An
org without a staging environment could type a tracking branch, hit
Save, get a success toast, and have it silently do nothing.

Staging and Preview environments are created together for projects on a
plan that includes them, so gating one and not the other was an
oversight.

The Staging row now mirrors the Preview row. Server-side it ignores the
submitted branch when there's no staging environment, but **preserves
the stored branch rather than clearing it** — deliberately different
from the Preview handling. Forcing a boolean off is harmless; forcing a
*string* off would wipe a tracking branch the org had already configured
the first time they saved after losing the environment.

The Vercel write path had the same gap: `update-config` /
`complete-onboarding` / `update-env-mapping` never re-derived available
env slugs server-side, so `["stg","preview"]` could be persisted for a
project with neither environment, and
`createDefaultVercelIntegrationData` turned preview on unconditionally.
Both now filter against the project's actual environments, via a pure
`restrictConfigToAvailableEnvSlugs` helper that only touches keys
present on the input.

## `fix`: show build settings when the GitHub app is disabled
(TRI-13488)

The page wrapped Git settings, the Vercel section **and** build settings
in one `githubAppEnabled` guard, so with the GitHub app off it rendered
an empty container.

The Vercel section genuinely depends on GitHub — it can't sync
environment variables or link deployments without a connected repo — so
it stays gated. Build settings don't: they also apply to CLI deploys run
with `--native-build-server`, exactly as the section's own description
states. They now render regardless.

## `fix`: stop the Vercel onboarding modal spinning forever (TRI-13488)

`computeInitialState` starts in `loading-projects` whenever the org has
a Vercel integration but no onboarding data yet, and the effect that
escapes it waits for `availableProjects !== undefined`. When
`getOnboardingData` returns `null` — it does that on any thrown error,
and when the org integration row is missing — nothing ever arrives.

The empty-array case self-resolves (`[] !== undefined`), so this is
specifically the null case. The route can tell "still loading" from
"loaded nothing" because its fetcher always requests
`?vercelOnboarding=true`; it now passes that down and the modal explains
the failure with a retry and a link to check the integration's access on
Vercel.

## `fix`: match staging and preview environments consistently
(TRI-13488)

The four places that ask "does this project have a staging / preview
environment?" disagreed. `VercelSettingsPresenter` matched on type with
no parent filter, so any preview *branch* row satisfied it — branches
are `PREVIEW` rows too. `GitHubSettingsPresenter` and
`ProjectSettingsService` matched on slug instead.

Slug is the weaker key: it's derived at creation time and legacy rows
can carry something else, which is why
`memberDevelopmentEnvironmentWhere` deliberately avoids it. All four now
match on `type` plus `parentEnvironmentId: null`, which excludes
branches without depending on the slug being canonical.

## `fix`: explain when no Vercel environment can be mapped to Staging
(TRI-13488)

Reported while reviewing the branch. The Staging build settings show
*"Set a Vercel environment for Staging first."* whenever the project has
a staging environment and no mapping — but the control that sets the
mapping only rendered when the Vercel project had at least one custom
environment:

```
hint:     hasStagingEnvironment && !configValues.vercelStagingEnvironment
control:  hasStagingEnvironment && customEnvironments.length > 0
```

So a Vercel project with no custom environments, or one whose custom
environments failed to fetch (the presenter swallows that error to
`[]`), got an instruction with nothing to act on. Both conditions
predate this PR.

The mapping row now always renders alongside the hint and explains what
to do when there's nothing to choose from, and the build-settings hint
says the same thing.

## `chore`: remove the remaining dead code (TRI-13488)

- The `"installing"` `OnboardingState` is unproducible — no `setState`
call yields it — so its redirect effect, switch arm, `isLoadingState`
conjunct and the `vercelAppInstallPath` import it was the only user of
are all dead.
- `(state as string) !== "completed"` sits in a branch where TypeScript
has already narrowed `"completed"` out; the cast is what let it compile.
- `hideSectionToggles` was only ever passed alongside
`layout="settings"` but only read inside `layout="card"` blocks, so it
could never take effect. Removed the prop entirely.
- Unused bindings and the helpers only they referenced: `envSlugLabel`,
`_formatSelectedEnvs`, `_CompleteOnboardingForm`,
`_handleFinishOnboarding`, and the rest.

No behaviour change in that commit.

## Not included

The three overlapping modal-open effects in
`settings.integrations/route.tsx` are left alone — they're defensive
against a close-then-reopen race, and untangling them is a behavioural
risk with no user-visible payoff.

## Verification

`pnpm run typecheck --filter webapp`, `pnpm run lint` and `pnpm run
knip` are clean. New `apps/webapp/test/vercelIntegrationConfig.test.ts`
covers the slug restriction and the default-config seeding (both pure
functions); 39 tests pass across it and the three existing
Vercel/project-settings files.

The new `projectId` + `slug` query is served by the existing
`@@unique([projectId, slug, orgMemberId])` prefix — same access pattern
as the preview check it mirrors.

refs TRI-12645, TRI-12646, TRI-13488
2026-08-26 13:26:44 +00:00

355 lines
10 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);
const oldStagingBranch = connectedRepo.branchTracking?.staging?.branch;
return this.isStagingEnvironmentEnabled(projectId).andThen((stagingEnvironmentEnabled) =>
ResultAsync.combine([
validateProductionBranch({
installationId,
fullRepoName: connectedRepo.repository.fullName,
oldProductionBranch: connectedRepo.branchTracking?.prod?.branch,
}),
stagingEnvironmentEnabled
? validateStagingBranch({
installationId,
fullRepoName: connectedRepo.repository.fullName,
oldStagingBranch,
})
: okAsync(oldStagingBranch),
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,
type: "PREVIEW",
parentEnvironmentId: null,
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
).map((previewEnvironment) => previewEnvironment !== null);
}
private isStagingEnvironmentEnabled(projectId: string) {
return fromPromise(
this.#prismaClient.runtimeEnvironment.findFirst({
select: {
id: true,
},
where: {
projectId: projectId,
type: "STAGING",
parentEnvironmentId: null,
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
).map((stagingEnvironment) => stagingEnvironment !== null);
}
}