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; // 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> { 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 { const result = await ResultAsync.fromPromise( (async (): Promise => { 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 = {}; 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; } }