Files
2026-07-13 13:32:57 +08:00

744 lines
25 KiB
TypeScript

import { type PrismaClient } from "@trigger.dev/database";
import { type Result, fromPromise, ok, okAsync, ResultAsync } from "neverthrow";
import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
import type {
VercelCustomEnvironment,
VercelEnvironmentVariable,
} from "~/models/vercelIntegration.server";
import { VercelIntegrationRepository } from "~/models/vercelIntegration.server";
import { type GitHubAppInstallation } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github";
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
import { isReservedForExternalSync } from "~/v3/environmentVariableRules.server";
import type { VercelProjectIntegrationData } from "~/v3/vercel/vercelProjectIntegrationSchema";
import { VercelProjectIntegrationDataSchema } from "~/v3/vercel/vercelProjectIntegrationSchema";
import { BasePresenter } from "./basePresenter.server";
type VercelSettingsOptions = {
projectId: string;
organizationId: string;
};
export type VercelSettingsResult = {
enabled: boolean;
hasOrgIntegration: boolean;
authInvalid?: boolean;
authError?: string;
connectedProject?: {
id: string;
vercelProjectId: string;
vercelProjectName: string;
vercelTeamId: string | null;
integrationData: VercelProjectIntegrationData;
createdAt: Date;
};
isGitHubConnected: boolean;
hasStagingEnvironment: boolean;
hasPreviewEnvironment: boolean;
customEnvironments: VercelCustomEnvironment[];
/** Whether autoAssignCustomDomains is enabled on the Vercel project. null if unknown. */
autoAssignCustomDomains?: boolean | null;
/** URL to manage Vercel integration access (project sharing) on vercel.com */
vercelManageAccessUrl?: string;
/** The currently pinned TRIGGER_VERSION on Vercel production, if set. Used to surface
* the pin in the UI and prompt the user to clear it when atomic deployments are disabled. */
currentTriggerVersion?: string | null;
/** True when the Vercel lookup for TRIGGER_VERSION failed (network/auth/etc). Distinct
* from "no pin set" — the UI uses this to warn the user and still prompt them on disable
* so they can manually verify that production isn't pinned. */
currentTriggerVersionFetchFailed?: boolean;
};
export type VercelAvailableProject = {
id: string;
name: string;
};
export type VercelOnboardingData = {
customEnvironments: VercelCustomEnvironment[];
environmentVariables: VercelEnvironmentVariable[];
availableProjects: VercelAvailableProject[];
hasProjectSelected: boolean;
authInvalid?: boolean;
authError?: string;
existingVariables: Record<string, { environments: string[] }>; // Environment slugs (non-archived only)
gitHubAppInstallations: GitHubAppInstallation[];
isGitHubConnected: boolean;
isOnboardingComplete: boolean;
};
export class VercelSettingsPresenter extends BasePresenter {
/**
* Get Vercel integration settings for the settings page
*/
public async call({
projectId,
organizationId,
}: VercelSettingsOptions): Promise<Result<VercelSettingsResult, unknown>> {
const vercelIntegrationEnabled = OrgIntegrationRepository.isVercelSupported;
if (!vercelIntegrationEnabled) {
return ok({
enabled: false,
hasOrgIntegration: false,
authInvalid: false,
connectedProject: undefined,
isGitHubConnected: false,
hasStagingEnvironment: false,
hasPreviewEnvironment: false,
customEnvironments: [],
} as VercelSettingsResult);
}
const orgIntegrationResult = await fromPromise(
(this._replica as PrismaClient).organizationIntegration.findFirst({
where: {
organizationId,
service: "VERCEL",
deletedAt: null,
},
include: {
tokenReference: true,
},
}),
(error) => error
);
if (orgIntegrationResult.isErr()) {
logger.error("Unexpected error in VercelSettingsPresenter.call", {
error: orgIntegrationResult.error,
});
return ok({
enabled: true,
hasOrgIntegration: false,
authInvalid: true,
authError:
orgIntegrationResult.error instanceof Error
? orgIntegrationResult.error.message
: "Failed to fetch organization integration",
connectedProject: undefined,
isGitHubConnected: false,
hasStagingEnvironment: false,
hasPreviewEnvironment: false,
customEnvironments: [],
} as VercelSettingsResult);
}
const orgIntegration = orgIntegrationResult.value;
const hasOrgIntegration = orgIntegration !== null;
if (hasOrgIntegration) {
const tokenResult = await VercelIntegrationRepository.validateVercelToken(orgIntegration);
if (tokenResult.isErr() || !tokenResult.value.isValid) {
return ok({
enabled: true,
hasOrgIntegration: true,
authInvalid: true,
authError: tokenResult.isErr() ? tokenResult.error.message : "Vercel token is invalid",
connectedProject: undefined,
isGitHubConnected: false,
hasStagingEnvironment: false,
hasPreviewEnvironment: false,
customEnvironments: [],
} as VercelSettingsResult);
}
}
const checkOrgIntegration = () =>
fromPromise(Promise.resolve(hasOrgIntegration), (error) => ({
type: "other" as const,
cause: error,
}));
const checkGitHubConnection = () =>
fromPromise(
(this._replica as PrismaClient).connectedGithubRepository.findFirst({
where: {
projectId,
repository: {
installation: {
deletedAt: null,
suspendedAt: null,
},
},
},
select: {
id: true,
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
).map((repo) => repo !== null);
const checkStagingEnvironment = () =>
fromPromise(
(this._replica as PrismaClient).runtimeEnvironment.findFirst({
select: {
id: true,
},
where: {
projectId,
type: "STAGING",
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
).map((env) => env !== null);
const checkPreviewEnvironment = () =>
fromPromise(
(this._replica as PrismaClient).runtimeEnvironment.findFirst({
select: {
id: true,
},
where: {
projectId,
type: "PREVIEW",
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
).map((env) => env !== null);
const getVercelProjectIntegration = () =>
fromPromise(
(this._replica as PrismaClient).organizationProjectIntegration.findFirst({
where: {
projectId,
deletedAt: null,
organizationIntegration: {
service: "VERCEL",
deletedAt: null,
},
},
include: {
organizationIntegration: true,
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
).map((integration) => {
if (!integration) {
return undefined;
}
const parsedData = VercelProjectIntegrationDataSchema.safeParse(
integration.integrationData
);
if (!parsedData.success) {
return undefined;
}
return {
id: integration.id,
vercelProjectId: integration.externalEntityId,
vercelProjectName: parsedData.data.vercelProjectName,
vercelTeamId: parsedData.data.vercelTeamId,
integrationData: parsedData.data,
createdAt: integration.createdAt,
};
});
return ResultAsync.combine([
checkOrgIntegration(),
checkGitHubConnection(),
checkStagingEnvironment(),
checkPreviewEnvironment(),
getVercelProjectIntegration(),
])
.andThen(
([
hasOrgIntegration,
isGitHubConnected,
hasStagingEnvironment,
hasPreviewEnvironment,
connectedProject,
]) => {
const fetchVercelData = async (): Promise<{
customEnvironments: VercelCustomEnvironment[];
autoAssignCustomDomains: boolean | null;
vercelManageAccessUrl?: string;
currentTriggerVersion: string | null;
currentTriggerVersionFetchFailed: boolean;
}> => {
if (!orgIntegration) {
return {
customEnvironments: [],
autoAssignCustomDomains: null,
currentTriggerVersion: null,
currentTriggerVersionFetchFailed: false,
};
}
const clientResult = await VercelIntegrationRepository.getVercelClient(orgIntegration);
if (clientResult.isErr()) {
// We couldn't even build a Vercel client — treat as fetch failure so the UI
// still prompts the user when they disable atomic deployments.
return {
customEnvironments: [],
autoAssignCustomDomains: null,
currentTriggerVersion: null,
currentTriggerVersionFetchFailed: true,
};
}
const client = clientResult.value;
const teamId =
await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration);
// Build manage access URL
let vercelManageAccessUrl: string | undefined;
const appSlug = env.VERCEL_INTEGRATION_APP_SLUG;
const integrationData = orgIntegration.integrationData as Record<
string,
unknown
> | null;
const installationId =
typeof integrationData?.installationId === "string"
? integrationData.installationId
: undefined;
if (appSlug && installationId && teamId) {
const teamSlugResult = await VercelIntegrationRepository.getTeamSlug(client, teamId);
if (teamSlugResult.isOk()) {
vercelManageAccessUrl = `https://vercel.com/${teamSlugResult.value}/~/integrations/${appSlug}/${installationId}`;
}
}
if (!connectedProject) {
return {
customEnvironments: [],
autoAssignCustomDomains: null,
vercelManageAccessUrl,
currentTriggerVersion: null,
currentTriggerVersionFetchFailed: false,
};
}
const [customEnvsResult, autoAssignResult, triggerVersionResult] = await Promise.all([
VercelIntegrationRepository.getVercelCustomEnvironments(
client,
connectedProject.vercelProjectId,
teamId
),
VercelIntegrationRepository.getAutoAssignCustomDomains(
client,
connectedProject.vercelProjectId,
teamId
),
VercelIntegrationRepository.getVercelEnvironmentVariableValues(
client,
connectedProject.vercelProjectId,
teamId,
"production",
(key) => key === "TRIGGER_VERSION"
),
]);
let currentTriggerVersion: string | null = null;
let currentTriggerVersionFetchFailed = false;
if (triggerVersionResult.isOk()) {
const match = triggerVersionResult.value.find(
(envVar) => envVar.key === "TRIGGER_VERSION" && envVar.target.includes("production")
);
currentTriggerVersion = match?.value ?? null;
} else {
currentTriggerVersionFetchFailed = true;
logger.warn(
"Failed to fetch current TRIGGER_VERSION from Vercel — surfacing as unknown",
{
projectId,
vercelProjectId: connectedProject.vercelProjectId,
error: triggerVersionResult.error.message,
}
);
}
return {
customEnvironments: customEnvsResult.isOk() ? customEnvsResult.value : [],
autoAssignCustomDomains: autoAssignResult.isOk() ? autoAssignResult.value : null,
vercelManageAccessUrl,
currentTriggerVersion,
currentTriggerVersionFetchFailed,
};
};
return fromPromise(fetchVercelData(), (error) => ({
type: "other" as const,
cause: error,
})).map(
({
customEnvironments,
autoAssignCustomDomains,
vercelManageAccessUrl,
currentTriggerVersion,
currentTriggerVersionFetchFailed,
}) =>
({
enabled: true,
hasOrgIntegration,
authInvalid: false,
connectedProject,
isGitHubConnected,
hasStagingEnvironment,
hasPreviewEnvironment,
customEnvironments,
autoAssignCustomDomains,
vercelManageAccessUrl,
currentTriggerVersion,
currentTriggerVersionFetchFailed,
}) as VercelSettingsResult
);
}
)
.mapErr((error) => {
// Log the error and return a safe fallback
logger.error("Error in VercelSettingsPresenter.call", { error });
return error;
});
}
/**
* Get data needed for the onboarding modal (custom environments and env vars)
*/
public async getOnboardingData(
projectId: string,
organizationId: string,
vercelEnvironmentId?: string
): Promise<VercelOnboardingData | null> {
const result = await ResultAsync.fromPromise(
(async (): Promise<VercelOnboardingData | null> => {
const [gitHubInstallations, connectedGitHubRepo] = await Promise.all([
(this._replica as PrismaClient).githubAppInstallation.findMany({
where: {
organizationId,
deletedAt: null,
suspendedAt: null,
},
select: {
id: true,
accountHandle: true,
targetType: true,
appInstallationId: true,
repositories: {
select: {
id: true,
name: true,
fullName: true,
htmlUrl: true,
private: true,
},
take: 200,
},
},
take: 20,
orderBy: {
createdAt: "desc",
},
}),
(this._replica as PrismaClient).connectedGithubRepository.findFirst({
where: {
projectId,
repository: {
installation: {
deletedAt: null,
suspendedAt: null,
},
},
},
select: {
id: true,
},
}),
]);
const isGitHubConnected = connectedGitHubRepo !== null;
const gitHubAppInstallations: GitHubAppInstallation[] = gitHubInstallations.map(
(installation) => ({
id: installation.id,
appInstallationId: installation.appInstallationId,
targetType: installation.targetType,
accountHandle: installation.accountHandle,
repositories: installation.repositories.map((repo) => ({
id: repo.id,
name: repo.name,
fullName: repo.fullName,
private: repo.private,
htmlUrl: repo.htmlUrl,
})),
})
);
const orgIntegration = await (
this._replica as PrismaClient
).organizationIntegration.findFirst({
where: {
organizationId,
service: "VERCEL",
deletedAt: null,
},
include: {
tokenReference: true,
},
});
if (!orgIntegration) {
return null;
}
const tokenResult = await VercelIntegrationRepository.validateVercelToken(orgIntegration);
if (tokenResult.isErr() || !tokenResult.value.isValid) {
return {
customEnvironments: [],
environmentVariables: [],
availableProjects: [],
hasProjectSelected: false,
authInvalid: true,
authError: tokenResult.isErr() ? tokenResult.error.message : "Vercel token is invalid",
existingVariables: {},
gitHubAppInstallations,
isGitHubConnected,
isOnboardingComplete: false,
};
}
const clientResult =
await VercelIntegrationRepository.getVercelClientAndToken(orgIntegration);
if (clientResult.isErr()) {
return {
customEnvironments: [],
environmentVariables: [],
availableProjects: [],
hasProjectSelected: false,
authInvalid: clientResult.error.authInvalid,
authError: clientResult.error.authInvalid ? clientResult.error.message : undefined,
existingVariables: {},
gitHubAppInstallations,
isGitHubConnected,
isOnboardingComplete: false,
};
}
const { client, accessToken } = clientResult.value;
const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration);
const projectIntegration = await (
this._replica as PrismaClient
).organizationProjectIntegration.findFirst({
where: {
projectId,
deletedAt: null,
organizationIntegration: {
service: "VERCEL",
deletedAt: null,
},
},
});
const availableProjectsResult = await VercelIntegrationRepository.getVercelProjects(
client,
teamId
);
if (availableProjectsResult.isErr()) {
return {
customEnvironments: [],
environmentVariables: [],
availableProjects: [],
hasProjectSelected: false,
authInvalid: availableProjectsResult.error.authInvalid,
authError: availableProjectsResult.error.authInvalid
? availableProjectsResult.error.message
: undefined,
existingVariables: {},
gitHubAppInstallations,
isGitHubConnected,
isOnboardingComplete: false,
};
}
if (!projectIntegration) {
return {
customEnvironments: [],
environmentVariables: [],
availableProjects: availableProjectsResult.value,
hasProjectSelected: false,
existingVariables: {},
gitHubAppInstallations,
isGitHubConnected,
isOnboardingComplete: false,
};
}
const [customEnvironmentsResult, projectEnvVarsResult, sharedEnvVarsResult] =
await Promise.all([
VercelIntegrationRepository.getVercelCustomEnvironments(
client,
projectIntegration.externalEntityId,
teamId
),
VercelIntegrationRepository.getVercelEnvironmentVariables(
client,
projectIntegration.externalEntityId,
teamId
),
// Only fetch shared env vars if teamId is available
teamId
? VercelIntegrationRepository.getVercelSharedEnvironmentVariables(
accessToken,
teamId,
projectIntegration.externalEntityId
)
: okAsync(
[] as Array<{
id: string;
key: string;
type: string;
isSecret: boolean;
target: string[];
}>
),
]);
const authInvalid =
(customEnvironmentsResult.isErr() && customEnvironmentsResult.error.authInvalid) ||
(projectEnvVarsResult.isErr() && projectEnvVarsResult.error.authInvalid) ||
(sharedEnvVarsResult.isErr() && sharedEnvVarsResult.error.authInvalid);
if (authInvalid) {
const authError =
(customEnvironmentsResult.isErr() &&
customEnvironmentsResult.error.authInvalid &&
customEnvironmentsResult.error.message) ||
(projectEnvVarsResult.isErr() &&
projectEnvVarsResult.error.authInvalid &&
projectEnvVarsResult.error.message) ||
(sharedEnvVarsResult.isErr() &&
sharedEnvVarsResult.error.authInvalid &&
sharedEnvVarsResult.error.message) ||
undefined;
return {
customEnvironments: [],
environmentVariables: [],
availableProjects: availableProjectsResult.value,
hasProjectSelected: true,
authInvalid: true,
authError: authError || undefined,
existingVariables: {},
gitHubAppInstallations,
isGitHubConnected,
isOnboardingComplete: false,
};
}
const customEnvironments = customEnvironmentsResult.isOk()
? customEnvironmentsResult.value
: [];
const projectEnvVars = projectEnvVarsResult.isOk() ? projectEnvVarsResult.value : [];
const sharedEnvVars = sharedEnvVarsResult.isOk() ? sharedEnvVarsResult.value : [];
// Hide platform-managed reserved keys from the onboarding preview.
const projectEnvVarKeys = new Set(projectEnvVars.map((v) => v.key));
const mergedEnvVars: VercelEnvironmentVariable[] = [
...projectEnvVars
.filter((v) => !isReservedForExternalSync(v.key))
.map((v) => {
const envVar = { ...v };
if (
vercelEnvironmentId &&
(v as any).customEnvironmentIds?.includes(vercelEnvironmentId)
) {
envVar.target = [...v.target, "staging"];
}
return envVar;
}),
...sharedEnvVars
.filter((v) => !projectEnvVarKeys.has(v.key) && !isReservedForExternalSync(v.key))
.map((v) => {
const envVar = {
id: v.id,
key: v.key,
type: v.type as VercelEnvironmentVariable["type"],
isSecret: v.isSecret,
target: v.target,
isShared: true,
customEnvironmentIds: [] as string[],
};
if (
vercelEnvironmentId &&
(v as any).customEnvironmentIds?.includes(vercelEnvironmentId)
) {
envVar.target = [...v.target, "staging"];
}
return envVar;
}),
];
const sortedEnvVars = [...mergedEnvVars].sort((a, b) => a.key.localeCompare(b.key));
const projectEnvs = await (this._replica as PrismaClient).runtimeEnvironment.findMany({
where: {
projectId,
archivedAt: null, // Filter out archived environments
},
select: {
id: true,
slug: true,
type: true,
},
});
const envIdToSlug = new Map(projectEnvs.map((e) => [e.id, e.slug]));
const activeEnvIds = new Set(projectEnvs.map((e) => e.id));
const envVarRepository = new EnvironmentVariablesRepository(this._replica as PrismaClient);
const existingVariables = await envVarRepository.getProject(projectId);
const existingVariablesRecord: Record<string, { environments: string[] }> = {};
for (const v of existingVariables) {
// Filter out archived environments and map to slugs
const activeEnvSlugs = v.values
.filter((val) => activeEnvIds.has(val.environment.id))
.map(
(val) => envIdToSlug.get(val.environment.id) || val.environment.type.toLowerCase()
);
if (activeEnvSlugs.length > 0) {
existingVariablesRecord[v.key] = {
environments: activeEnvSlugs,
};
}
}
const parsedIntegrationData = VercelProjectIntegrationDataSchema.safeParse(
projectIntegration.integrationData
);
return {
customEnvironments,
environmentVariables: sortedEnvVars,
availableProjects: availableProjectsResult.value,
hasProjectSelected: true,
existingVariables: existingVariablesRecord,
gitHubAppInstallations,
isGitHubConnected,
isOnboardingComplete: parsedIntegrationData.success
? (parsedIntegrationData.data.onboardingCompleted ?? false)
: false,
};
})(),
(error) => error
);
if (result.isErr()) {
logger.error("Error in getOnboardingData", { error: result.error });
return null;
}
return result.value;
}
}