4c16387426
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
900 lines
26 KiB
TypeScript
900 lines
26 KiB
TypeScript
import type {
|
|
PrismaClient,
|
|
OrganizationProjectIntegration,
|
|
OrganizationIntegration,
|
|
SecretReference,
|
|
} from "@trigger.dev/database";
|
|
import { ResultAsync } from "neverthrow";
|
|
import { prisma, $transaction } from "~/db.server";
|
|
import { logger } from "~/services/logger.server";
|
|
import { VercelIntegrationRepository } from "~/models/vercelIntegration.server";
|
|
import { findCurrentWorkerDeployment } from "~/v3/models/workerDeployment.server";
|
|
import type {
|
|
VercelProjectIntegrationData,
|
|
VercelIntegrationConfig,
|
|
SyncEnvVarsMapping,
|
|
TriggerEnvironmentType,
|
|
EnvSlug,
|
|
} from "~/v3/vercel/vercelProjectIntegrationSchema";
|
|
import {
|
|
VercelProjectIntegrationDataSchema,
|
|
envTypeToSlug,
|
|
createDefaultVercelIntegrationData,
|
|
getAvailableEnvSlugs,
|
|
restrictConfigToAvailableEnvSlugs,
|
|
SKEW_PROTECTION_ENV_VAR_KEY,
|
|
} from "~/v3/vercel/vercelProjectIntegrationSchema";
|
|
|
|
export type VercelProjectIntegrationWithParsedData = OrganizationProjectIntegration & {
|
|
parsedIntegrationData: VercelProjectIntegrationData;
|
|
};
|
|
|
|
export type VercelProjectIntegrationWithData = VercelProjectIntegrationWithParsedData & {
|
|
organizationIntegration: OrganizationIntegration;
|
|
};
|
|
|
|
export type VercelProjectIntegrationWithProject = VercelProjectIntegrationWithData & {
|
|
project: {
|
|
id: string;
|
|
name: string;
|
|
slug: string;
|
|
};
|
|
};
|
|
|
|
export class VercelIntegrationService {
|
|
#prismaClient: PrismaClient;
|
|
|
|
constructor(prismaClient: PrismaClient = prisma) {
|
|
this.#prismaClient = prismaClient;
|
|
}
|
|
|
|
async getVercelProjectIntegration(
|
|
projectId: string
|
|
): Promise<VercelProjectIntegrationWithData | null> {
|
|
const integration = await this.#prismaClient.organizationProjectIntegration.findFirst({
|
|
where: {
|
|
projectId,
|
|
deletedAt: null,
|
|
organizationIntegration: {
|
|
service: "VERCEL",
|
|
deletedAt: null,
|
|
},
|
|
},
|
|
include: {
|
|
organizationIntegration: true,
|
|
},
|
|
});
|
|
|
|
if (!integration) {
|
|
return null;
|
|
}
|
|
|
|
const parsedData = VercelProjectIntegrationDataSchema.safeParse(integration.integrationData);
|
|
|
|
if (!parsedData.success) {
|
|
logger.error("Failed to parse Vercel integration data", {
|
|
projectId,
|
|
integrationId: integration.id,
|
|
error: parsedData.error,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
...integration,
|
|
parsedIntegrationData: parsedData.data,
|
|
};
|
|
}
|
|
|
|
async getConnectedVercelProjects(
|
|
organizationId: string
|
|
): Promise<VercelProjectIntegrationWithProject[]> {
|
|
const integrations = await this.#prismaClient.organizationProjectIntegration.findMany({
|
|
where: {
|
|
deletedAt: null,
|
|
organizationIntegration: {
|
|
organizationId,
|
|
service: "VERCEL",
|
|
deletedAt: null,
|
|
},
|
|
},
|
|
include: {
|
|
organizationIntegration: true,
|
|
project: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
slug: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
return integrations
|
|
.map((integration) => {
|
|
const parsedData = VercelProjectIntegrationDataSchema.safeParse(
|
|
integration.integrationData
|
|
);
|
|
if (!parsedData.success) {
|
|
logger.error("Failed to parse Vercel integration data", {
|
|
integrationId: integration.id,
|
|
error: parsedData.error,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
...integration,
|
|
parsedIntegrationData: parsedData.data,
|
|
};
|
|
})
|
|
.filter((i): i is VercelProjectIntegrationWithProject => i !== null);
|
|
}
|
|
|
|
async #getAvailableEnvSlugs(projectId: string): Promise<EnvSlug[]> {
|
|
const environments = await this.#prismaClient.runtimeEnvironment.findMany({
|
|
where: { projectId, type: { in: ["STAGING", "PREVIEW"] }, parentEnvironmentId: null },
|
|
select: { type: true },
|
|
});
|
|
|
|
const types = new Set(environments.map((environment) => environment.type));
|
|
|
|
return getAvailableEnvSlugs(types.has("STAGING"), types.has("PREVIEW"));
|
|
}
|
|
|
|
async createVercelProjectIntegration(params: {
|
|
organizationIntegrationId: string;
|
|
projectId: string;
|
|
vercelProjectId: string;
|
|
vercelProjectName: string;
|
|
vercelTeamId: string | null;
|
|
vercelTeamSlug?: string;
|
|
installedByUserId?: string;
|
|
}): Promise<OrganizationProjectIntegration> {
|
|
const integrationData = createDefaultVercelIntegrationData(
|
|
params.vercelProjectId,
|
|
params.vercelProjectName,
|
|
params.vercelTeamId,
|
|
params.vercelTeamSlug,
|
|
await this.#getAvailableEnvSlugs(params.projectId)
|
|
);
|
|
|
|
return this.#prismaClient.organizationProjectIntegration.create({
|
|
data: {
|
|
organizationIntegrationId: params.organizationIntegrationId,
|
|
projectId: params.projectId,
|
|
externalEntityId: params.vercelProjectId,
|
|
integrationData: integrationData,
|
|
installedBy: params.installedByUserId,
|
|
},
|
|
});
|
|
}
|
|
|
|
async selectVercelProject(params: {
|
|
organizationId: string;
|
|
projectId: string;
|
|
vercelProjectId: string;
|
|
vercelProjectName: string;
|
|
userId: string;
|
|
}): Promise<{
|
|
integration: OrganizationProjectIntegration;
|
|
syncResult: { success: boolean; errors: string[] };
|
|
}> {
|
|
const orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationByOrganization(
|
|
params.organizationId
|
|
);
|
|
|
|
if (!orgIntegration) {
|
|
throw new Error("No Vercel organization integration found");
|
|
}
|
|
|
|
const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration);
|
|
|
|
const vercelTeamSlug = await VercelIntegrationRepository.getVercelClient(orgIntegration)
|
|
.andThen((client) => VercelIntegrationRepository.getTeamSlug(client, teamId))
|
|
.match(
|
|
(slug) => slug,
|
|
() => undefined
|
|
);
|
|
|
|
const availableEnvSlugs = await this.#getAvailableEnvSlugs(params.projectId);
|
|
|
|
// Use a serializable transaction to prevent duplicate project integrations
|
|
// from concurrent selectVercelProject calls (read-then-write race condition).
|
|
const txResult = await $transaction(
|
|
this.#prismaClient,
|
|
"selectVercelProject",
|
|
async (tx) => {
|
|
const existing = await tx.organizationProjectIntegration.findFirst({
|
|
where: {
|
|
projectId: params.projectId,
|
|
deletedAt: null,
|
|
organizationIntegration: {
|
|
service: "VERCEL",
|
|
deletedAt: null,
|
|
},
|
|
},
|
|
include: {
|
|
organizationIntegration: true,
|
|
},
|
|
});
|
|
|
|
if (existing) {
|
|
const parsedData = VercelProjectIntegrationDataSchema.safeParse(existing.integrationData);
|
|
|
|
const updated = await tx.organizationProjectIntegration.update({
|
|
where: { id: existing.id },
|
|
data: {
|
|
externalEntityId: params.vercelProjectId,
|
|
integrationData: {
|
|
...(parsedData.success ? parsedData.data : {}),
|
|
vercelProjectId: params.vercelProjectId,
|
|
vercelProjectName: params.vercelProjectName,
|
|
vercelTeamId: teamId,
|
|
vercelTeamSlug,
|
|
},
|
|
},
|
|
});
|
|
|
|
return {
|
|
integration: updated,
|
|
wasCreated: false,
|
|
vercelStagingEnvironment: parsedData.success
|
|
? parsedData.data.config.vercelStagingEnvironment
|
|
: null,
|
|
atomicBuildsEnabled: parsedData.success
|
|
? (parsedData.data.config.atomicBuilds ?? []).includes("prod")
|
|
: false,
|
|
};
|
|
}
|
|
|
|
const integrationData = createDefaultVercelIntegrationData(
|
|
params.vercelProjectId,
|
|
params.vercelProjectName,
|
|
teamId,
|
|
vercelTeamSlug,
|
|
availableEnvSlugs
|
|
);
|
|
|
|
const created = await tx.organizationProjectIntegration.create({
|
|
data: {
|
|
organizationIntegrationId: orgIntegration.id,
|
|
projectId: params.projectId,
|
|
externalEntityId: params.vercelProjectId,
|
|
integrationData: integrationData,
|
|
installedBy: params.userId,
|
|
},
|
|
});
|
|
|
|
return {
|
|
integration: created,
|
|
wasCreated: true,
|
|
vercelStagingEnvironment: null,
|
|
atomicBuildsEnabled: (integrationData.config.atomicBuilds ?? []).includes("prod"),
|
|
};
|
|
},
|
|
{ isolationLevel: "Serializable" }
|
|
);
|
|
|
|
if (!txResult) {
|
|
throw new Error("Failed to select Vercel project: transaction returned undefined");
|
|
}
|
|
|
|
const { integration, wasCreated, vercelStagingEnvironment, atomicBuildsEnabled } = txResult;
|
|
|
|
const syncResultAsync = await VercelIntegrationRepository.syncApiKeysToVercel({
|
|
projectId: params.projectId,
|
|
vercelProjectId: params.vercelProjectId,
|
|
teamId,
|
|
vercelStagingEnvironment,
|
|
orgIntegration,
|
|
});
|
|
const syncResult = syncResultAsync.isOk()
|
|
? { success: syncResultAsync.value.errors.length === 0, errors: syncResultAsync.value.errors }
|
|
: { success: false, errors: [syncResultAsync.error.message] };
|
|
|
|
if (wasCreated) {
|
|
if (atomicBuildsEnabled) {
|
|
const disableResult = await VercelIntegrationRepository.getVercelClient(
|
|
orgIntegration
|
|
).andThen((client) =>
|
|
VercelIntegrationRepository.disableAutoAssignCustomDomains(
|
|
client,
|
|
params.vercelProjectId,
|
|
teamId
|
|
)
|
|
);
|
|
|
|
if (disableResult.isErr()) {
|
|
logger.warn("Failed to disable autoAssignCustomDomains during project selection", {
|
|
projectId: params.projectId,
|
|
vercelProjectId: params.vercelProjectId,
|
|
error: disableResult.error.message,
|
|
});
|
|
}
|
|
}
|
|
|
|
logger.info("Vercel project selected and API keys synced", {
|
|
projectId: params.projectId,
|
|
vercelProjectId: params.vercelProjectId,
|
|
vercelProjectName: params.vercelProjectName,
|
|
syncSuccess: syncResult.success,
|
|
syncErrors: syncResult.errors,
|
|
});
|
|
}
|
|
|
|
return { integration, syncResult };
|
|
}
|
|
|
|
async updateVercelIntegrationConfig(
|
|
projectId: string,
|
|
configUpdates: Partial<VercelIntegrationConfig>
|
|
): Promise<VercelProjectIntegrationWithParsedData | null> {
|
|
const existing = await this.getVercelProjectIntegration(projectId);
|
|
if (!existing) {
|
|
return null;
|
|
}
|
|
|
|
const updatedConfig = {
|
|
...existing.parsedIntegrationData.config,
|
|
...restrictConfigToAvailableEnvSlugs(
|
|
configUpdates,
|
|
await this.#getAvailableEnvSlugs(projectId)
|
|
),
|
|
};
|
|
|
|
const updatedData: VercelProjectIntegrationData = {
|
|
...existing.parsedIntegrationData,
|
|
config: updatedConfig,
|
|
};
|
|
|
|
const updated = await this.#prismaClient.organizationProjectIntegration.update({
|
|
where: { id: existing.id },
|
|
data: {
|
|
integrationData: updatedData,
|
|
},
|
|
});
|
|
|
|
if (!updatedConfig.atomicBuilds?.includes("prod")) {
|
|
return { ...updated, parsedIntegrationData: updatedData };
|
|
}
|
|
|
|
const orgIntegration =
|
|
await VercelIntegrationRepository.findVercelOrgIntegrationForProject(projectId);
|
|
|
|
if (orgIntegration) {
|
|
await this.#syncTriggerVersionToVercelProduction(
|
|
projectId,
|
|
updatedConfig.atomicBuilds,
|
|
orgIntegration
|
|
);
|
|
}
|
|
|
|
return {
|
|
...updated,
|
|
parsedIntegrationData: updatedData,
|
|
};
|
|
}
|
|
|
|
async syncStagingKeyForCustomEnvironment(
|
|
projectId: string,
|
|
previousCustomEnvironmentId?: string | null,
|
|
newCustomEnvironmentId?: string | null
|
|
) {
|
|
const existing = await this.getVercelProjectIntegration(projectId);
|
|
if (!existing) {
|
|
return;
|
|
}
|
|
|
|
const orgIntegration =
|
|
await VercelIntegrationRepository.findVercelOrgIntegrationForProject(projectId);
|
|
if (!orgIntegration) {
|
|
return;
|
|
}
|
|
|
|
const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration);
|
|
const vercelProjectId = existing.parsedIntegrationData.vercelProjectId;
|
|
|
|
// Remove the key from the old custom environment (if it changed or was removed)
|
|
if (previousCustomEnvironmentId && previousCustomEnvironmentId !== newCustomEnvironmentId) {
|
|
const removeResult = await VercelIntegrationRepository.removeEnvVarForCustomEnvironment({
|
|
orgIntegration,
|
|
vercelProjectId,
|
|
teamId,
|
|
key: "TRIGGER_SECRET_KEY",
|
|
customEnvironmentId: previousCustomEnvironmentId,
|
|
});
|
|
|
|
if (removeResult.isErr()) {
|
|
logger.error(
|
|
"Failed to remove staging TRIGGER_SECRET_KEY from previous custom environment",
|
|
{
|
|
projectId,
|
|
previousCustomEnvironmentId,
|
|
error: removeResult.error.message,
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
// Create/update the key for the new custom environment
|
|
if (newCustomEnvironmentId) {
|
|
const stagingEnv = await this.#prismaClient.runtimeEnvironment.findFirst({
|
|
where: {
|
|
projectId,
|
|
type: "STAGING",
|
|
},
|
|
select: {
|
|
apiKey: true,
|
|
},
|
|
});
|
|
|
|
if (!stagingEnv) {
|
|
logger.warn("No STAGING runtime environment found for project", { projectId });
|
|
return;
|
|
}
|
|
|
|
const upsertResult = await VercelIntegrationRepository.upsertEnvVarForCustomEnvironment({
|
|
orgIntegration,
|
|
vercelProjectId,
|
|
teamId,
|
|
key: "TRIGGER_SECRET_KEY",
|
|
value: stagingEnv.apiKey,
|
|
customEnvironmentId: newCustomEnvironmentId,
|
|
type: "sensitive",
|
|
});
|
|
|
|
if (upsertResult.isErr()) {
|
|
logger.warn("Failed to sync staging TRIGGER_SECRET_KEY to custom environment", {
|
|
projectId,
|
|
newCustomEnvironmentId,
|
|
error: upsertResult.error.message,
|
|
});
|
|
}
|
|
|
|
const skewResult = await VercelIntegrationRepository.ensureEnvVarForCustomEnvironment({
|
|
orgIntegration,
|
|
vercelProjectId,
|
|
teamId,
|
|
key: SKEW_PROTECTION_ENV_VAR_KEY,
|
|
value: "1",
|
|
type: "plain",
|
|
customEnvironmentId: newCustomEnvironmentId,
|
|
});
|
|
|
|
if (skewResult.isErr()) {
|
|
logger.error("Failed to write skew protection env var to staging custom environment", {
|
|
projectId,
|
|
newCustomEnvironmentId,
|
|
key: SKEW_PROTECTION_ENV_VAR_KEY,
|
|
error: skewResult.error.message,
|
|
});
|
|
} else if (skewResult.value.unresolved.length > 0 || skewResult.value.failed.length > 0) {
|
|
logger.error("Skew protection env var did not reach the staging custom environment", {
|
|
projectId,
|
|
newCustomEnvironmentId,
|
|
key: SKEW_PROTECTION_ENV_VAR_KEY,
|
|
...skewResult.value,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
async updateSyncEnvVarsMapping(
|
|
projectId: string,
|
|
syncEnvVarsMapping: SyncEnvVarsMapping
|
|
): Promise<VercelProjectIntegrationWithParsedData | null> {
|
|
const existing = await this.getVercelProjectIntegration(projectId);
|
|
if (!existing) {
|
|
return null;
|
|
}
|
|
|
|
const updatedData: VercelProjectIntegrationData = {
|
|
...existing.parsedIntegrationData,
|
|
syncEnvVarsMapping,
|
|
};
|
|
|
|
const updated = await this.#prismaClient.organizationProjectIntegration.update({
|
|
where: { id: existing.id },
|
|
data: {
|
|
integrationData: updatedData,
|
|
},
|
|
});
|
|
|
|
return {
|
|
...updated,
|
|
parsedIntegrationData: updatedData,
|
|
};
|
|
}
|
|
|
|
async updateSyncEnvVarForEnvironment(
|
|
projectId: string,
|
|
envVarKey: string,
|
|
environmentType: TriggerEnvironmentType,
|
|
syncEnabled: boolean
|
|
): Promise<VercelProjectIntegrationWithParsedData | null> {
|
|
const existing = await this.getVercelProjectIntegration(projectId);
|
|
if (!existing) {
|
|
return null;
|
|
}
|
|
|
|
const currentMapping = existing.parsedIntegrationData.syncEnvVarsMapping || {};
|
|
const envSlug = envTypeToSlug(environmentType);
|
|
|
|
const currentEnvSettings = currentMapping[envSlug] || {};
|
|
|
|
const updatedMapping: SyncEnvVarsMapping = {
|
|
...currentMapping,
|
|
[envSlug]: {
|
|
...currentEnvSettings,
|
|
[envVarKey]: syncEnabled,
|
|
},
|
|
};
|
|
|
|
const updatedData: VercelProjectIntegrationData = {
|
|
...existing.parsedIntegrationData,
|
|
syncEnvVarsMapping: updatedMapping,
|
|
};
|
|
|
|
const updated = await this.#prismaClient.organizationProjectIntegration.update({
|
|
where: { id: existing.id },
|
|
data: {
|
|
integrationData: updatedData,
|
|
},
|
|
});
|
|
|
|
return {
|
|
...updated,
|
|
parsedIntegrationData: updatedData,
|
|
};
|
|
}
|
|
|
|
async removeSyncEnvVarForEnvironment(
|
|
projectId: string,
|
|
envVarKey: string,
|
|
environmentType: TriggerEnvironmentType
|
|
): Promise<void> {
|
|
const existing = await this.getVercelProjectIntegration(projectId);
|
|
if (!existing) return;
|
|
|
|
const currentMapping = existing.parsedIntegrationData.syncEnvVarsMapping || {};
|
|
const envSlug = envTypeToSlug(environmentType);
|
|
const currentEnvSettings = currentMapping[envSlug];
|
|
if (!currentEnvSettings || !(envVarKey in currentEnvSettings)) return;
|
|
|
|
const { [envVarKey]: _, ...rest } = currentEnvSettings;
|
|
const updatedMapping = { ...currentMapping, [envSlug]: rest };
|
|
|
|
await this.#prismaClient.organizationProjectIntegration.update({
|
|
where: { id: existing.id },
|
|
data: {
|
|
integrationData: {
|
|
...existing.parsedIntegrationData,
|
|
syncEnvVarsMapping: updatedMapping,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
async completeOnboarding(
|
|
projectId: string,
|
|
params: {
|
|
vercelStagingEnvironment?: { environmentId: string; displayName: string } | null;
|
|
pullEnvVarsBeforeBuild?: EnvSlug[] | null;
|
|
atomicBuilds?: EnvSlug[] | null;
|
|
discoverEnvVars?: EnvSlug[] | null;
|
|
syncEnvVarsMapping?: SyncEnvVarsMapping;
|
|
origin?: "marketplace" | "dashboard";
|
|
}
|
|
): Promise<VercelProjectIntegrationWithParsedData | null> {
|
|
const existing = await this.getVercelProjectIntegration(projectId);
|
|
if (!existing) {
|
|
return null;
|
|
}
|
|
|
|
const syncEnvVarsMapping = params.syncEnvVarsMapping ?? {
|
|
dev: {},
|
|
stg: {},
|
|
prod: {},
|
|
preview: {},
|
|
};
|
|
const availableEnvSlugs = await this.#getAvailableEnvSlugs(projectId);
|
|
const updatedData: VercelProjectIntegrationData = {
|
|
...existing.parsedIntegrationData,
|
|
config: {
|
|
...existing.parsedIntegrationData.config,
|
|
...restrictConfigToAvailableEnvSlugs(
|
|
{
|
|
pullEnvVarsBeforeBuild: params.pullEnvVarsBeforeBuild ?? null,
|
|
atomicBuilds: params.atomicBuilds ?? null,
|
|
discoverEnvVars: params.discoverEnvVars ?? null,
|
|
vercelStagingEnvironment: params.vercelStagingEnvironment ?? null,
|
|
},
|
|
availableEnvSlugs
|
|
),
|
|
},
|
|
//This is intentionally not updated here, in case of resetting the onboarding it should not override the existing mapping with an empty one
|
|
syncEnvVarsMapping: existing.parsedIntegrationData.syncEnvVarsMapping,
|
|
onboardingCompleted: true,
|
|
onboardingOrigin: params.origin ?? existing.parsedIntegrationData.onboardingOrigin,
|
|
};
|
|
|
|
const updated = await this.#prismaClient.organizationProjectIntegration.update({
|
|
where: { id: existing.id },
|
|
data: {
|
|
integrationData: updatedData,
|
|
},
|
|
});
|
|
|
|
const orgIntegration =
|
|
await VercelIntegrationRepository.findVercelOrgIntegrationForProject(projectId);
|
|
|
|
if (orgIntegration) {
|
|
const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration);
|
|
|
|
const pullResult = await VercelIntegrationRepository.pullEnvVarsFromVercel({
|
|
projectId,
|
|
vercelProjectId: updatedData.vercelProjectId,
|
|
teamId,
|
|
vercelStagingEnvironment: updatedData.config.vercelStagingEnvironment,
|
|
syncEnvVarsMapping,
|
|
orgIntegration,
|
|
});
|
|
|
|
if (pullResult.isErr()) {
|
|
logger.error("Failed to pull env vars from Vercel during onboarding", {
|
|
projectId,
|
|
error: pullResult.error.message,
|
|
});
|
|
} else if (pullResult.value.errors.length > 0) {
|
|
logger.warn("Errors pulling env vars from Vercel during onboarding", {
|
|
projectId,
|
|
errors: pullResult.value.errors,
|
|
});
|
|
}
|
|
|
|
await this.#syncTriggerVersionToVercelProduction(
|
|
projectId,
|
|
updatedData.config.atomicBuilds,
|
|
orgIntegration
|
|
);
|
|
}
|
|
|
|
return {
|
|
...updated,
|
|
parsedIntegrationData: updatedData,
|
|
};
|
|
}
|
|
|
|
async #syncTriggerVersionToVercelProduction(
|
|
projectId: string,
|
|
atomicBuilds: string[] | null | undefined,
|
|
orgIntegration: OrganizationIntegration & { tokenReference: SecretReference }
|
|
): Promise<void> {
|
|
if (!atomicBuilds?.includes("prod")) {
|
|
return;
|
|
}
|
|
|
|
const prodEnvironment = await this.#prismaClient.runtimeEnvironment.findFirst({
|
|
where: {
|
|
projectId,
|
|
type: "PRODUCTION",
|
|
},
|
|
select: {
|
|
id: true,
|
|
},
|
|
});
|
|
|
|
if (!prodEnvironment) {
|
|
return;
|
|
}
|
|
|
|
const currentDeployment = await findCurrentWorkerDeployment({
|
|
environmentId: prodEnvironment.id,
|
|
});
|
|
|
|
if (!currentDeployment?.version) {
|
|
return;
|
|
}
|
|
|
|
const clientResult = await VercelIntegrationRepository.getVercelClient(orgIntegration);
|
|
if (clientResult.isErr()) {
|
|
logger.error("Failed to get Vercel client for TRIGGER_VERSION sync", {
|
|
projectId,
|
|
error: clientResult.error.message,
|
|
});
|
|
return;
|
|
}
|
|
const client = clientResult.value;
|
|
const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration);
|
|
|
|
// Get the Vercel project ID from the project integration
|
|
const projectIntegration = await this.#prismaClient.organizationProjectIntegration.findFirst({
|
|
where: {
|
|
projectId,
|
|
organizationIntegrationId: orgIntegration.id,
|
|
deletedAt: null,
|
|
},
|
|
select: {
|
|
externalEntityId: true,
|
|
},
|
|
});
|
|
|
|
if (!projectIntegration) {
|
|
return;
|
|
}
|
|
|
|
const vercelProjectId = projectIntegration.externalEntityId;
|
|
|
|
// Check if TRIGGER_VERSION already exists targeting production
|
|
const envVarsResult = await VercelIntegrationRepository.getVercelEnvironmentVariables(
|
|
client,
|
|
vercelProjectId,
|
|
teamId
|
|
);
|
|
|
|
if (envVarsResult.isErr()) {
|
|
logger.warn("Failed to fetch Vercel env vars for TRIGGER_VERSION sync", {
|
|
projectId,
|
|
vercelProjectId,
|
|
error: envVarsResult.error.message,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const existingTriggerVersion = envVarsResult.value.find(
|
|
(env) => env.key === "TRIGGER_VERSION" && env.target.includes("production")
|
|
);
|
|
|
|
if (existingTriggerVersion) {
|
|
return;
|
|
}
|
|
|
|
// Push TRIGGER_VERSION to Vercel production
|
|
const createResult = await ResultAsync.fromPromise(
|
|
client.projects.createProjectEnv({
|
|
idOrName: vercelProjectId,
|
|
...(teamId && { teamId }),
|
|
upsert: "true",
|
|
requestBody: {
|
|
key: "TRIGGER_VERSION",
|
|
value: currentDeployment.version,
|
|
target: ["production"] as any,
|
|
type: "encrypted",
|
|
},
|
|
}),
|
|
(error) => error
|
|
);
|
|
|
|
if (createResult.isErr()) {
|
|
logger.error("Failed to sync TRIGGER_VERSION to Vercel production", {
|
|
projectId,
|
|
vercelProjectId,
|
|
error:
|
|
createResult.error instanceof Error
|
|
? createResult.error.message
|
|
: String(createResult.error),
|
|
});
|
|
return;
|
|
}
|
|
|
|
logger.info("Synced TRIGGER_VERSION to Vercel production", {
|
|
projectId,
|
|
vercelProjectId,
|
|
version: currentDeployment.version,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns true when TRIGGER_VERSION is no longer pinned on Vercel production after the call
|
|
* (either we cleared it or it wasn't set to begin with). Returns false when we failed to
|
|
* verify or perform the delete — callers should surface that to the user so they can clear
|
|
* it manually.
|
|
*/
|
|
async clearTriggerVersionFromVercelProduction(projectId: string): Promise<boolean> {
|
|
const orgIntegration =
|
|
await VercelIntegrationRepository.findVercelOrgIntegrationForProject(projectId);
|
|
if (!orgIntegration) {
|
|
return false;
|
|
}
|
|
|
|
const clientResult = await VercelIntegrationRepository.getVercelClient(orgIntegration);
|
|
if (clientResult.isErr()) {
|
|
logger.error("Failed to get Vercel client for TRIGGER_VERSION clear", {
|
|
projectId,
|
|
error: clientResult.error.message,
|
|
});
|
|
return false;
|
|
}
|
|
const client = clientResult.value;
|
|
const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration);
|
|
|
|
const projectIntegration = await this.#prismaClient.organizationProjectIntegration.findFirst({
|
|
where: {
|
|
projectId,
|
|
organizationIntegrationId: orgIntegration.id,
|
|
deletedAt: null,
|
|
},
|
|
select: {
|
|
externalEntityId: true,
|
|
},
|
|
});
|
|
|
|
if (!projectIntegration) {
|
|
return false;
|
|
}
|
|
|
|
const vercelProjectId = projectIntegration.externalEntityId;
|
|
|
|
const envVarsResult = await VercelIntegrationRepository.getVercelEnvironmentVariables(
|
|
client,
|
|
vercelProjectId,
|
|
teamId
|
|
);
|
|
|
|
if (envVarsResult.isErr()) {
|
|
logger.warn("Failed to fetch Vercel env vars for TRIGGER_VERSION clear", {
|
|
projectId,
|
|
vercelProjectId,
|
|
error: envVarsResult.error.message,
|
|
});
|
|
return false;
|
|
}
|
|
|
|
const existingTriggerVersion = envVarsResult.value.find(
|
|
(env) => env.key === "TRIGGER_VERSION" && env.target.includes("production")
|
|
);
|
|
|
|
if (!existingTriggerVersion) {
|
|
logger.info("TRIGGER_VERSION not present on Vercel production — nothing to clear", {
|
|
projectId,
|
|
vercelProjectId,
|
|
});
|
|
return true;
|
|
}
|
|
|
|
const removeResult = await ResultAsync.fromPromise(
|
|
client.projects.batchRemoveProjectEnv({
|
|
idOrName: vercelProjectId,
|
|
...(teamId && { teamId }),
|
|
requestBody: { ids: [existingTriggerVersion.id] },
|
|
}),
|
|
(error) => error
|
|
);
|
|
|
|
if (removeResult.isErr()) {
|
|
logger.error("Failed to clear TRIGGER_VERSION from Vercel production", {
|
|
projectId,
|
|
vercelProjectId,
|
|
error:
|
|
removeResult.error instanceof Error
|
|
? removeResult.error.message
|
|
: String(removeResult.error),
|
|
});
|
|
return false;
|
|
}
|
|
|
|
logger.info("Cleared TRIGGER_VERSION from Vercel production", {
|
|
projectId,
|
|
vercelProjectId,
|
|
});
|
|
return true;
|
|
}
|
|
|
|
async disconnectVercelProject(projectId: string): Promise<boolean> {
|
|
const existing = await this.getVercelProjectIntegration(projectId);
|
|
if (!existing) {
|
|
return false;
|
|
}
|
|
|
|
await this.#prismaClient.organizationProjectIntegration.update({
|
|
where: { id: existing.id },
|
|
data: {
|
|
deletedAt: new Date(),
|
|
},
|
|
});
|
|
|
|
return true;
|
|
}
|
|
}
|