diff --git a/.changeset/vercel-integration.md b/.changeset/vercel-integration.md new file mode 100644 index 0000000000..8b638e3643 --- /dev/null +++ b/.changeset/vercel-integration.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/core": patch +--- + +Add Vercel integration support to API schemas: `commitSHA` and `integrationDeployments` on deployment responses, and `source` field for environment variable imports. diff --git a/.vscode/settings.json b/.vscode/settings.json index 12aefeb358..382a5ae620 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,5 +7,6 @@ "packages/cli-v3/e2e": true }, "vitest.disableWorkspaceWarning": true, - "typescript.experimental.useTsgo": false + "typescript.experimental.useTsgo": true, + "chat.agent.maxRequests": 10000 } diff --git a/apps/webapp/app/components/GitHubLoginButton.tsx b/apps/webapp/app/components/GitHubLoginButton.tsx index 87238db087..76a494927c 100644 --- a/apps/webapp/app/components/GitHubLoginButton.tsx +++ b/apps/webapp/app/components/GitHubLoginButton.tsx @@ -32,8 +32,6 @@ export function OctoKitty({ className }: { className?: string }) { baseProfile="tiny" id="Layer_1" xmlns="http://www.w3.org/2000/svg" - x="0px" - y="0px" viewBox="0 0 2350 2314.8" xmlSpace="preserve" fill="currentColor" diff --git a/apps/webapp/app/components/integrations/VercelBuildSettings.tsx b/apps/webapp/app/components/integrations/VercelBuildSettings.tsx new file mode 100644 index 0000000000..3001894f24 --- /dev/null +++ b/apps/webapp/app/components/integrations/VercelBuildSettings.tsx @@ -0,0 +1,180 @@ +import { Switch } from "~/components/primitives/Switch"; +import { Label } from "~/components/primitives/Label"; +import { Hint } from "~/components/primitives/Hint"; +import { TextLink } from "~/components/primitives/TextLink"; +import { + EnvironmentIcon, + environmentFullTitle, + environmentTextClassName, +} from "~/components/environments/EnvironmentLabel"; +import type { EnvSlug } from "~/v3/vercel/vercelProjectIntegrationSchema"; + +type BuildSettingsFieldsProps = { + availableEnvSlugs: EnvSlug[]; + pullEnvVarsBeforeBuild: EnvSlug[]; + onPullEnvVarsChange: (slugs: EnvSlug[]) => void; + discoverEnvVars: EnvSlug[]; + onDiscoverEnvVarsChange: (slugs: EnvSlug[]) => void; + atomicBuilds: EnvSlug[]; + onAtomicBuildsChange: (slugs: EnvSlug[]) => void; + envVarsConfigLink?: string; +}; + +function slugToEnvType(slug: EnvSlug) { + return slug === "prod" ? "PRODUCTION" : slug === "stg" ? "STAGING" : "PREVIEW"; +} + +export function BuildSettingsFields({ + availableEnvSlugs, + pullEnvVarsBeforeBuild, + onPullEnvVarsChange, + discoverEnvVars, + onDiscoverEnvVarsChange, + atomicBuilds, + onAtomicBuildsChange, + envVarsConfigLink, +}: BuildSettingsFieldsProps) { + return ( + <> + {/* Pull env vars before build */} +
+
+
+ + + Select which environments should pull environment variables from Vercel before each + build.{" "} + {envVarsConfigLink && ( + <> + Configure which variables to pull. + + )} + +
+ {availableEnvSlugs.length > 1 && ( + 0 && + availableEnvSlugs.every((s) => pullEnvVarsBeforeBuild.includes(s)) + } + onCheckedChange={(checked) => { + onPullEnvVarsChange(checked ? [...availableEnvSlugs] : []); + }} + /> + )} +
+
+ {availableEnvSlugs.map((slug) => { + const envType = slugToEnvType(slug); + return ( +
+
+ + + {environmentFullTitle({ type: envType })} + +
+ { + onPullEnvVarsChange( + checked + ? [...pullEnvVarsBeforeBuild, slug] + : pullEnvVarsBeforeBuild.filter((s) => s !== slug) + ); + }} + /> +
+ ); + })} +
+
+ + {/* Discover new env vars */} +
+
+
+ + + Select which environments should automatically discover and create new environment + variables from Vercel during builds. + +
+ {availableEnvSlugs.length > 1 && ( + 0 && + availableEnvSlugs.every( + (s) => discoverEnvVars.includes(s) || !pullEnvVarsBeforeBuild.includes(s) + ) && + availableEnvSlugs.some((s) => discoverEnvVars.includes(s)) + } + disabled={!availableEnvSlugs.some((s) => pullEnvVarsBeforeBuild.includes(s))} + onCheckedChange={(checked) => { + onDiscoverEnvVarsChange( + checked + ? availableEnvSlugs.filter((s) => pullEnvVarsBeforeBuild.includes(s)) + : [] + ); + }} + /> + )} +
+
+ {availableEnvSlugs.map((slug) => { + const envType = slugToEnvType(slug); + const isPullDisabled = !pullEnvVarsBeforeBuild.includes(slug); + return ( +
+
+ + + {environmentFullTitle({ type: envType })} + +
+ { + onDiscoverEnvVarsChange( + checked + ? [...discoverEnvVars, slug] + : discoverEnvVars.filter((s) => s !== slug) + ); + }} + /> +
+ ); + })} +
+
+ + {/* Atomic deployments */} +
+
+
+ + + When enabled, production deployments wait for Vercel deployment to complete before + promoting the Trigger.dev deployment. + +
+ { + onAtomicBuildsChange(checked ? ["prod"] : []); + }} + /> +
+
+ + ); +} diff --git a/apps/webapp/app/components/integrations/VercelLogo.tsx b/apps/webapp/app/components/integrations/VercelLogo.tsx new file mode 100644 index 0000000000..7ddf039abf --- /dev/null +++ b/apps/webapp/app/components/integrations/VercelLogo.tsx @@ -0,0 +1,12 @@ +export function VercelLogo({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx b/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx new file mode 100644 index 0000000000..60de3af12c --- /dev/null +++ b/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx @@ -0,0 +1,1053 @@ +import { + CheckCircleIcon, + ExclamationTriangleIcon, + ChevronDownIcon, + ChevronUpIcon, +} from "@heroicons/react/20/solid"; +import { + useFetcher, + useNavigation, + useSearchParams, +} from "@remix-run/react"; +import { useTypedFetcher } from "remix-typedjson"; +import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { Callout } from "~/components/primitives/Callout"; +import { FormButtons } from "~/components/primitives/FormButtons"; +import { FormError } from "~/components/primitives/FormError"; +import { Header3 } from "~/components/primitives/Headers"; +import { Hint } from "~/components/primitives/Hint"; +import { Label } from "~/components/primitives/Label"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { Select, SelectItem } from "~/components/primitives/Select"; +import { SpinnerWhite } from "~/components/primitives/Spinner"; +import { Switch } from "~/components/primitives/Switch"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, + TooltipProvider, +} from "~/components/primitives/Tooltip"; +import { VercelLogo } from "~/components/integrations/VercelLogo"; +import { BuildSettingsFields } from "~/components/integrations/VercelBuildSettings"; +import { OctoKitty } from "~/components/GitHubLoginButton"; +import { + ConnectGitHubRepoModal, + type GitHubAppInstallation, +} from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github"; +import { + type SyncEnvVarsMapping, + type EnvSlug, + ALL_ENV_SLUGS, + shouldSyncEnvVarForAnyEnvironment, + getAvailableEnvSlugs, + getAvailableEnvSlugsForBuildSettings, +} from "~/v3/vercel/vercelProjectIntegrationSchema"; +import { type VercelCustomEnvironment } from "~/models/vercelIntegration.server"; +import { type VercelOnboardingData } from "~/presenters/v3/VercelSettingsPresenter.server"; +import { vercelAppInstallPath, v3ProjectSettingsPath, githubAppInstallPath } from "~/utils/pathBuilder"; +import { vercelResourcePath } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel"; +import type { loader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel"; +import { useEffect, useState, useCallback, useRef } from "react"; + +function formatVercelTargets(targets: string[]): string { + const targetLabels: Record = { + production: "Production", + preview: "Preview", + development: "Development", + staging: "Staging", + }; + + return targets + .map((t) => targetLabels[t.toLowerCase()] || t) + .join(", "); +} + +type OnboardingState = + | "idle" + | "installing" + | "loading-projects" + | "project-selection" + | "loading-env-mapping" + | "env-mapping" + | "loading-env-vars" + | "env-var-sync" + | "build-settings" + | "github-connection" + | "completed"; + +export function VercelOnboardingModal({ + isOpen, + onClose, + onboardingData, + organizationSlug, + projectSlug, + environmentSlug, + hasStagingEnvironment, + hasPreviewEnvironment, + hasOrgIntegration, + nextUrl, + onDataReload, +}: { + isOpen: boolean; + onClose: () => void; + onboardingData: VercelOnboardingData | null; + organizationSlug: string; + projectSlug: string; + environmentSlug: string; + hasStagingEnvironment: boolean; + hasPreviewEnvironment: boolean; + hasOrgIntegration: boolean; + nextUrl?: string; + onDataReload?: (vercelStagingEnvironment?: string) => void; +}) { + const navigation = useNavigation(); + const fetcher = useTypedFetcher(); + const envMappingFetcher = useFetcher(); + const completeOnboardingFetcher = useFetcher(); + const { Form: CompleteOnboardingForm } = completeOnboardingFetcher; + const [searchParams] = useSearchParams(); + const fromMarketplaceContext = searchParams.get("origin") === "marketplace"; + + const availableProjects = onboardingData?.availableProjects || []; + const hasProjectSelected = onboardingData?.hasProjectSelected ?? false; + const customEnvironments = onboardingData?.customEnvironments || []; + const envVars = onboardingData?.environmentVariables || []; + const existingVars = onboardingData?.existingVariables || {}; + const hasCustomEnvs = customEnvironments.length > 0 && hasStagingEnvironment; + + const computeInitialState = useCallback((): OnboardingState => { + if (!hasOrgIntegration || onboardingData?.authInvalid) { + return "idle"; + } + const projectSelected = onboardingData?.hasProjectSelected ?? false; + if (!projectSelected) { + if (!onboardingData?.availableProjects || onboardingData.availableProjects.length === 0) { + return "loading-projects"; + } + return "project-selection"; + } + // For marketplace origin, skip env-mapping step and go directly to env-var-sync + if (!fromMarketplaceContext) { + const customEnvs = (onboardingData?.customEnvironments?.length ?? 0) > 0 && hasStagingEnvironment; + if (customEnvs) { + return "env-mapping"; + } + } + if (!onboardingData?.environmentVariables || onboardingData.environmentVariables.length === 0) { + return "loading-env-vars"; + } + return "env-var-sync"; + }, [hasOrgIntegration, onboardingData, hasStagingEnvironment, fromMarketplaceContext]); + + const [state, setState] = useState(() => { + if (!isOpen) return "idle"; + return computeInitialState(); + }); + + const prevIsOpenRef = useRef(isOpen); + const hasSyncedStagingRef = useRef(false); + const hasSyncedPreviewRef = useRef(false); + useEffect(() => { + if (isOpen && !prevIsOpenRef.current) { + setState(computeInitialState()); + hasSyncedStagingRef.current = false; + hasSyncedPreviewRef.current = false; + } else if (isOpen && state === "idle") { + setState(computeInitialState()); + } + prevIsOpenRef.current = isOpen; + }, [isOpen, state, computeInitialState]); + + const [selectedVercelProject, setSelectedVercelProject] = useState<{ + id: string; + name: string; + } | null>(null); + const [vercelStagingEnvironment, setVercelStagingEnvironment] = useState<{ + environmentId: string; + displayName: string; + } | null>(null); + const availableEnvSlugsForOnboarding = getAvailableEnvSlugs(hasStagingEnvironment, hasPreviewEnvironment); + const availableEnvSlugsForOnboardingBuildSettings = getAvailableEnvSlugsForBuildSettings(hasStagingEnvironment, hasPreviewEnvironment); + const [pullEnvVarsBeforeBuild, setPullEnvVarsBeforeBuild] = useState( + () => availableEnvSlugsForOnboardingBuildSettings + ); + const [atomicBuilds, setAtomicBuilds] = useState( + () => ["prod"] + ); + const [discoverEnvVars, setDiscoverEnvVars] = useState( + () => availableEnvSlugsForOnboardingBuildSettings + ); + + // Sync pullEnvVarsBeforeBuild and discoverEnvVars when hasStagingEnvironment becomes true (once) + useEffect(() => { + if (hasStagingEnvironment && !hasSyncedStagingRef.current) { + hasSyncedStagingRef.current = true; + setPullEnvVarsBeforeBuild((prev) => { + if (!prev.includes("stg")) { + return [...prev, "stg"]; + } + return prev; + }); + setDiscoverEnvVars((prev) => { + if (!prev.includes("stg")) { + return [...prev, "stg"]; + } + return prev; + }); + } + }, [hasStagingEnvironment]); + + // Sync pullEnvVarsBeforeBuild and discoverEnvVars when hasPreviewEnvironment becomes true (once) + useEffect(() => { + if (hasPreviewEnvironment && !hasSyncedPreviewRef.current) { + hasSyncedPreviewRef.current = true; + setPullEnvVarsBeforeBuild((prev) => { + if (!prev.includes("preview")) { + return [...prev, "preview"]; + } + return prev; + }); + setDiscoverEnvVars((prev) => { + if (!prev.includes("preview")) { + return [...prev, "preview"]; + } + return prev; + }); + } + }, [hasPreviewEnvironment]); + const [syncEnvVarsMapping, setSyncEnvVarsMapping] = useState({}); + const [expandedEnvVars, setExpandedEnvVars] = useState(false); + const [expandedSecretEnvVars, setExpandedSecretEnvVars] = useState(false); + const [projectSelectionError, setProjectSelectionError] = useState(null); + + const gitHubAppInstallations = onboardingData?.gitHubAppInstallations ?? []; + const isGitHubConnectedForOnboarding = onboardingData?.isGitHubConnected ?? false; + + const hasTriggeredMarketplaceRedirectRef = useRef(false); + + // Auto-redirect for marketplace flow when returning from GitHub with everything complete + useEffect(() => { + if (hasTriggeredMarketplaceRedirectRef.current) { + return; + } + + if ( + isOpen && + fromMarketplaceContext && + nextUrl && + hasProjectSelected && + isGitHubConnectedForOnboarding + ) { + hasTriggeredMarketplaceRedirectRef.current = true; + setTimeout(() => { + window.location.href = nextUrl; + }, 100); + } + }, [isOpen, fromMarketplaceContext, nextUrl, hasProjectSelected, isGitHubConnectedForOnboarding]); + + useEffect(() => { + if (!isOpen) { + hasTriggeredMarketplaceRedirectRef.current = false; + } + }, [isOpen]); + + const loadingStateRef = useRef(null); + + useEffect(() => { + if (!isOpen || state === "idle") { + loadingStateRef.current = null; + return; + } + + if (onboardingData?.authInvalid) { + onClose(); + return; + } + + if (loadingStateRef.current === state) { + return; + } + + switch (state) { + + case "loading-projects": + loadingStateRef.current = state; + if (onDataReload) { + onDataReload(); + } + break; + + case "loading-env-mapping": + loadingStateRef.current = state; + if (onDataReload) { + onDataReload(); + } + break; + + case "loading-env-vars": + loadingStateRef.current = state; + if (onDataReload) { + onDataReload(vercelStagingEnvironment?.environmentId || undefined); + } + break; + + case "installing": + case "project-selection": + case "env-mapping": + case "env-var-sync": + case "completed": + case "build-settings": + case "github-connection": + loadingStateRef.current = null; + break; + } + }, [isOpen, state, onboardingData?.authInvalid, vercelStagingEnvironment, onDataReload, onClose]); + + useEffect(() => { + if (!onboardingData?.authInvalid && state === "loading-projects" && onboardingData?.availableProjects !== undefined) { + setState("project-selection"); + } + }, [state, onboardingData?.availableProjects, onboardingData?.authInvalid]); + + useEffect(() => { + if (!onboardingData?.authInvalid && state === "loading-env-vars" && onboardingData?.environmentVariables) { + setState("env-var-sync"); + } + }, [state, onboardingData?.environmentVariables, onboardingData?.authInvalid]); + + useEffect(() => { + if (state === "project-selection" && fetcher.data && "success" in fetcher.data && fetcher.data.success && fetcher.state === "idle") { + setState("loading-env-mapping"); + if (onDataReload) { + onDataReload(); + } + } else if (fetcher.data && "error" in fetcher.data && typeof fetcher.data.error === "string") { + setProjectSelectionError(fetcher.data.error); + } + }, [state, fetcher.data, fetcher.state, onDataReload]); + + // For marketplace origin, skip env-mapping step + useEffect(() => { + if (state === "loading-env-mapping" && onboardingData) { + const hasCustomEnvs = (onboardingData.customEnvironments?.length ?? 0) > 0 && hasStagingEnvironment; + if (hasCustomEnvs && !fromMarketplaceContext) { + setState("env-mapping"); + } else { + setState("loading-env-vars"); + } + } + }, [state, onboardingData, hasStagingEnvironment]); + + const secretEnvVars = envVars.filter((v) => v.isSecret); + const syncableEnvVars = envVars.filter((v) => !v.isSecret); + const enabledEnvVars = syncableEnvVars.filter( + (v) => shouldSyncEnvVarForAnyEnvironment(syncEnvVarsMapping, v.key) + ); + + const overlappingEnvVarsCount = enabledEnvVars.filter((v) => existingVars[v.key]).length; + + const isSubmitting = + navigation.state === "submitting" || navigation.state === "loading"; + + const actionUrl = vercelResourcePath(organizationSlug, projectSlug, environmentSlug); + + const handleToggleEnvVar = useCallback((key: string, enabled: boolean) => { + setSyncEnvVarsMapping((prev) => { + const newMapping = { ...prev }; + + if (enabled) { + for (const envSlug of ALL_ENV_SLUGS) { + if (newMapping[envSlug]) { + const { [key]: _, ...rest } = newMapping[envSlug]; + if (Object.keys(rest).length === 0) { + delete newMapping[envSlug]; + } else { + newMapping[envSlug] = rest; + } + } + } + } else { + for (const envSlug of ALL_ENV_SLUGS) { + newMapping[envSlug] = { + ...(newMapping[envSlug] || {}), + [key]: false, + }; + } + } + + return newMapping; + }); + }, []); + + const handleToggleAllEnvVars = useCallback( + (enabled: boolean, syncableVars: Array<{ key: string }>) => { + if (enabled) { + setSyncEnvVarsMapping({}); + } else { + const newMapping: SyncEnvVarsMapping = {}; + for (const envSlug of ALL_ENV_SLUGS) { + newMapping[envSlug] = {}; + for (const v of syncableVars) { + newMapping[envSlug][v.key] = false; + } + } + setSyncEnvVarsMapping(newMapping); + } + }, + [] + ); + + const handleProjectSelection = useCallback(async () => { + if (!selectedVercelProject) { + setProjectSelectionError("Please select a Vercel project"); + return; + } + + setProjectSelectionError(null); + + const formData = new FormData(); + formData.append("action", "select-vercel-project"); + formData.append("vercelProjectId", selectedVercelProject.id); + formData.append("vercelProjectName", selectedVercelProject.name); + + fetcher.submit(formData, { + method: "post", + action: actionUrl, + }); + }, [selectedVercelProject, fetcher, actionUrl]); + + const handleSkipOnboarding = useCallback(() => { + onClose(); + + if (fromMarketplaceContext) { + return window.close(); + } + + const formData = new FormData(); + formData.append("action", "skip-onboarding"); + fetcher.submit(formData, { + method: "post", + action: actionUrl, + }); + }, [actionUrl, fetcher, onClose, nextUrl, fromMarketplaceContext]); + + const handleSkipEnvMapping = useCallback(() => { + setVercelStagingEnvironment(null); + setState("loading-env-vars"); + }, []); + + const handleUpdateEnvMapping = useCallback(() => { + if (!vercelStagingEnvironment) { + setState("loading-env-vars"); + return; + } + + const formData = new FormData(); + formData.append("action", "update-env-mapping"); + formData.append("vercelStagingEnvironment", JSON.stringify(vercelStagingEnvironment)); + + envMappingFetcher.submit(formData, { + method: "post", + action: actionUrl, + }); + + }, [vercelStagingEnvironment, envMappingFetcher, actionUrl]); + + const handleBuildSettingsNext = useCallback(() => { + const formData = new FormData(); + formData.append("action", "complete-onboarding"); + formData.append("vercelStagingEnvironment", vercelStagingEnvironment ? JSON.stringify(vercelStagingEnvironment) : ""); + formData.append("pullEnvVarsBeforeBuild", JSON.stringify(pullEnvVarsBeforeBuild)); + formData.append("atomicBuilds", JSON.stringify(atomicBuilds)); + formData.append("discoverEnvVars", JSON.stringify(discoverEnvVars)); + formData.append("syncEnvVarsMapping", JSON.stringify(syncEnvVarsMapping)); + if (nextUrl && fromMarketplaceContext && isGitHubConnectedForOnboarding) { + formData.append("next", nextUrl); + } + + if (!isGitHubConnectedForOnboarding) { + formData.append("skipRedirect", "true"); + } + + completeOnboardingFetcher.submit(formData, { + method: "post", + action: actionUrl, + }); + + if (!isGitHubConnectedForOnboarding) { + setState("github-connection"); + } + }, [vercelStagingEnvironment, pullEnvVarsBeforeBuild, atomicBuilds, discoverEnvVars, syncEnvVarsMapping, nextUrl, fromMarketplaceContext, isGitHubConnectedForOnboarding, completeOnboardingFetcher, actionUrl]); + + const handleFinishOnboarding = useCallback((e: React.FormEvent) => { + e.preventDefault(); + const form = e.currentTarget; + const formData = new FormData(form); + completeOnboardingFetcher.submit(formData, { + method: "post", + action: actionUrl, + }); + }, [completeOnboardingFetcher, actionUrl]); + + useEffect(() => { + if (completeOnboardingFetcher.data && typeof completeOnboardingFetcher.data === "object" && "success" in completeOnboardingFetcher.data && completeOnboardingFetcher.data.success && completeOnboardingFetcher.state === "idle") { + if (state === "github-connection") { + return; + } + if ("redirectTo" in completeOnboardingFetcher.data && typeof completeOnboardingFetcher.data.redirectTo === "string") { + window.location.href = completeOnboardingFetcher.data.redirectTo; + return; + } + setState("completed"); + } + }, [completeOnboardingFetcher.data, completeOnboardingFetcher.state, state]); + + useEffect(() => { + if (state === "completed") { + onClose(); + } + }, [state, onClose]); + + useEffect(() => { + if (state === "installing") { + const installUrl = vercelAppInstallPath(organizationSlug, projectSlug); + window.location.href = installUrl; + } + }, [state, organizationSlug, projectSlug]); + + useEffect(() => { + if (envMappingFetcher.data && typeof envMappingFetcher.data === "object" && "success" in envMappingFetcher.data && envMappingFetcher.data.success && envMappingFetcher.state === "idle") { + setState("loading-env-vars"); + } + }, [envMappingFetcher.data, envMappingFetcher.state]); + + useEffect(() => { + if (state === "env-mapping" && customEnvironments.length > 0 && !vercelStagingEnvironment) { + let selectedEnv: VercelCustomEnvironment; + + if (customEnvironments.length === 1) { + selectedEnv = customEnvironments[0]; + } else { + const stagingEnv = customEnvironments.find( + (env) => env.slug.toLowerCase() === "staging" + ); + selectedEnv = stagingEnv ?? customEnvironments[0]; + } + + setVercelStagingEnvironment({ environmentId: selectedEnv.id, displayName: selectedEnv.slug }); + } + }, [state, customEnvironments, vercelStagingEnvironment]); + + if (!isOpen || onboardingData?.authInvalid) { + return null; + } + + const isLoadingState = + state === "loading-projects" || + state === "loading-env-mapping" || + state === "loading-env-vars" || + state === "installing" || + (state === "idle" && !onboardingData); + + if (isLoadingState) { + return ( + !open && !fromMarketplaceContext && onClose()}> + + +
+ + Set up Vercel Integration +
+
+
+ +
+
+
+ ); + } + + const showProjectSelection = state === "project-selection"; + const showEnvMapping = state === "env-mapping"; + const showEnvVarSync = state === "env-var-sync"; + const showBuildSettings = state === "build-settings"; + const showGitHubConnection = state === "github-connection"; + + return ( + !open && !fromMarketplaceContext && onClose()}> + + +
+ + Set up Vercel Integration +
+
+ +
+ {showProjectSelection && ( +
+ Select Vercel Project + + Choose which Vercel project to connect with this Trigger.dev project. + Your API keys will be automatically synced to Vercel. + + + {availableProjects.length === 0 ? ( + + No Vercel projects found. Please create a project in Vercel first. + + ) : ( + + )} + + {projectSelectionError && ( + {projectSelectionError} + )} + + + Once connected, your TRIGGER_SECRET_KEY will be + automatically synced to Vercel for each environment. + + + + {fetcher.state !== "idle" ? "Connecting..." : "Connect Project"} + + } + cancelButton={ + + } + /> +
+ )} + + {showEnvMapping && ( +
+ Map Vercel Environment to Staging + + Select which custom Vercel environment should map to Trigger.dev's Staging + environment. Production and Preview environments are mapped automatically. + + + + +
+ +
+ {!fromMarketplaceContext && ( + + )} + +
+
+
+ )} + + {showEnvVarSync && ( +
+ Pull Environment Variables + + Select which environment variables to pull from Vercel now. This is a one-time pull. + + +
+
+ {syncableEnvVars.length} + can be pulled +
+ {secretEnvVars.length > 0 && ( +
+ {secretEnvVars.length} + secret (cannot pull) +
+ )} +
+ +
+
+ + Select all variables to pull from Vercel. +
+ handleToggleAllEnvVars(checked, syncableEnvVars)} + /> +
+ + {syncableEnvVars.length > 0 && ( +
+ + + {expandedEnvVars && ( +
+ {syncableEnvVars.map((envVar) => ( +
+
+ {existingVars[envVar.key] ? ( + + + +
+ {envVar.key} +
+
+ + {`This variable is going to be replaced in: ${existingVars[ + envVar.key + ].environments.join(", ")}`} + +
+
+ ) : ( + {envVar.key} + )} + {envVar.target && envVar.target.length > 0 && ( + + {formatVercelTargets(envVar.target)} + {envVar.isShared && " · Shared"} + + )} +
+ + handleToggleEnvVar(envVar.key, checked) + } + /> +
+ ))} +
+ )} +
+ )} + + {secretEnvVars.length > 0 && ( +
+ + + {expandedSecretEnvVars && ( +
+ {secretEnvVars.map((envVar) => ( +
+
+ {envVar.key} + {envVar.target && envVar.target.length > 0 && ( + + {formatVercelTargets(envVar.target)} + {envVar.isShared && " · Shared"} + + )} +
+ Secret +
+ ))} +
+ )} +
+ )} + + {overlappingEnvVarsCount > 0 && enabledEnvVars.length > 0 && ( +
+ + + {overlappingEnvVarsCount} env vars are going to be updated (marked with{" "} + + underline + + ) + +
+ )} + + { + if (fromMarketplaceContext) { + handleBuildSettingsNext(); + } else { + setState("build-settings"); + } + }} + disabled={fromMarketplaceContext && completeOnboardingFetcher.state !== "idle"} + LeadingIcon={fromMarketplaceContext && completeOnboardingFetcher.state !== "idle" ? SpinnerWhite : undefined} + > + {fromMarketplaceContext ? (isGitHubConnectedForOnboarding ? "Finish" : "Next") : "Next"} + + } + cancelButton={ + hasCustomEnvs && !fromMarketplaceContext ? ( + + ) : ( + + ) + } + /> +
+ )} + + {showBuildSettings && ( +
+ Build Settings + + Configure how environment variables are pulled during builds and atomic deployments. + + + + + + {isGitHubConnectedForOnboarding ? "Finish" : "Next"} + + } + cancelButton={ + + } + /> +
+ )} + + {showGitHubConnection && ( +
+ Connect GitHub Repository + + To fully integrate with Vercel, Trigger.dev needs access to your source code. + This allows automatic deployments and build synchronization. + + + +

+ Connecting your GitHub repository enables Trigger.dev to read your source code + and automatically create deployments when you push changes to Vercel. +

+
+ + {(() => { + const baseSettingsPath = v3ProjectSettingsPath( + { slug: organizationSlug }, + { slug: projectSlug }, + { slug: environmentSlug } + ); + const redirectParams = new URLSearchParams(); + redirectParams.set("vercelOnboarding", "true"); + if (fromMarketplaceContext) { + redirectParams.set("origin", "marketplace"); + } + if (nextUrl) { + redirectParams.set("next", nextUrl); + } + const redirectUrlWithContext = `${baseSettingsPath}?${redirectParams.toString()}`; + + return gitHubAppInstallations.length === 0 ? ( +
+ + Install GitHub app + +
+ ) : ( +
+
+ + + GitHub app is installed + +
+
+ ); + })()} + + { + setState("completed"); + window.location.href = nextUrl; + }} + > + Complete + + ) : ( + + ) + } + cancelButton={ + isGitHubConnectedForOnboarding && fromMarketplaceContext && nextUrl ? ( + + ) : undefined + } + /> +
+ )} +
+
+
+ ); +} diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx index e42928cdd6..8758e181ff 100644 --- a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx +++ b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx @@ -3,6 +3,7 @@ import { ChartBarIcon, Cog8ToothIcon, CreditCardIcon, + PuzzlePieceIcon, UserGroupIcon, } from "@heroicons/react/20/solid"; import { ArrowLeftIcon } from "@heroicons/react/24/solid"; @@ -12,6 +13,7 @@ import { cn } from "~/utils/cn"; import { organizationSettingsPath, organizationTeamPath, + organizationVercelIntegrationPath, rootPath, v3BillingAlertsPath, v3BillingPath, @@ -113,6 +115,13 @@ export function OrganizationSettingsSideMenu({ to={organizationSettingsPath(organization)} data-action="settings" /> +
diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index dcbcac079a..6733af0add 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -425,6 +425,11 @@ const EnvironmentSchema = z ORG_SLACK_INTEGRATION_CLIENT_ID: z.string().optional(), ORG_SLACK_INTEGRATION_CLIENT_SECRET: z.string().optional(), + /** Vercel integration OAuth credentials */ + VERCEL_INTEGRATION_CLIENT_ID: z.string().optional(), + VERCEL_INTEGRATION_CLIENT_SECRET: z.string().optional(), + VERCEL_INTEGRATION_APP_SLUG: z.string().optional(), + /** These enable the alerts feature in v3 */ ALERT_EMAIL_TRANSPORT: z.enum(["resend", "smtp", "aws-ses"]).optional(), ALERT_FROM_EMAIL: z.string().optional(), diff --git a/apps/webapp/app/models/orgIntegration.server.ts b/apps/webapp/app/models/orgIntegration.server.ts index 343da2701d..80a0334281 100644 --- a/apps/webapp/app/models/orgIntegration.server.ts +++ b/apps/webapp/app/models/orgIntegration.server.ts @@ -47,6 +47,13 @@ export type AuthenticatableIntegration = OrganizationIntegration & { tokenReference: SecretReference; }; +export function isIntegrationForService( + integration: AuthenticatableIntegration, + service: TService +): integration is OrganizationIntegrationForService { + return (integration.service satisfies IntegrationService) === service; +} + export class OrgIntegrationRepository { static async getAuthenticatedClientForIntegration( integration: OrganizationIntegrationForService, @@ -89,6 +96,22 @@ export class OrgIntegrationRepository { static isSlackSupported = !!env.ORG_SLACK_INTEGRATION_CLIENT_ID && !!env.ORG_SLACK_INTEGRATION_CLIENT_SECRET; + static isVercelSupported = + !!env.VERCEL_INTEGRATION_CLIENT_ID && !!env.VERCEL_INTEGRATION_CLIENT_SECRET && !!env.VERCEL_INTEGRATION_APP_SLUG; + + /** + * Generate the URL to install the Vercel integration. + * Users are redirected to Vercel's marketplace to complete the installation. + * + * @param state - Base64-encoded state containing org/project info for the callback + */ + static vercelInstallUrl(state: string): string { + // The user goes to Vercel's marketplace to install the integration + // After installation, Vercel redirects to our callback with the authorization code + const redirectUri = encodeURIComponent(`${env.APP_ORIGIN}/vercel/callback`); + return `https://vercel.com/integrations/${env.VERCEL_INTEGRATION_APP_SLUG}/new?state=${state}&redirect_uri=${redirectUri}`; + } + static slackAuthorizationUrl( state: string, scopes: string[] = [ diff --git a/apps/webapp/app/models/vercelIntegration.server.ts b/apps/webapp/app/models/vercelIntegration.server.ts new file mode 100644 index 0000000000..eed9b9b740 --- /dev/null +++ b/apps/webapp/app/models/vercelIntegration.server.ts @@ -0,0 +1,1504 @@ +import { Vercel } from "@vercel/sdk"; +import { + Organization, + OrganizationIntegration, + SecretReference, +} from "@trigger.dev/database"; +import { z } from "zod"; +import { $transaction, prisma } from "~/db.server"; +import { env } from "~/env.server"; +import { logger } from "~/services/logger.server"; +import { getSecretStore } from "~/services/secrets/secretStore.server"; +import { generateFriendlyId } from "~/v3/friendlyIdentifiers"; +import { + SyncEnvVarsMapping, + shouldSyncEnvVar, + TriggerEnvironmentType, + envTypeToVercelTarget, +} from "~/v3/vercel/vercelProjectIntegrationSchema"; +import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; + +function normalizeTarget(target: unknown): string[] { + if (Array.isArray(target)) return target.filter(Boolean) as string[]; + if (typeof target === 'string') return [target]; + return []; +} + +function extractEnvs(response: unknown): unknown[] { + if (response && typeof response === 'object' && 'envs' in response) { + const envs = (response as { envs: unknown }).envs; + return Array.isArray(envs) ? envs : []; + } + return []; +} + +function isVercelSecretType(type: string): boolean { + return type === "secret" || type === "sensitive" || type === "encrypted"; +} + +function vercelApiError(message: string, context: Record, error: unknown): VercelAPIResult { + const authInvalid = isVercelAuthError(error); + logger.error(message, { ...context, error, authInvalid }); + return { + success: false, + authInvalid, + error: error instanceof Error ? error.message : "Unknown error", + }; +} + +export const VercelSecretSchema = z.object({ + accessToken: z.string(), + tokenType: z.string().optional(), + teamId: z.string().nullable().optional(), + userId: z.string().optional(), + installationId: z.string().optional(), + raw: z.record(z.any()).optional(), +}); + +export type VercelSecret = z.infer; + +export type TokenResponse = { + accessToken: string; + tokenType: string; + teamId?: string; + userId?: string; + raw: Record; +}; + +export type VercelEnvironmentVariable = { + id: string; + key: string; + type: "system" | "encrypted" | "plain" | "sensitive" | "secret"; + isSecret: boolean; + target: string[]; + isShared?: boolean; +}; + +export type VercelCustomEnvironment = { + id: string; + slug: string; + description?: string; + branchMatcher?: { + pattern: string; + type: string; + }; +}; + +export type VercelAPIResult = { + success: true; + data: T; +} | { + success: false; + authInvalid: boolean; + error: string; +}; + +const VercelErrorSchema = z.union([ + z.object({ status: z.number() }), + z.object({ response: z.object({ status: z.number() }) }), + z.object({ statusCode: z.number() }), +]); + +function extractVercelErrorStatus(error: unknown): number | null { + if (error && typeof error === 'object' && 'status' in error) { + const parsed = VercelErrorSchema.safeParse(error); + if (parsed.success && 'status' in parsed.data) { + return parsed.data.status; + } + } + + if (error && typeof error === 'object' && 'response' in error) { + const parsed = VercelErrorSchema.safeParse(error); + if (parsed.success && 'response' in parsed.data) { + return parsed.data.response.status; + } + } + + if (error && typeof error === 'object' && 'statusCode' in error) { + const parsed = VercelErrorSchema.safeParse(error); + if (parsed.success && 'statusCode' in parsed.data) { + return parsed.data.statusCode; + } + } + + if (typeof error === 'string') { + if (error.includes('401')) return 401; + if (error.includes('403')) return 403; + } + + return null; +} + +function isVercelAuthError(error: unknown): boolean { + const status = extractVercelErrorStatus(error); + return status === 401 || status === 403; +} + +export class VercelIntegrationRepository { + static async exchangeCodeForToken(code: string): Promise { + const clientId = env.VERCEL_INTEGRATION_CLIENT_ID; + const clientSecret = env.VERCEL_INTEGRATION_CLIENT_SECRET; + const redirectUri = `${env.APP_ORIGIN}/vercel/callback`; + + if (!clientId || !clientSecret) { + logger.error("Vercel integration not configured"); + return null; + } + + try { + const response = await fetch("https://api.vercel.com/v2/oauth/access_token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + code, + redirect_uri: redirectUri, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + logger.error("Failed to exchange Vercel OAuth code", { + status: response.status, + error: errorText, + }); + return null; + } + + const data = (await response.json()) as { + access_token: string; + token_type: string; + team_id?: string; + user_id?: string; + }; + + return { + accessToken: data.access_token, + tokenType: data.token_type, + teamId: data.team_id, + userId: data.user_id, + raw: data as Record, + }; + } catch (error) { + logger.error("Error exchanging Vercel OAuth code", { error }); + return null; + } + } + + static async getVercelClient( + integration: OrganizationIntegration & { tokenReference: SecretReference } + ): Promise { + const secretStore = getSecretStore(integration.tokenReference.provider); + + const secret = await secretStore.getSecret( + VercelSecretSchema, + integration.tokenReference.key + ); + + if (!secret) { + throw new Error("Failed to get Vercel access token"); + } + + return new Vercel({ + bearerToken: secret.accessToken, + }); + } + + static async validateVercelToken( + integration: OrganizationIntegration & { tokenReference: SecretReference } + ): Promise<{ isValid: boolean }> { + try { + const client = await this.getVercelClient(integration); + await client.user.getAuthUser(); + return { isValid: true }; + } catch (error) { + const authInvalid = isVercelAuthError(error); + if (authInvalid) { + logger.debug("Vercel token validation failed - auth error", { + integrationId: integration.id, + error, + }); + return { isValid: false }; + } + logger.error("Vercel token validation failed - unexpected error", { + integrationId: integration.id, + error, + }); + throw error; + } + } + + static async getTeamIdFromIntegration( + integration: OrganizationIntegration & { tokenReference: SecretReference } + ): Promise { + const secretStore = getSecretStore(integration.tokenReference.provider); + + const secret = await secretStore.getSecret( + VercelSecretSchema, + integration.tokenReference.key + ); + + if (!secret) { + return null; + } + + return secret.teamId ?? null; + } + + static async getVercelIntegrationConfiguration( + accessToken: string, + configurationId: string, + teamId?: string | null + ): Promise<{ + id: string; + teamId: string | null; + projects: string[]; + } | null> { + try { + const client = new Vercel({ + bearerToken: accessToken, + }); + + const response = await fetch( + `https://api.vercel.com/v1/integrations/configuration/${configurationId}${teamId ? `?teamId=${teamId}` : ""}`, + { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + } + ); + + if (!response.ok) { + const errorText = await response.text(); + logger.error("Failed to fetch Vercel integration configuration", { + status: response.status, + error: errorText, + configurationId, + teamId, + }); + return null; + } + + const data = (await response.json()) as { + id: string; + teamId?: string | null; + projects?: string[]; + [key: string]: any; + }; + + return { + id: data.id, + teamId: data.teamId ?? null, + projects: data.projects || [], + }; + } catch (error) { + logger.error("Error fetching Vercel integration configuration", { + configurationId, + teamId, + error, + }); + return null; + } + } + + static async getVercelCustomEnvironments( + client: Vercel, + projectId: string, + teamId?: string | null + ): Promise> { + try { + const response = await client.environment.getV9ProjectsIdOrNameCustomEnvironments({ + idOrName: projectId, + ...(teamId && { teamId }), + }); + + const environments = response.environments || []; + + return { + success: true, + data: environments.map((env: any) => ({ + id: env.id, + slug: env.slug, + description: env.description, + branchMatcher: env.branchMatcher, + })), + }; + } catch (error) { + return vercelApiError("Failed to fetch Vercel custom environments", { projectId, teamId }, error); + } + } + + static async getVercelEnvironmentVariables( + client: Vercel, + projectId: string, + teamId?: string | null, + ): Promise> { + try { + const response = await client.projects.filterProjectEnvs({ + idOrName: projectId, + ...(teamId && { teamId }), + }); + + const envs = extractEnvs(response); + + return { + success: true, + data: envs.map((env: any) => { + const type = env.type as VercelEnvironmentVariable["type"]; + + return { + id: env.id, + key: env.key, + type, + isSecret: isVercelSecretType(type), + target: normalizeTarget(env.target), + customEnvironmentIds: env.customEnvironmentIds as string[] ?? [], + }; + }), + }; + } catch (error) { + return vercelApiError("Failed to fetch Vercel environment variables", { projectId, teamId }, error); + } + } + + static async getVercelEnvironmentVariableValues( + client: Vercel, + projectId: string, + teamId?: string | null, + target?: string + ): Promise< + Array<{ + key: string; + value: string; + target: string[]; + type: string; + isSecret: boolean; + }> + > { + try { + const response = await client.projects.filterProjectEnvs({ + idOrName: projectId, + ...(teamId && { teamId }), + decrypt: "true", + }); + + const envs = extractEnvs(response); + + const result = envs + .filter((env: any) => { + if (!env.value) { + return false; + } + if (target) { + return normalizeTarget(env.target).includes(target); + } + return true; + }) + .map((env: any) => { + const type = env.type as string; + + return { + key: env.key as string, + value: env.value as string, + target: normalizeTarget(env.target), + type, + isSecret: isVercelSecretType(type), + }; + }); + + return result; + } catch (error) { + logger.error("Failed to fetch Vercel environment variable values", { + projectId, + teamId, + target, + error, + }); + return []; + } + } + + static async getVercelSharedEnvironmentVariables( + client: Vercel, + teamId: string, + projectId?: string // Optional: filter by project + ): Promise>> { + try { + const response = await client.environment.listSharedEnvVariable({ + teamId, + ...(projectId && { projectId }), + }); + + const envVars = response.data || []; + + return { + success: true, + data: envVars.map((env) => { + const type = (env.type as string) || "plain"; + + return { + id: env.id as string, + key: env.key as string, + type, + isSecret: isVercelSecretType(type), + target: normalizeTarget(env.target), + }; + }), + }; + } catch (error) { + return vercelApiError("Failed to fetch Vercel shared environment variables", { teamId, projectId }, error); + } + } + + static async getVercelSharedEnvironmentVariableValues( + client: Vercel, + teamId: string, + projectId?: string // Optional: filter by project + ): Promise< + Array<{ + key: string; + value: string; + target: string[]; + type: string; + isSecret: boolean; + applyToAllCustomEnvironments?: boolean; + }> + > { + try { + const listResponse = await client.environment.listSharedEnvVariable({ + teamId, + ...(projectId && { projectId }), + }); + + const envVars = listResponse.data || []; + + if (envVars.length === 0) { + return []; + } + + const results = await Promise.all( + envVars.map(async (env) => { + const type = (env.type as string) || "plain"; + const isSecret = isVercelSecretType(type); + + if (isSecret) { + return null; + } + + const listValue = (env as any).value as string | undefined; + const applyToAllCustomEnvs = (env as any).applyToAllCustomEnvironments as boolean | undefined; + + if (listValue) { + return { + key: env.key as string, + value: listValue, + target: normalizeTarget(env.target), + type, + isSecret, + applyToAllCustomEnvironments: applyToAllCustomEnvs, + }; + } + + try { + // Get the decrypted value for this shared env var + const getResponse = await client.environment.getSharedEnvVar({ + id: env.id as string, + teamId, + }); + + if (!getResponse.value) { + return null; + } + + const result = { + key: env.key as string, + value: getResponse.value as string, + target: normalizeTarget(env.target), + type, + isSecret, + applyToAllCustomEnvironments: (env as any).applyToAllCustomEnvironments as boolean | undefined, + }; + + return result; + } catch (error) { + // Workaround: Vercel SDK may throw ResponseValidationError even when the API response + // is valid (e.g., deletedAt: null vs expected number). Extract value from rawValue. + let errorValue: string | undefined; + if (error && typeof error === "object" && "rawValue" in error) { + const rawValue = (error as any).rawValue; + if (rawValue && typeof rawValue === "object" && "value" in rawValue) { + errorValue = rawValue.value as string | undefined; + } + } + + const fallbackValue = errorValue || listValue; + + if (fallbackValue) { + logger.warn("getSharedEnvVar failed validation, using value from error.rawValue or list response", { + teamId, + envId: env.id, + envKey: env.key, + error: error instanceof Error ? error.message : String(error), + hasErrorRawValue: !!errorValue, + hasListValue: !!listValue, + valueLength: fallbackValue.length, + }); + return { + key: env.key as string, + value: fallbackValue, + target: normalizeTarget(env.target), + type, + isSecret, + applyToAllCustomEnvironments: applyToAllCustomEnvs, + }; + } + + logger.warn("Failed to get decrypted value for shared env var, no fallback available", { + teamId, + projectId, + envId: env.id, + envKey: env.key, + error: error instanceof Error ? error.message : String(error), + errorStack: error instanceof Error ? error.stack : undefined, + hasRawValue: error && typeof error === "object" && "rawValue" in error, + }); + return null; + } + }) + ); + + const validResults = results.filter((r): r is NonNullable => r !== null); + + return validResults; + } catch (error) { + logger.error("Failed to fetch Vercel shared environment variable values", { + teamId, + projectId, + error: error instanceof Error ? error.message : String(error), + errorStack: error instanceof Error ? error.stack : undefined, + }); + return []; + } + } + + static async getVercelProjects( + client: Vercel, + teamId?: string | null + ): Promise>> { + try { + const response = await client.projects.getProjects({ + ...(teamId && { teamId }), + }); + + const projects = response.projects || []; + + return { + success: true, + data: projects.map((project: any) => ({ + id: project.id, + name: project.name, + })), + }; + } catch (error) { + return vercelApiError("Failed to fetch Vercel projects", { teamId }, error); + } + } + + static async updateVercelOrgIntegrationToken(params: { + integrationId: string; + accessToken: string; + tokenType?: string; + teamId: string | null; + userId?: string; + installationId?: string; + raw?: Record; + }): Promise { + await $transaction(prisma, async (tx) => { + const integration = await tx.organizationIntegration.findUnique({ + where: { id: params.integrationId }, + include: { tokenReference: true }, + }); + + if (!integration) { + throw new Error("Vercel integration not found"); + } + + const secretStore = getSecretStore(integration.tokenReference.provider, { + prismaClient: tx, + }); + + const secretValue: VercelSecret = { + accessToken: params.accessToken, + tokenType: params.tokenType, + teamId: params.teamId, + userId: params.userId, + installationId: params.installationId, + raw: params.raw, + }; + + await secretStore.setSecret(integration.tokenReference.key, secretValue); + + await tx.organizationIntegration.update({ + where: { id: params.integrationId }, + data: { + integrationData: { + teamId: params.teamId, + userId: params.userId, + installationId: params.installationId, + } as any, + }, + }); + }); + } + + static async createVercelOrgIntegration(params: { + accessToken: string; + tokenType?: string; + teamId: string | null; + userId?: string; + installationId?: string; + organization: Pick; + raw?: Record; + origin: 'marketplace' | 'dashboard'; + }): Promise { + const result = await $transaction(prisma, async (tx) => { + const secretStore = getSecretStore("DATABASE", { + prismaClient: tx, + }); + + const integrationFriendlyId = generateFriendlyId("org_integration"); + + const secretValue: VercelSecret = { + accessToken: params.accessToken, + tokenType: params.tokenType, + teamId: params.teamId, + userId: params.userId, + installationId: params.installationId, + raw: params.raw, + }; + + await secretStore.setSecret(integrationFriendlyId, secretValue); + + const reference = await tx.secretReference.create({ + data: { + provider: "DATABASE", + key: integrationFriendlyId, + }, + }); + + return await tx.organizationIntegration.create({ + data: { + friendlyId: integrationFriendlyId, + organizationId: params.organization.id, + service: "VERCEL", + externalOrganizationId: params.teamId, + tokenReferenceId: reference.id, + integrationData: { + teamId: params.teamId, + userId: params.userId, + installationId: params.installationId, + origin: params.origin, + } as any, + }, + }); + }); + + if (!result) { + throw new Error("Failed to create Vercel organization integration"); + } + + return result; + } + + static async findVercelOrgIntegrationByTeamId( + organizationId: string, + teamId: string | null + ): Promise<(OrganizationIntegration & { tokenReference: SecretReference }) | null> { + return prisma.organizationIntegration.findFirst({ + where: { + organizationId, + service: "VERCEL", + externalOrganizationId: teamId, + deletedAt: null, + }, + include: { + tokenReference: true, + }, + }); + } + + static async findVercelOrgIntegrationForProject( + projectId: string + ): Promise<(OrganizationIntegration & { tokenReference: SecretReference }) | null> { + const projectIntegration = await prisma.organizationProjectIntegration.findFirst({ + where: { + projectId, + deletedAt: null, + organizationIntegration: { + service: "VERCEL", + deletedAt: null, + }, + }, + include: { + organizationIntegration: { + include: { + tokenReference: true, + }, + }, + }, + }); + + return projectIntegration?.organizationIntegration ?? null; + } + + static async findVercelOrgIntegrationByOrganization( + organizationId: string + ): Promise<(OrganizationIntegration & { tokenReference: SecretReference }) | null> { + return prisma.organizationIntegration.findFirst({ + where: { + organizationId, + service: "VERCEL", + deletedAt: null, + }, + include: { + tokenReference: true, + }, + }); + } + + static async syncApiKeysToVercel(params: { + projectId: string; + vercelProjectId: string; + teamId: string | null; + vercelStagingEnvironment?: { environmentId: string; displayName: string } | null; + orgIntegration: OrganizationIntegration & { tokenReference: SecretReference }; + }): Promise<{ success: boolean; errors: string[] }> { + const errors: string[] = []; + + try { + const client = await this.getVercelClient(params.orgIntegration); + + // Get all environments for the project + const environments = await prisma.runtimeEnvironment.findMany({ + where: { + projectId: params.projectId, + type: { + in: ["PRODUCTION", "STAGING", "PREVIEW", "DEVELOPMENT"], + }, + }, + select: { + id: true, + type: true, + apiKey: true, + }, + }); + + // Build the list of env vars to sync + const envVarsToSync: Array<{ + key: string; + value: string; + target: string[]; + type: "sensitive" | "encrypted" | "plain"; + environmentType: string; + }> = []; + + for (const env of environments) { + const vercelTarget = envTypeToVercelTarget( + env.type as TriggerEnvironmentType, + params.vercelStagingEnvironment?.environmentId + ); + + if (!vercelTarget) { + continue; + } + + envVarsToSync.push({ + key: "TRIGGER_SECRET_KEY", + value: env.apiKey, + target: vercelTarget, + type: "encrypted", + environmentType: env.type, + }); + } + + if (envVarsToSync.length === 0) { + return { success: true, errors: [] }; + } + + const result = await this.batchUpsertVercelEnvVars({ + client, + vercelProjectId: params.vercelProjectId, + teamId: params.teamId, + envVars: envVarsToSync, + }); + + if (result.errors.length > 0) { + errors.push(...result.errors); + } + + logger.info("Synced API keys to Vercel", { + projectId: params.projectId, + vercelProjectId: params.vercelProjectId, + syncedCount: result.created + result.updated, + created: result.created, + updated: result.updated, + errors: result.errors, + }); + + return { + success: errors.length === 0, + errors, + }; + } catch (error) { + const errorMessage = `Failed to sync API keys to Vercel: ${error instanceof Error ? error.message : "Unknown error"}`; + errors.push(errorMessage); + logger.error(errorMessage, { + projectId: params.projectId, + vercelProjectId: params.vercelProjectId, + error, + }); + return { + success: false, + errors, + }; + } + } + + static async syncSingleApiKeyToVercel(params: { + projectId: string; + environmentType: "PRODUCTION" | "STAGING" | "PREVIEW" | "DEVELOPMENT"; + apiKey: string; + }): Promise<{ success: boolean; error?: string }> { + try { + const projectIntegration = await prisma.organizationProjectIntegration.findFirst({ + where: { + projectId: params.projectId, + deletedAt: null, + organizationIntegration: { + service: "VERCEL", + deletedAt: null, + }, + }, + include: { + organizationIntegration: { + include: { + tokenReference: true, + }, + }, + }, + }); + + if (!projectIntegration) { + return { success: true }; + } + + const orgIntegration = projectIntegration.organizationIntegration; + const client = await this.getVercelClient(orgIntegration); + const teamId = await this.getTeamIdFromIntegration(orgIntegration); + + const integrationData = projectIntegration.integrationData as any; + const vercelStagingEnvironment = integrationData?.config?.vercelStagingEnvironment; + + const vercelTarget = envTypeToVercelTarget( + params.environmentType, + vercelStagingEnvironment?.environmentId + ); + + if (!vercelTarget) { + return { success: true }; + } + + await this.upsertVercelEnvVar({ + client, + vercelProjectId: projectIntegration.externalEntityId, + teamId, + key: "TRIGGER_SECRET_KEY", + value: params.apiKey, + target: vercelTarget, + type: "encrypted", + }); + + logger.info("Synced regenerated API key to Vercel", { + projectId: params.projectId, + vercelProjectId: projectIntegration.externalEntityId, + environmentType: params.environmentType, + target: vercelTarget, + }); + + return { success: true }; + } catch (error) { + const errorMessage = `Failed to sync API key to Vercel: ${error instanceof Error ? error.message : "Unknown error"}`; + logger.error(errorMessage, { + projectId: params.projectId, + environmentType: params.environmentType, + error, + }); + return { success: false, error: errorMessage }; + } + } + + static async pullEnvVarsFromVercel(params: { + projectId: string; + vercelProjectId: string; + teamId: string | null; + vercelStagingEnvironment?: { environmentId: string; displayName: string } | null; + syncEnvVarsMapping: SyncEnvVarsMapping; + orgIntegration: OrganizationIntegration & { tokenReference: SecretReference }; + }): Promise<{ success: boolean; errors: string[]; syncedCount: number }> { + const errors: string[] = []; + let syncedCount = 0; + + try { + const client = await this.getVercelClient(params.orgIntegration); + + // Get all runtime environments for the project + const runtimeEnvironments = await prisma.runtimeEnvironment.findMany({ + where: { + projectId: params.projectId, + type: { + in: ["PRODUCTION", "STAGING", "PREVIEW", "DEVELOPMENT"], + }, + }, + select: { + id: true, + type: true, + }, + }); + + const envMapping: Array<{ + triggerEnvType: "PRODUCTION" | "STAGING" | "PREVIEW" | "DEVELOPMENT"; + vercelTarget: string; + runtimeEnvironmentId: string; + }> = []; + + for (const env of runtimeEnvironments) { + const vercelTarget = envTypeToVercelTarget( + env.type as TriggerEnvironmentType, + params.vercelStagingEnvironment?.environmentId + ); + + if (!vercelTarget) { + continue; + } + + envMapping.push({ + triggerEnvType: env.type as "PRODUCTION" | "STAGING" | "PREVIEW" | "DEVELOPMENT", + vercelTarget: vercelTarget[0], + runtimeEnvironmentId: env.id, + }); + } + + if (envMapping.length === 0) { + logger.warn("No environments to sync for Vercel integration", { + projectId: params.projectId, + vercelProjectId: params.vercelProjectId, + }); + return { success: true, errors: [], syncedCount: 0 }; + } + + const envVarRepository = new EnvironmentVariablesRepository(); + + // Fetch shared env vars once (they apply across all targets) + let sharedEnvVars: Array<{ + key: string; + value: string; + target: string[]; + type: string; + isSecret: boolean; + applyToAllCustomEnvironments?: boolean; + }> = []; + + if (params.teamId) { + sharedEnvVars = await this.getVercelSharedEnvironmentVariableValues( + client, + params.teamId, + params.vercelProjectId + ); + } + + // Process each environment mapping + for (const mapping of envMapping) { + try { + const projectEnvVars = await this.getVercelEnvironmentVariableValues( + client, + params.vercelProjectId, + params.teamId, + mapping.vercelTarget + ); + + const standardTargets = ["production", "preview", "development"]; + const isCustomEnvironment = !standardTargets.includes(mapping.vercelTarget); + + const filteredSharedEnvVars = sharedEnvVars.filter((envVar) => { + const matchesTarget = envVar.target.includes(mapping.vercelTarget); + const matchesCustomEnv = isCustomEnvironment && envVar.applyToAllCustomEnvironments === true; + return matchesTarget || matchesCustomEnv; + }); + + const projectEnvVarKeys = new Set(projectEnvVars.map((v) => v.key)); + const sharedEnvVarsToAdd = filteredSharedEnvVars.filter((v) => !projectEnvVarKeys.has(v.key)); + const mergedEnvVars = [ + ...projectEnvVars, + ...sharedEnvVarsToAdd, + ]; + + if (mergedEnvVars.length === 0) { + continue; + } + + const varsToSync = mergedEnvVars.filter((envVar) => { + if (envVar.isSecret) { + return false; + } + if (envVar.key === "TRIGGER_SECRET_KEY") { + return false; + } + return shouldSyncEnvVar( + params.syncEnvVarsMapping, + envVar.key, + mapping.triggerEnvType as TriggerEnvironmentType + ); + }); + + if (varsToSync.length === 0) { + continue; + } + + const existingSecretKeys = new Set(); + const existingValues = new Map(); + + const existingVarValues = await prisma.environmentVariableValue.findMany({ + where: { + environmentId: mapping.runtimeEnvironmentId, + variable: { + projectId: params.projectId, + key: { + in: varsToSync.map((v) => v.key), + }, + }, + }, + select: { + isSecret: true, + valueReference: { + select: { + key: true, + }, + }, + variable: { + select: { + key: true, + }, + }, + }, + }); + + if (existingVarValues.length > 0) { + const secretStore = getSecretStore("DATABASE", { prismaClient: prisma }); + const SecretValue = z.object({ secret: z.string() }); + + for (const varValue of existingVarValues) { + if (varValue.isSecret) { + existingSecretKeys.add(varValue.variable.key); + } + + if (varValue.valueReference?.key) { + try { + const existingSecret = await secretStore.getSecret(SecretValue, varValue.valueReference.key); + if (existingSecret) { + existingValues.set(varValue.variable.key, existingSecret.secret); + } + } catch { + // If we can't read the existing value, we'll update it anyway + } + } + } + } + + const changedVars = varsToSync.filter((v) => { + const existingValue = existingValues.get(v.key); + return existingValue === undefined || existingValue !== v.value; + }); + + if (changedVars.length === 0) { + continue; + } + + const secretVars = changedVars.filter((v) => existingSecretKeys.has(v.key)); + const nonSecretVars = changedVars.filter((v) => !existingSecretKeys.has(v.key)); + + if (nonSecretVars.length > 0) { + const result = await envVarRepository.create(params.projectId, { + override: true, + environmentIds: [mapping.runtimeEnvironmentId], + isSecret: false, + variables: nonSecretVars.map((v) => ({ + key: v.key, + value: v.value, + })), + lastUpdatedBy: { + type: "integration", + integration: "vercel", + }, + }); + + if (result.success) { + syncedCount += nonSecretVars.length; + } else { + const errorMsg = `Failed to sync env vars for ${mapping.triggerEnvType}: ${result.error}`; + errors.push(errorMsg); + logger.error(errorMsg, { + projectId: params.projectId, + vercelProjectId: params.vercelProjectId, + vercelTarget: mapping.vercelTarget, + error: result.error, + variableErrors: result.variableErrors, + attemptedKeys: nonSecretVars.map((v) => v.key), + }); + } + } + + if (secretVars.length > 0) { + const result = await envVarRepository.create(params.projectId, { + override: true, + environmentIds: [mapping.runtimeEnvironmentId], + isSecret: true, + variables: secretVars.map((v) => ({ + key: v.key, + value: v.value, + })), + lastUpdatedBy: { + type: "integration", + integration: "vercel", + }, + }); + + if (result.success) { + syncedCount += secretVars.length; + } else { + const errorMsg = `Failed to sync secret env vars for ${mapping.triggerEnvType}: ${result.error}`; + errors.push(errorMsg); + logger.error(errorMsg, { + projectId: params.projectId, + vercelProjectId: params.vercelProjectId, + vercelTarget: mapping.vercelTarget, + error: result.error, + variableErrors: result.variableErrors, + attemptedKeys: secretVars.map((v) => v.key), + }); + } + } + } catch (envError) { + const errorMsg = `Failed to process env vars for ${mapping.triggerEnvType}: ${envError instanceof Error ? envError.message : "Unknown error"}`; + errors.push(errorMsg); + logger.error(errorMsg, { + projectId: params.projectId, + vercelProjectId: params.vercelProjectId, + vercelTarget: mapping.vercelTarget, + error: envError, + }); + } + } + + return { + success: errors.length === 0, + errors, + syncedCount, + }; + } catch (error) { + const errorMsg = `Failed to pull env vars from Vercel: ${error instanceof Error ? error.message : "Unknown error"}`; + errors.push(errorMsg); + logger.error(errorMsg, { + projectId: params.projectId, + vercelProjectId: params.vercelProjectId, + error, + }); + return { + success: false, + errors, + syncedCount, + }; + } + } + + static async batchUpsertVercelEnvVars(params: { + client: Vercel; + vercelProjectId: string; + teamId: string | null; + envVars: Array<{ + key: string; + value: string; + target: string[]; + type: "sensitive" | "encrypted" | "plain"; + environmentType?: string; // For logging purposes + }>; + }): Promise<{ created: number; updated: number; errors: string[] }> { + const { client, vercelProjectId, teamId, envVars } = params; + const errors: string[] = []; + let created = 0; + let updated = 0; + + if (envVars.length === 0) { + return { created: 0, updated: 0, errors: [] }; + } + + const existingEnvs = await client.projects.filterProjectEnvs({ + idOrName: vercelProjectId, + ...(teamId && { teamId }), + }); + + const existingEnvsList = + "envs" in existingEnvs && Array.isArray(existingEnvs.envs) ? existingEnvs.envs : []; + + const toCreate: Array<{ + key: string; + value: string; + target: string[]; + type: "sensitive" | "encrypted" | "plain"; + }> = []; + + const toUpdate: Array<{ + id: string; + key: string; + value: string; + target: string[]; + type: "sensitive" | "encrypted" | "plain"; + environmentType?: string; + }> = []; + + for (const envVar of envVars) { + const existingEnv = existingEnvsList.find((env: any) => { + if (env.key !== envVar.key) { + return false; + } + const envTargets = normalizeTarget(env.target); + return ( + envVar.target.length === envTargets.length && + envVar.target.every((t) => envTargets.includes(t)) + ); + }); + + if (existingEnv && existingEnv.id) { + toUpdate.push({ + id: existingEnv.id, + key: envVar.key, + value: envVar.value, + target: envVar.target, + type: envVar.type, + environmentType: envVar.environmentType, + }); + } else { + toCreate.push({ + key: envVar.key, + value: envVar.value, + target: envVar.target, + type: envVar.type, + }); + } + } + + if (toCreate.length > 0) { + try { + await client.projects.createProjectEnv({ + idOrName: vercelProjectId, + ...(teamId && { teamId }), + requestBody: toCreate.map((env) => ({ + key: env.key, + value: env.value, + target: env.target as any, + type: env.type, + })) as any, + }); + created = toCreate.length; + } catch (error) { + const errorMsg = `Failed to batch create env vars: ${error instanceof Error ? error.message : "Unknown error"}`; + errors.push(errorMsg); + logger.error(errorMsg, { + vercelProjectId, + teamId, + count: toCreate.length, + error, + }); + } + } + + // Update existing env vars (Vercel doesn't support batch updates) + for (const envVar of toUpdate) { + try { + await client.projects.editProjectEnv({ + idOrName: vercelProjectId, + id: envVar.id, + ...(teamId && { teamId }), + requestBody: { + value: envVar.value, + target: envVar.target as any, + type: envVar.type, + }, + }); + updated++; + } catch (error) { + const errorMsg = `Failed to update ${envVar.environmentType || envVar.key} env var: ${error instanceof Error ? error.message : "Unknown error"}`; + errors.push(errorMsg); + logger.error(errorMsg, { + vercelProjectId, + teamId, + envVarId: envVar.id, + key: envVar.key, + error, + }); + } + } + + return { created, updated, errors }; + } + + private static async upsertVercelEnvVar(params: { + client: Vercel; + vercelProjectId: string; + teamId: string | null; + key: string; + value: string; + target: string[]; + type: "sensitive" | "encrypted" | "plain"; + }): Promise { + const { client, vercelProjectId, teamId, key, value, target, type } = params; + + const existingEnvs = await client.projects.filterProjectEnvs({ + idOrName: vercelProjectId, + ...(teamId && { teamId }), + }); + + const envs = "envs" in existingEnvs && Array.isArray(existingEnvs.envs) + ? existingEnvs.envs + : []; + + // Vercel can have multiple env vars with the same key but different targets + const existingEnv = envs.find((env: any) => { + if (env.key !== key) { + return false; + } + const envTargets = normalizeTarget(env.target); + return target.length === envTargets.length && target.every((t) => envTargets.includes(t)); + }); + + if (existingEnv && existingEnv.id) { + await client.projects.editProjectEnv({ + idOrName: vercelProjectId, + id: existingEnv.id, + ...(teamId && { teamId }), + requestBody: { + value, + target: target as any, + type, + }, + }); + } else { + await client.projects.createProjectEnv({ + idOrName: vercelProjectId, + ...(teamId && { teamId }), + requestBody: { + key, + value, + target: target as any, + type, + }, + }); + } + } + + static async getAutoAssignCustomDomains( + client: Vercel, + vercelProjectId: string, + teamId?: string | null + ): Promise { + try { + // Vercel SDK lacks a getProject method — updateProject with empty body reads without modifying. + const project = await client.projects.updateProject({ + idOrName: vercelProjectId, + ...(teamId && { teamId }), + requestBody: {}, + }); + + return project.autoAssignCustomDomains ?? null; + } catch (error) { + logger.error("Failed to get Vercel project autoAssignCustomDomains", { + vercelProjectId, + teamId, + error, + }); + return null; + } + } + + /** Disable autoAssignCustomDomains — required for atomic deployments. */ + static async disableAutoAssignCustomDomains( + client: Vercel, + vercelProjectId: string, + teamId?: string | null + ): Promise<{ success: boolean; error?: string }> { + try { + await client.projects.updateProject({ + idOrName: vercelProjectId, + ...(teamId && { teamId }), + requestBody: { + autoAssignCustomDomains: false, + }, + }); + + return { success: true }; + } catch (error) { + const errorMessage = `Failed to disable autoAssignCustomDomains: ${error instanceof Error ? error.message : "Unknown error"}`; + logger.error(errorMessage, { + vercelProjectId, + teamId, + error, + }); + return { success: false, error: errorMessage }; + } + } + + static async uninstallVercelIntegration( + integration: OrganizationIntegration & { tokenReference: SecretReference } + ): Promise<{ authInvalid: boolean }> { + const client = await this.getVercelClient(integration); + + const secret = await getSecretStore(integration.tokenReference.provider).getSecret( + VercelSecretSchema, + integration.tokenReference.key + ); + + if (!secret?.installationId) { + throw new Error("Installation ID not found in Vercel integration"); + } + + try { + await client.integrations.deleteConfiguration({ + id: secret.installationId, + }); + return { authInvalid: false }; + } catch (error) { + const isAuthError = isVercelAuthError(error); + logger.error("Failed to uninstall Vercel integration", { + installationId: secret.installationId, + error: error instanceof Error ? error.message : "Unknown error", + isAuthError, + }); + // Auth errors (401/403): still clean up on our side, return flag for caller + if (isAuthError) { + return { authInvalid: true }; + } + throw error; + } + } +} + diff --git a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts index 730591f4eb..4189ea54c3 100644 --- a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts @@ -1,9 +1,14 @@ -import { flipCauseOption } from "effect/Cause"; import { PrismaClient, prisma } from "~/db.server"; import { Project } from "~/models/project.server"; import { User } from "~/models/user.server"; import { filterOrphanedEnvironments, sortEnvironments } from "~/utils/environmentSort"; import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; +import type { EnvironmentVariableUpdater } from "~/v3/environmentVariables/repository"; +import { + SyncEnvVarsMapping, + EnvSlug, +} from "~/v3/vercel/vercelProjectIntegrationSchema"; +import { VercelIntegrationService } from "~/services/vercelIntegration.server"; type Result = Awaited>; export type EnvironmentVariableWithSetValues = Result["environmentVariables"][number]; @@ -44,6 +49,9 @@ export class EnvironmentVariablesPresenter { select: { id: true, environmentId: true, + version: true, + lastUpdatedBy: true, + updatedAt: true, valueReference: { select: { key: true, @@ -67,6 +75,42 @@ export class EnvironmentVariablesPresenter { }, }); + const userIds = new Set( + environmentVariables + .flatMap((envVar) => envVar.values) + .map((value) => value.lastUpdatedBy) + .filter( + (lastUpdatedBy): lastUpdatedBy is { type: "user"; userId: string } => + lastUpdatedBy !== null && + typeof lastUpdatedBy === "object" && + "type" in lastUpdatedBy && + lastUpdatedBy.type === "user" && + "userId" in lastUpdatedBy && + typeof lastUpdatedBy.userId === "string" + ) + .map((lastUpdatedBy) => lastUpdatedBy.userId) + ); + + const users = + userIds.size > 0 + ? await this.#prismaClient.user.findMany({ + where: { + id: { + in: Array.from(userIds), + }, + }, + select: { + id: true, + name: true, + displayName: true, + avatarUrl: true, + }, + }) + : []; + + const usersRecord: Record = + Object.fromEntries(users.map((u) => [u.id, u])); + const environments = await this.#prismaClient.runtimeEnvironment.findMany({ select: { id: true, @@ -94,6 +138,18 @@ export class EnvironmentVariablesPresenter { const repository = new EnvironmentVariablesRepository(this.#prismaClient); const variables = await repository.getProject(project.id); + // Get Vercel integration data if it exists + const vercelService = new VercelIntegrationService(this.#prismaClient); + const vercelIntegration = await vercelService.getVercelProjectIntegration(project.id, true); + + let vercelSyncEnvVarsMapping: SyncEnvVarsMapping = {}; + let vercelPullEnvVarsBeforeBuild: EnvSlug[] | null = null; + + if (vercelIntegration) { + vercelSyncEnvVarsMapping = vercelIntegration.parsedIntegrationData.syncEnvVarsMapping; + vercelPullEnvVarsBeforeBuild = vercelIntegration.parsedIntegrationData.config.pullEnvVarsBeforeBuild ?? null; + } + return { environmentVariables: environmentVariables .flatMap((environmentVariable) => { @@ -101,13 +157,29 @@ export class EnvironmentVariablesPresenter { return sortedEnvironments.flatMap((env) => { const val = variable?.values.find((v) => v.environment.id === env.id); - const isSecret = - environmentVariable.values.find((v) => v.environmentId === env.id)?.isSecret ?? false; + const valueRecord = environmentVariable.values.find((v) => v.environmentId === env.id); + const isSecret = valueRecord?.isSecret ?? false; - if (!val) { + if (!val || !valueRecord) { return []; } + const lastUpdatedBy = valueRecord.lastUpdatedBy as EnvironmentVariableUpdater | null; + + const updatedByUser = + lastUpdatedBy?.type === "user" + ? (() => { + const user = usersRecord[lastUpdatedBy.userId]; + return user + ? { + id: user.id, + name: user.displayName || user.name || "Unknown", + avatarUrl: user.avatarUrl, + } + : null; + })() + : null; + return [ { id: environmentVariable.id, @@ -115,6 +187,10 @@ export class EnvironmentVariablesPresenter { environment: { type: env.type, id: env.id, branchName: env.branchName }, value: isSecret ? "" : val.value, isSecret, + version: valueRecord.version, + lastUpdatedBy, + updatedByUser, + updatedAt: valueRecord.updatedAt, }, ]; }); @@ -127,6 +203,14 @@ export class EnvironmentVariablesPresenter { branchName: environment.branchName, })), hasStaging: environments.some((environment) => environment.type === "STAGING"), + // Vercel integration data + vercelIntegration: vercelIntegration + ? { + enabled: true, + pullEnvVarsBeforeBuild: vercelPullEnvVarsBeforeBuild, + syncEnvVarsMapping: vercelSyncEnvVarsMapping, + } + : null, }; } } diff --git a/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts b/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts new file mode 100644 index 0000000000..c9524823c9 --- /dev/null +++ b/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts @@ -0,0 +1,575 @@ +import { type PrismaClient } from "@trigger.dev/database"; +import { fromPromise, ok, ResultAsync } from "neverthrow"; +import { env } from "~/env.server"; +import { logger } from "~/services/logger.server"; +import { OrgIntegrationRepository } from "~/models/orgIntegration.server"; +import { + VercelIntegrationRepository, + VercelCustomEnvironment, + VercelEnvironmentVariable, +} from "~/models/vercelIntegration.server"; +import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; +import { + VercelProjectIntegrationDataSchema, + VercelProjectIntegrationData, +} from "~/v3/vercel/vercelProjectIntegrationSchema"; +import { BasePresenter } from "./basePresenter.server"; + +type VercelSettingsOptions = { + projectId: string; + organizationId: string; +}; + +export type VercelSettingsResult = { + enabled: boolean; + hasOrgIntegration: boolean; + authInvalid?: boolean; + 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; +}; + +export type VercelAvailableProject = { + id: string; + name: string; +}; + +export type GitHubAppInstallationForVercel = { + id: string; + appInstallationId: bigint; + targetType: string; + accountHandle: string; + repositories: Array<{ + id: string; + name: string; + fullName: string; + private: boolean; + htmlUrl: string; + }>; +}; + +export type VercelOnboardingData = { + customEnvironments: VercelCustomEnvironment[]; + environmentVariables: VercelEnvironmentVariable[]; + availableProjects: VercelAvailableProject[]; + hasProjectSelected: boolean; + authInvalid?: boolean; + existingVariables: Record; // Environment slugs (non-archived only) + gitHubAppInstallations: GitHubAppInstallationForVercel[]; + isGitHubConnected: boolean; +}; + +export class VercelSettingsPresenter extends BasePresenter { + /** + * Get Vercel integration settings for the settings page + */ + public async call({ projectId, organizationId }: VercelSettingsOptions) { + try { + 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 orgIntegration = await (this._replica as PrismaClient).organizationIntegration.findFirst({ + where: { + organizationId, + service: "VERCEL", + deletedAt: null, + }, + include: { + tokenReference: true, + }, + }); + + const hasOrgIntegration = orgIntegration !== null; + + if (hasOrgIntegration) { + const tokenValidation = await VercelIntegrationRepository.validateVercelToken(orgIntegration); + if (!tokenValidation.isValid) { + return ok({ + enabled: true, + hasOrgIntegration: true, + authInvalid: true, + 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, + }; + }); + + try { + return ResultAsync.combine([ + checkOrgIntegration(), + checkGitHubConnection(), + checkStagingEnvironment(), + checkPreviewEnvironment(), + getVercelProjectIntegration(), + ]).andThen(([hasOrgIntegration, isGitHubConnected, hasStagingEnvironment, hasPreviewEnvironment, connectedProject]) => { + const fetchCustomEnvsAndProjectSettings = async (): Promise<{ + customEnvironments: VercelCustomEnvironment[]; + autoAssignCustomDomains: boolean | null; + }> => { + if (!connectedProject || !orgIntegration) { + return { customEnvironments: [], autoAssignCustomDomains: null }; + } + try { + const client = await VercelIntegrationRepository.getVercelClient(orgIntegration); + const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration); + const [customEnvsResult, autoAssign] = await Promise.all([ + VercelIntegrationRepository.getVercelCustomEnvironments( + client, + connectedProject.vercelProjectId, + teamId + ), + VercelIntegrationRepository.getAutoAssignCustomDomains( + client, + connectedProject.vercelProjectId, + teamId + ), + ]); + return { + customEnvironments: customEnvsResult.success ? customEnvsResult.data : [], + autoAssignCustomDomains: autoAssign, + }; + } catch { + return { customEnvironments: [], autoAssignCustomDomains: null }; + } + }; + + return fromPromise( + fetchCustomEnvsAndProjectSettings(), + (error) => ({ type: "other" as const, cause: error }) + ).map(({ customEnvironments, autoAssignCustomDomains }) => ({ + enabled: true, + hasOrgIntegration, + authInvalid: false, + connectedProject, + isGitHubConnected, + hasStagingEnvironment, + hasPreviewEnvironment, + customEnvironments, + autoAssignCustomDomains, + } as VercelSettingsResult)); + }).mapErr((error) => { + // Log the error and return a safe fallback + logger.error("Error in VercelSettingsPresenter.call", { error }); + return error; + }); + } catch (syncError) { + // Handle any synchronous errors that might occur + logger.error("Synchronous error in VercelSettingsPresenter.call", { error: syncError }); + return ok({ + enabled: true, + hasOrgIntegration: false, + authInvalid: true, + connectedProject: undefined, + isGitHubConnected: false, + hasStagingEnvironment: false, + hasPreviewEnvironment: false, + customEnvironments: [], + } as VercelSettingsResult); + } + } catch (error) { + // If there's an unexpected error, log it and return a safe error result + logger.error("Unexpected error in VercelSettingsPresenter.call", { error }); + return ok({ + enabled: true, + hasOrgIntegration: false, + authInvalid: true, + connectedProject: undefined, + isGitHubConnected: false, + hasStagingEnvironment: false, + hasPreviewEnvironment: false, + customEnvironments: [], + } as VercelSettingsResult); + } + } + + /** + * Get data needed for the onboarding modal (custom environments and env vars) + */ + public async getOnboardingData( + projectId: string, + organizationId: string, + vercelEnvironmentId?: string + ): Promise { + try { + 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: GitHubAppInstallationForVercel[] = 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 tokenValidation = await VercelIntegrationRepository.validateVercelToken(orgIntegration); + if (!tokenValidation.isValid) { + return { + customEnvironments: [], + environmentVariables: [], + availableProjects: [], + hasProjectSelected: false, + authInvalid: true, + existingVariables: {}, + gitHubAppInstallations, + isGitHubConnected, + }; + } + + const client = await VercelIntegrationRepository.getVercelClient(orgIntegration); + 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.success) { + return { + customEnvironments: [], + environmentVariables: [], + availableProjects: [], + hasProjectSelected: false, + authInvalid: availableProjectsResult.authInvalid, + existingVariables: {}, + gitHubAppInstallations, + isGitHubConnected, + }; + } + + if (!projectIntegration) { + return { + customEnvironments: [], + environmentVariables: [], + availableProjects: availableProjectsResult.data, + hasProjectSelected: false, + existingVariables: {}, + gitHubAppInstallations, + isGitHubConnected, + }; + } + + 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( + client, + teamId, + projectIntegration.externalEntityId + ) + : Promise.resolve({ success: true as const, data: [] }), + ]); + const authInvalid = + (!customEnvironmentsResult.success && customEnvironmentsResult.authInvalid) || + (!projectEnvVarsResult.success && projectEnvVarsResult.authInvalid) || + (!sharedEnvVarsResult.success && sharedEnvVarsResult.authInvalid); + + if (authInvalid) { + return { + customEnvironments: [], + environmentVariables: [], + availableProjects: availableProjectsResult.data, + hasProjectSelected: true, + authInvalid: true, + existingVariables: {}, + gitHubAppInstallations, + isGitHubConnected, + }; + } + + const customEnvironments = customEnvironmentsResult.success ? customEnvironmentsResult.data : []; + const projectEnvVars = projectEnvVarsResult.success ? projectEnvVarsResult.data : []; + const sharedEnvVars = sharedEnvVarsResult.success ? sharedEnvVarsResult.data : []; + + // Filter out TRIGGER_SECRET_KEY (managed by Trigger.dev) and merge project + shared env vars + const projectEnvVarKeys = new Set(projectEnvVars.map((v) => v.key)); + const mergedEnvVars: VercelEnvironmentVariable[] = [ + ...projectEnvVars + .filter((v) => v.key !== "TRIGGER_SECRET_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) && v.key !== "TRIGGER_SECRET_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, + }; + 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, + }; + } + } + + return { + customEnvironments, + environmentVariables: sortedEnvVars, + availableProjects: availableProjectsResult.data, + hasProjectSelected: true, + existingVariables: existingVariablesRecord, + gitHubAppInstallations, + isGitHubConnected, + }; + } catch (error) { + logger.error("Error in getOnboardingData", { error }); + return null; + } + } + +} \ No newline at end of file diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx index c52942a8ac..86bd5bbc95 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx @@ -151,7 +151,13 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { } const repository = new EnvironmentVariablesRepository(prisma); - const result = await repository.create(project.id, submission.value); + const result = await repository.create(project.id, { + ...submission.value, + lastUpdatedBy: { + type: "user", + userId, + }, + }); if (!result.success) { if (result.variableErrors) { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx index 80976d41fc..afd74190a3 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx @@ -9,7 +9,7 @@ import { PlusIcon, TrashIcon, } from "@heroicons/react/20/solid"; -import { Form, type MetaFunction, Outlet, useActionData, useFetcher, useNavigation } from "@remix-run/react"; +import { Form, type MetaFunction, Outlet, useActionData, useFetcher, useNavigation, useRevalidator } from "@remix-run/react"; import { type ActionFunctionArgs, type LoaderFunctionArgs, @@ -19,10 +19,12 @@ import { useEffect, useMemo, useState } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; +import { VercelLogo } from "~/components/integrations/VercelLogo"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { ClipboardField } from "~/components/primitives/ClipboardField"; import { CopyableText } from "~/components/primitives/CopyableText"; +import { DateTime } from "~/components/primitives/DateTime"; import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; import { Fieldset } from "~/components/primitives/Fieldset"; import { FormButtons } from "~/components/primitives/FormButtons"; @@ -70,6 +72,9 @@ import { EditEnvironmentVariableValue, EnvironmentVariable, } from "~/v3/environmentVariables/repository"; +import { UserAvatar } from "~/components/UserProfilePhoto"; +import { VercelIntegrationService } from "~/services/vercelIntegration.server"; +import { shouldSyncEnvVar, isPullEnvVarsEnabledForEnvironment, type TriggerEnvironmentType } from "~/v3/vercel/vercelProjectIntegrationSchema"; export const meta: MetaFunction = () => { return [ @@ -85,7 +90,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { try { const presenter = new EnvironmentVariablesPresenter(); - const { environmentVariables, environments, hasStaging } = await presenter.call({ + const { environmentVariables, environments, hasStaging, vercelIntegration } = await presenter.call({ userId, projectSlug: projectParam, }); @@ -94,6 +99,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { environmentVariables, environments, hasStaging, + vercelIntegration, }); } catch (error) { console.error(error); @@ -111,6 +117,12 @@ const schema = z.discriminatedUnion("action", [ key: z.string(), ...DeleteEnvironmentVariableValue.shape, }), + z.object({ + action: z.literal("update-vercel-sync"), + key: z.string(), + environmentType: z.enum(["PRODUCTION", "STAGING", "PREVIEW", "DEVELOPMENT"]), + syncEnabled: z.string().transform((val) => val === "true"), + }), ]); export const action = async ({ request, params }: ActionFunctionArgs) => { @@ -151,7 +163,13 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { switch (submission.value.action) { case "edit": { const repository = new EnvironmentVariablesRepository(prisma); - const result = await repository.editValue(project.id, submission.value); + const result = await repository.editValue(project.id, { + ...submission.value, + lastUpdatedBy: { + type: "user", + userId, + }, + }); if (!result.success) { submission.error.key = [result.error]; @@ -169,6 +187,23 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { return json(submission); } + // Clean up syncEnvVarsMapping if Vercel integration exists + const vercelService = new VercelIntegrationService(); + const integration = await vercelService.getVercelProjectIntegration(project.id); + if (integration) { + const runtimeEnv = await prisma.runtimeEnvironment.findUnique({ + where: { id: submission.value.environmentId }, + select: { type: true }, + }); + if (runtimeEnv) { + await vercelService.removeSyncEnvVarForEnvironment( + project.id, + submission.value.key, + runtimeEnv.type as TriggerEnvironmentType + ); + } + } + return redirectWithSuccessMessage( v3EnvironmentVariablesPath( { slug: organizationSlug }, @@ -179,12 +214,31 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { `Deleted ${submission.value.key} environment variable` ); } + case "update-vercel-sync": { + const vercelService = new VercelIntegrationService(); + const integration = await vercelService.getVercelProjectIntegration(project.id); + + if (!integration) { + submission.error.key = ["Vercel integration not found"]; + return json(submission); + } + + // Update the sync mapping for the specific env var and environment + await vercelService.updateSyncEnvVarForEnvironment( + project.id, + submission.value.key, + submission.value.environmentType, + submission.value.syncEnabled + ); + + return json({ success: true }); + } } }; export default function Page() { const [revealAll, setRevealAll] = useState(false); - const { environmentVariables, environments } = useTypedLoaderData(); + const { environmentVariables, environments, vercelIntegration } = useTypedLoaderData(); const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); @@ -279,10 +333,32 @@ export default function Page() { - Key - Value - Environment - + + Key + + + Value + + + Environment + + {vercelIntegration?.enabled && ( + + + Sync + + + } + content="When enabled, this variable will be pulled from Vercel during builds. Requires 'Pull env vars before build' to be enabled in settings." + /> + + )} + + Updated + + Actions @@ -341,9 +417,54 @@ export default function Page() { + {vercelIntegration?.enabled && ( + + {variable.environment.type !== "DEVELOPMENT" && ( + + )} + + )} + +
+ {variable.updatedByUser ? ( +
+ + {variable.updatedByUser.name} +
+ ) : (variable.lastUpdatedBy?.type === "integration" && variable.lastUpdatedBy?.integration === 'vercel' ) ? ( +
+ + + {variable.lastUpdatedBy.integration} + +
+ ) : null} + {variable.updatedAt ? ( + + + + ) : null} +
+
- + {environmentVariables.length === 0 ? (
You haven't set any environment variables yet. @@ -430,7 +551,7 @@ function EditEnvironmentVariablePanel({ @@ -526,8 +647,82 @@ function DeleteEnvironmentVariableButton({ leadingIconClassName="text-rose-500 group-hover/button:text-text-bright transition-colors" className="ml-0.5 transition-colors group-hover/button:bg-error" > - {isLoading ? "Deleting" : "Delete"} + {isLoading ? "Deleting" : ""} ); } + +/** + * Toggle component for controlling whether an environment variable is pulled from Vercel. + * + * When enabled, the variable will be pulled from Vercel during builds. + * By default, all variables are pulled unless explicitly disabled. + * + * Note: If the env slug is missing from syncEnvVarsMapping, all vars are pulled by default. + * Only when syncEnvVarsMapping[envSlug][envVarName] = false, the env var is skipped during builds. + */ +function VercelSyncCheckbox({ + envVarKey, + environmentType, + syncEnabled, + pullEnvVarsEnabledForEnv, +}: { + envVarKey: string; + environmentType: "PRODUCTION" | "STAGING" | "PREVIEW" | "DEVELOPMENT"; + syncEnabled: boolean; + pullEnvVarsEnabledForEnv: boolean; +}) { + const fetcher = useFetcher(); + const revalidator = useRevalidator(); + + const isLoading = fetcher.state !== "idle"; + + // Revalidate loader data after successful submission (without full page reload) + useEffect(() => { + if (fetcher.state === "idle" && fetcher.data) { + const data = fetcher.data as { success?: boolean }; + if (data.success) { + revalidator.revalidate(); + } + } + }, [fetcher.state, fetcher.data, revalidator]); + + const handleChange = (checked: boolean) => { + fetcher.submit( + { + action: "update-vercel-sync", + key: envVarKey, + environmentType, + syncEnabled: checked.toString(), + }, + { method: "post" } + ); + }; + + // If pull env vars is disabled for this environment, show disabled state + if (!pullEnvVarsEnabledForEnv) { + return ( + {}} + /> + } + content="Enable 'Pull env vars before build' for this environment in Vercel settings." + /> + ); + } + + return ( + + ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx index 66ea64cb36..627c5e1cfe 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx @@ -39,11 +39,20 @@ import { ProjectSettingsService } from "~/services/projectSettings.server"; import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; import { organizationPath, v3ProjectPath, EnvironmentParamSchema, v3BillingPath } from "~/utils/pathBuilder"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useCallback, useRef } from "react"; +import { useSearchParams } from "@remix-run/react"; import { useEnvironment } from "~/hooks/useEnvironment"; import { ProjectSettingsPresenter } from "~/services/projectSettingsPresenter.server"; import { type BuildSettings } from "~/v3/buildSettings"; import { GitHubSettingsPanel } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github"; +import { + VercelSettingsPanel, + VercelOnboardingModal, + vercelResourcePath, +} from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel"; +import type { loader as vercelLoader } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel"; +import { OrgIntegrationRepository } from "~/models/orgIntegration.server"; +import { useTypedFetcher } from "remix-typedjson"; export const meta: MetaFunction = () => { return [ @@ -92,6 +101,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { return typedjson({ githubAppEnabled: gitHubApp.enabled, buildSettings, + vercelIntegrationEnabled: OrgIntegrationRepository.isVercelSupported, }); }; @@ -290,12 +300,121 @@ export const action: ActionFunction = async ({ request, params }) => { }; export default function Page() { - const { githubAppEnabled, buildSettings } = useTypedLoaderData(); + const { githubAppEnabled, buildSettings, vercelIntegrationEnabled } = + useTypedLoaderData(); const project = useProject(); const organization = useOrganization(); const environment = useEnvironment(); const lastSubmission = useActionData(); const navigation = useNavigation(); + const [searchParams, setSearchParams] = useSearchParams(); + + // Vercel onboarding modal state + const hasQueryParam = searchParams.get("vercelOnboarding") === "true"; + const nextUrl = searchParams.get("next"); + const [isModalOpen, setIsModalOpen] = useState(false); + const vercelFetcher = useTypedFetcher(); + + // Helper to open modal and ensure query param is present + const openVercelOnboarding = useCallback(() => { + setIsModalOpen(true); + // Ensure query param is present to maintain state during form submissions + if (!hasQueryParam) { + setSearchParams((prev) => { + prev.set("vercelOnboarding", "true"); + return prev; + }); + } + }, [hasQueryParam, setSearchParams]); + + const closeVercelOnboarding = useCallback(() => { + // Remove query param if present + if (hasQueryParam) { + setSearchParams((prev) => { + prev.delete("vercelOnboarding"); + return prev; + }); + } + // Close modal + setIsModalOpen(false); + }, [hasQueryParam, setSearchParams]); + + // When query param is present, handle modal opening + // Note: We don't close the modal based on data state during onboarding - only when explicitly closed + useEffect(() => { + if (hasQueryParam && vercelIntegrationEnabled) { + // Ensure query param is present and modal is open + if (vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") { + // Data is loaded, ensure modal is open (query param takes precedence) + if (!isModalOpen) { + openVercelOnboarding(); + } + } else if (vercelFetcher.state === "idle" && !vercelFetcher.data?.onboardingData) { + // Load onboarding data + vercelFetcher.load( + `${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true` + ); + } + } else if (!hasQueryParam && isModalOpen) { + // Query param removed but modal is open, close modal + setIsModalOpen(false); + } + }, [hasQueryParam, vercelIntegrationEnabled, organization.slug, project.slug, environment.slug, vercelFetcher.data, vercelFetcher.state, isModalOpen, openVercelOnboarding]); + + // Ensure modal stays open when query param is present (even after data reloads) + // This is a safeguard to prevent the modal from closing during form submissions + useEffect(() => { + if (hasQueryParam && !isModalOpen) { + // Query param is present but modal is closed, open it + // This ensures the modal stays open during the onboarding flow + openVercelOnboarding(); + } + }, [hasQueryParam, isModalOpen, openVercelOnboarding]); + + // When data finishes loading (from query param), ensure modal is open + useEffect(() => { + if (hasQueryParam && vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") { + // Data loaded and query param is present, ensure modal is open + if (!isModalOpen) { + openVercelOnboarding(); + } + } + }, [hasQueryParam, vercelFetcher.data, vercelFetcher.state, isModalOpen, openVercelOnboarding]); + + + // Track if we're waiting for data from button click (not query param) + const waitingForButtonClickRef = useRef(false); + + // Handle opening modal from button click (without query param) + const handleOpenVercelModal = useCallback(() => { + // Add query param to maintain state during form submissions + if (!hasQueryParam) { + setSearchParams((prev) => { + prev.set("vercelOnboarding", "true"); + return prev; + }); + } + + if (vercelFetcher.data && vercelFetcher.data.onboardingData) { + // Data already loaded, open modal immediately + openVercelOnboarding(); + } else { + // Need to load data first, mark that we're waiting for button click + waitingForButtonClickRef.current = true; + vercelFetcher.load( + `${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true` + ); + } + }, [organization.slug, project.slug, environment.slug, vercelFetcher, setSearchParams, hasQueryParam, openVercelOnboarding]); + + // When data loads from button click, open modal + useEffect(() => { + if (waitingForButtonClickRef.current && vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") { + // Data loaded from button click, open modal and ensure query param is present + waitingForButtonClickRef.current = false; + openVercelOnboarding(); + } + }, [vercelFetcher.data, vercelFetcher.state, openVercelOnboarding]); const [hasRenameFormChanges, setHasRenameFormChanges] = useState(false); @@ -425,6 +544,21 @@ export default function Page() {
+ {vercelIntegrationEnabled && ( +
+ Vercel integration +
+ +
+
+ )} +
Build settings
@@ -477,6 +611,29 @@ export default function Page() {
+ + {/* Vercel Onboarding Modal */} + {vercelIntegrationEnabled && ( + { + vercelFetcher.load( + `${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true${ + vercelEnvironmentId ? `&vercelEnvironmentId=${vercelEnvironmentId}` : "" + }` + ); + }} + /> + )} ); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx new file mode 100644 index 0000000000..413bea3e10 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx @@ -0,0 +1,370 @@ +import type { + ActionFunctionArgs, + LoaderFunctionArgs, +} from "@remix-run/node"; +import { json, redirect } from "@remix-run/node"; +import { fromPromise } from "neverthrow"; +import { Form, useActionData, useNavigation } from "@remix-run/react"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { z } from "zod"; +import { DialogClose } from "@radix-ui/react-dialog"; +import { Button } from "~/components/primitives/Buttons"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "~/components/primitives/Dialog"; +import { FormButtons } from "~/components/primitives/FormButtons"; +import { Header1 } from "~/components/primitives/Headers"; +import { PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { Table, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow } from "~/components/primitives/Table"; +import { VercelIntegrationRepository } from "~/models/vercelIntegration.server"; +import { $transaction, prisma } from "~/db.server"; +import { requireOrganization } from "~/services/org.server"; +import { OrganizationParamsSchema } from "~/utils/pathBuilder"; +import { logger } from "~/services/logger.server"; +import { TrashIcon } from "@heroicons/react/20/solid"; +import { v3ProjectSettingsPath } from "~/utils/pathBuilder"; +import { LinkButton } from "~/components/primitives/Buttons"; + +function formatDate(date: Date): string { + return new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + second: "2-digit", + hour12: true, + }).format(date); +} + +const SearchParamsSchema = OrganizationParamsSchema.extend({ + configurationId: z.string().optional(), +}); + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const { organizationSlug, configurationId } = SearchParamsSchema.parse(params); + const { organization } = await requireOrganization(request, organizationSlug); + + const url = new URL(request.url); + + // Find Vercel integration for this organization + let vercelIntegration = await prisma.organizationIntegration.findFirst({ + where: { + organizationId: organization.id, + service: "VERCEL", + deletedAt: null, + // If configurationId is provided, filter by it in integrationData + ...(configurationId && { + integrationData: { + path: ["installationId"], + equals: configurationId, + }, + }), + }, + include: { + tokenReference: true, + }, + }); + + if (!vercelIntegration) { + return typedjson({ + organization, + vercelIntegration: null, + connectedProjects: [], + teamId: null, + installationId: null, + }); + } + + // Get team ID from integrationData + const integrationData = vercelIntegration.integrationData as any; + const teamId = integrationData?.teamId ?? null; + const installationId = integrationData?.installationId ?? null; + + // Get all connected projects for this integration + const connectedProjects = await prisma.organizationProjectIntegration.findMany({ + where: { + organizationIntegrationId: vercelIntegration.id, + deletedAt: null, + }, + include: { + project: { + select: { + id: true, + slug: true, + name: true, + }, + }, + }, + orderBy: { + createdAt: "desc", + }, + }); + + return typedjson({ + organization, + vercelIntegration, + connectedProjects, + teamId, + installationId, + }); +}; + +const ActionSchema = z.object({ + intent: z.literal("uninstall"), +}); + +export const action = async ({ request, params }: ActionFunctionArgs) => { + const { organizationSlug } = OrganizationParamsSchema.parse(params); + const { organization, userId } = await requireOrganization(request, organizationSlug); + + const formData = await request.formData(); + const result = ActionSchema.safeParse({ intent: formData.get("intent") }); + if (!result.success) { + return json({ error: "Invalid action" }, { status: 400 }); + } + + // Find Vercel integration + const vercelIntegration = await prisma.organizationIntegration.findFirst({ + where: { + organizationId: organization.id, + service: "VERCEL", + deletedAt: null, + }, + include: { + tokenReference: true, + }, + }); + + if (!vercelIntegration) { + return json({ error: "Vercel integration not found" }, { status: 404 }); + } + + const uninstallActionResult = await fromPromise( + (async () => { + // First, attempt to uninstall the integration from Vercel side + const uninstallResult = await VercelIntegrationRepository.uninstallVercelIntegration(vercelIntegration); + + // Then soft-delete the integration and all connected projects in a transaction + await $transaction(prisma, async (tx) => { + // Soft-delete all connected projects + await tx.organizationProjectIntegration.updateMany({ + where: { + organizationIntegrationId: vercelIntegration.id, + deletedAt: null, + }, + data: { deletedAt: new Date() }, + }); + + // Soft-delete the integration record + await tx.organizationIntegration.update({ + where: { id: vercelIntegration.id }, + data: { deletedAt: new Date() }, + }); + }); + + return uninstallResult; + })(), + (error) => error + ); + + if (uninstallActionResult.isErr()) { + logger.error("Failed to uninstall Vercel integration", { + organizationId: organization.id, + organizationSlug, + userId, + integrationId: vercelIntegration.id, + error: uninstallActionResult.error instanceof Error ? uninstallActionResult.error.message : String(uninstallActionResult.error), + }); + + return json( + { error: "Failed to uninstall Vercel integration. Please try again." }, + { status: 500 } + ); + } + + if (uninstallActionResult.value.authInvalid) { + logger.warn("Vercel integration uninstalled with auth error - token invalid", { + organizationId: organization.id, + organizationSlug, + userId, + integrationId: vercelIntegration.id, + }); + } else { + logger.info("Vercel integration uninstalled successfully", { + organizationId: organization.id, + organizationSlug, + userId, + integrationId: vercelIntegration.id, + }); + } + + // Redirect back to organization settings + return redirect(`/orgs/${organizationSlug}/settings`); +}; + +export default function VercelIntegrationPage() { + const { organization, vercelIntegration, connectedProjects, teamId, installationId } = + useTypedLoaderData(); + const actionData = useActionData(); + const navigation = useNavigation(); + const isUninstalling = navigation.state === "submitting" && + navigation.formData?.get("intent") === "uninstall"; + + if (!vercelIntegration) { + return ( + + +
+ No Vercel Integration Found + + This organization doesn't have a Vercel integration configured. + +
+
+
+ ); + } + + return ( + + +
+ Vercel Integration + + Manage your organization's Vercel integration and connected projects. + +
+ + {/* Integration Info Section */} +
+
+
+

Integration Details

+
+ {teamId && ( +
+ Vercel Team ID: {teamId} +
+ )} + {installationId && ( +
+ Installation ID: {installationId} +
+ )} +
+ Installed:{" "} + {formatDate(new Date(vercelIntegration.createdAt))} +
+
+
+
+ + + + + + + Remove Vercel Integration + + + This will permanently remove the Vercel integration and disconnect all projects. + This action cannot be undone. + + + + + + } + cancelButton={ + + + + } + /> + + + {actionData?.error && ( + + {actionData.error} + + )} +
+
+
+ + {/* Connected Projects Section */} +
+

+ Connected Projects ({connectedProjects.length}) +

+ + {connectedProjects.length === 0 ? ( +
+ + No projects are currently connected to this Vercel integration. + +
+ ) : ( +
+ + + Project Name + Vercel Project ID + Connected + Actions + + + + {connectedProjects.map((projectIntegration) => ( + + {projectIntegration.project.name} + + {projectIntegration.externalEntityId} + + + {formatDate(new Date(projectIntegration.createdAt))} + + + + Configure + + + + ))} + +
+ )} +
+ + + ); +} \ No newline at end of file diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx index 68c3306e28..46768f8326 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx @@ -31,7 +31,9 @@ import { organizationPath, selectPlanPath, v3ProjectPath, + v3ProjectSettingsPath, } from "~/utils/pathBuilder"; +import { generateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server"; export async function loader({ params, request }: LoaderFunctionArgs) { const userId = await requireUserId(request); @@ -103,6 +105,12 @@ export const action: ActionFunction = async ({ request, params }) => { return json(submission); } + // Check for Vercel integration params in URL + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const configurationId = url.searchParams.get("configurationId"); + const next = url.searchParams.get("next"); + try { const project = await createProject({ organizationSlug: organizationSlug, @@ -111,6 +119,44 @@ export const action: ActionFunction = async ({ request, params }) => { version: submission.value.projectVersion, }); + // If this is a Vercel integration flow, generate state and redirect to connect + if (code && configurationId) { + const environment = await prisma.runtimeEnvironment.findFirst({ + where: { + projectId: project.id, + slug: "prod", + archivedAt: null, + }, + }); + + if (!environment) { + return redirectWithErrorMessage( + newProjectPath({ slug: organizationSlug }), + request, + "Failed to find project environment." + ); + } + + const state = await generateVercelOAuthState({ + organizationId: project.organization.id, + projectId: project.id, + environmentSlug: environment.slug, + organizationSlug: project.organization.slug, + projectSlug: project.slug, + }); + + const params = new URLSearchParams({ + state, + code, + configurationId, + origin: "marketplace", + }); + if (next) { + params.set("next", next); + } + return redirect(`/vercel/connect?${params.toString()}`); + } + return redirectWithSuccessMessage( v3ProjectPath(project.organization, project), request, diff --git a/apps/webapp/app/routes/_app.orgs.new/route.tsx b/apps/webapp/app/routes/_app.orgs.new/route.tsx index a677782eae..0a5c7fdd6a 100644 --- a/apps/webapp/app/routes/_app.orgs.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.new/route.tsx @@ -69,6 +69,27 @@ export const action: ActionFunction = async ({ request }) => { }); } + // Preserve Vercel integration params if present + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const configurationId = url.searchParams.get("configurationId"); + const integration = url.searchParams.get("integration"); + const next = url.searchParams.get("next"); + + if (code && configurationId && integration === "vercel") { + // Redirect to projects/new with params preserved + const params = new URLSearchParams({ + code, + configurationId, + integration, + }); + if (next) { + params.set("next", next); + } + const redirectUrl = `${organizationPath(organization)}/projects/new?${params.toString()}`; + return redirect(redirectUrl); + } + return redirect(organizationPath(organization)); } catch (error: any) { return json({ errors: { body: error.message } }, { status: 400 }); diff --git a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts index ca3417b75b..d9ee637a7c 100644 --- a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts +++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts @@ -39,6 +39,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { tasks: true, }, }, + integrationDeployments: true, }, }); @@ -69,5 +70,15 @@ export async function loader({ request, params }: LoaderFunctionArgs) { })), } : undefined, + integrationDeployments: + deployment.integrationDeployments.length > 0 + ? deployment.integrationDeployments.map((id) => ({ + id: id.id, + integrationName: id.integrationName, + integrationDeploymentId: id.integrationDeploymentId, + commitSHA: id.commitSHA, + createdAt: id.createdAt, + })) + : undefined, } satisfies GetDeploymentResponseBody); } diff --git a/apps/webapp/app/routes/api.v1.orgs.$organizationSlug.projects.$projectParam.vercel.projects.ts b/apps/webapp/app/routes/api.v1.orgs.$organizationSlug.projects.$projectParam.vercel.projects.ts new file mode 100644 index 0000000000..aaf5468588 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.orgs.$organizationSlug.projects.$projectParam.vercel.projects.ts @@ -0,0 +1,147 @@ +import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { json } from "@remix-run/server-runtime"; +import { fromPromise } from "neverthrow"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { apiCors } from "~/utils/apiCors"; +import { logger } from "~/services/logger.server"; +import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { VercelIntegrationService } from "~/services/vercelIntegration.server"; + +const ParamsSchema = z.object({ + organizationSlug: z.string(), + projectParam: z.string(), +}); + +/** + * API endpoint to retrieve connected Vercel projects for a Trigger.dev project. + * + * GET /api/v1/orgs/:organizationSlug/projects/:projectParam/vercel/projects + * + * Returns: + * - vercelProject: The connected Vercel project details (if any) + * - config: The Vercel integration configuration + * - syncEnvVarsMapping: The environment variable sync mapping + */ +export async function loader({ request, params }: LoaderFunctionArgs) { + // Handle CORS + if (request.method === "OPTIONS") { + return apiCors(request, json({})); + } + + const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); + + if (!authenticationResult) { + return apiCors( + request, + json({ error: "Invalid or Missing Access Token" }, { status: 401 }) + ); + } + + const parsedParams = ParamsSchema.safeParse(params); + if (!parsedParams.success) { + return apiCors( + request, + json({ error: "Invalid parameters" }, { status: 400 }) + ); + } + + const { organizationSlug, projectParam } = parsedParams.data; + + const result = await fromPromise( + (async () => { + // Find the project, verifying org membership + const project = await prisma.project.findFirst({ + where: { + slug: projectParam, + organization: { + slug: organizationSlug, + members: { + some: { + userId: authenticationResult.userId, + }, + }, + }, + deletedAt: null, + }, + select: { + id: true, + name: true, + slug: true, + organizationId: true, + }, + }); + + if (!project) { + return { type: "not_found" as const }; + } + + // Get Vercel integration for the project + const vercelService = new VercelIntegrationService(); + const integration = await vercelService.getVercelProjectIntegration(project.id); + + return { type: "success" as const, project, integration }; + })(), + (error) => error + ); + + if (result.isErr()) { + logger.error("Failed to fetch Vercel projects", { + error: result.error, + organizationSlug, + projectParam, + }); + + return apiCors( + request, + json({ error: "Internal server error" }, { status: 500 }) + ); + } + + if (result.value.type === "not_found") { + return apiCors( + request, + json({ error: "Project not found" }, { status: 404 }) + ); + } + + const { project, integration } = result.value; + + if (!integration) { + return apiCors( + request, + json({ + connected: false, + vercelProject: null, + config: null, + syncEnvVarsMapping: null, + }) + ); + } + + const { parsedIntegrationData } = integration; + + return apiCors( + request, + json({ + connected: true, + vercelProject: { + id: parsedIntegrationData.vercelProjectId, + name: parsedIntegrationData.vercelProjectName, + teamId: parsedIntegrationData.vercelTeamId, + }, + config: { + atomicBuilds: parsedIntegrationData.config.atomicBuilds, + pullEnvVarsBeforeBuild: parsedIntegrationData.config.pullEnvVarsBeforeBuild, + vercelStagingEnvironment: parsedIntegrationData.config.vercelStagingEnvironment, + }, + syncEnvVarsMapping: parsedIntegrationData.syncEnvVarsMapping, + triggerProject: { + id: project.id, + name: project.name, + slug: project.slug, + }, + }) + ); +} + diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.import.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.import.ts index ad2372a654..53bc4429c1 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.import.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.import.ts @@ -41,10 +41,13 @@ export async function action({ params, request }: ActionFunctionArgs) { const result = await repository.create(environment.project.id, { override: typeof body.override === "boolean" ? body.override : false, environmentIds: [environment.id], + // Pass parent environment ID so new variables can inherit isSecret from parent + parentEnvironmentId: environment.parentEnvironmentId ?? undefined, variables: Object.entries(body.variables).map(([key, value]) => ({ key, value, })), + lastUpdatedBy: body.source, }); // Only sync parent variables if this is a branch environment @@ -56,6 +59,7 @@ export async function action({ params, request }: ActionFunctionArgs) { key, value, })), + lastUpdatedBy: body.source, }); let childFailure = !result.success ? result : undefined; diff --git a/apps/webapp/app/routes/auth.github.callback.tsx b/apps/webapp/app/routes/auth.github.callback.tsx index 42473c64a4..2313b348f4 100644 --- a/apps/webapp/app/routes/auth.github.callback.tsx +++ b/apps/webapp/app/routes/auth.github.callback.tsx @@ -5,6 +5,7 @@ import { getSession, redirectWithErrorMessage } from "~/models/message.server"; import { authenticator } from "~/services/auth.server"; import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server"; import { commitSession } from "~/services/sessionStorage.server"; +import { trackAndClearReferralSource } from "~/services/referralSource.server"; import { redirectCookie } from "./auth.github"; import { sanitizeRedirectPath } from "~/utils"; @@ -17,7 +18,6 @@ export let loader: LoaderFunction = async ({ request }) => { failureRedirect: "/login", // If auth fails, the failureRedirect will be thrown as a Response }); - // manually get the session const session = await getSession(request.headers.get("cookie")); const userRecord = await prisma.user.findFirst({ @@ -49,12 +49,13 @@ export let loader: LoaderFunction = async ({ request }) => { return redirect("/login/mfa", { headers }); } - // and store the user data session.set(authenticator.sessionKey, auth); const headers = new Headers(); headers.append("Set-Cookie", await commitSession(session)); headers.append("Set-Cookie", await setLastAuthMethodHeader("github")); + await trackAndClearReferralSource(request, auth.userId, headers); + return redirect(redirectTo, { headers }); }; diff --git a/apps/webapp/app/routes/auth.google.callback.tsx b/apps/webapp/app/routes/auth.google.callback.tsx index 783ddce3a3..65dabd605c 100644 --- a/apps/webapp/app/routes/auth.google.callback.tsx +++ b/apps/webapp/app/routes/auth.google.callback.tsx @@ -5,6 +5,7 @@ import { getSession, redirectWithErrorMessage } from "~/models/message.server"; import { authenticator } from "~/services/auth.server"; import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server"; import { commitSession } from "~/services/sessionStorage.server"; +import { trackAndClearReferralSource } from "~/services/referralSource.server"; import { redirectCookie } from "./auth.google"; import { sanitizeRedirectPath } from "~/utils"; @@ -17,7 +18,6 @@ export let loader: LoaderFunction = async ({ request }) => { failureRedirect: "/login", // If auth fails, the failureRedirect will be thrown as a Response }); - // manually get the session const session = await getSession(request.headers.get("cookie")); const userRecord = await prisma.user.findFirst({ @@ -49,13 +49,14 @@ export let loader: LoaderFunction = async ({ request }) => { return redirect("/login/mfa", { headers }); } - // and store the user data session.set(authenticator.sessionKey, auth); const headers = new Headers(); headers.append("Set-Cookie", await commitSession(session)); headers.append("Set-Cookie", await setLastAuthMethodHeader("google")); + await trackAndClearReferralSource(request, auth.userId, headers); + return redirect(redirectTo, { headers }); }; diff --git a/apps/webapp/app/routes/confirm-basic-details.tsx b/apps/webapp/app/routes/confirm-basic-details.tsx index 4187a2e9d0..0596ee8b52 100644 --- a/apps/webapp/app/routes/confirm-basic-details.tsx +++ b/apps/webapp/app/routes/confirm-basic-details.tsx @@ -25,6 +25,7 @@ import { redirectWithSuccessMessage } from "~/models/message.server"; import { updateUser } from "~/models/user.server"; import { requireUserId } from "~/services/session.server"; import { rootPath } from "~/utils/pathBuilder"; +import { getVercelInstallParams } from "~/v3/vercel"; function createSchema( constraints: { @@ -105,7 +106,24 @@ export const action: ActionFunction = async ({ request }) => { referralSource: submission.value.referralSource, }); - return redirectWithSuccessMessage(rootPath(), request, "Your details have been updated."); + // Preserve Vercel integration params if present + const vercelParams = getVercelInstallParams(request); + let redirectUrl = rootPath(); + + if (vercelParams) { + // Redirect to orgs/new with params preserved + const params = new URLSearchParams({ + code: vercelParams.code, + configurationId: vercelParams.configurationId, + integration: "vercel", + }); + if (vercelParams.next) { + params.set("next", vercelParams.next); + } + redirectUrl = `/orgs/new?${params.toString()}`; + } + + return redirectWithSuccessMessage(redirectUrl, request, "Your details have been updated."); } catch (error: any) { return json({ errors: { body: error.message } }, { status: 400 }); } diff --git a/apps/webapp/app/routes/login._index/route.tsx b/apps/webapp/app/routes/login._index/route.tsx index 40cea7905c..8878ffc888 100644 --- a/apps/webapp/app/routes/login._index/route.tsx +++ b/apps/webapp/app/routes/login._index/route.tsx @@ -167,7 +167,7 @@ export default function LoginPage() {
{data.lastAuthMethod === "email" && } { const parentMeta = matches @@ -160,11 +161,13 @@ async function completeLogin(request: Request, session: Session, userId: string) session.unset("pending-mfa-user-id"); session.unset("pending-mfa-redirect-to"); - return redirect(redirectTo, { - headers: { - "Set-Cookie": await sessionStorage.commitSession(authSession), - }, - }); + const headers = new Headers(); + headers.append("Set-Cookie", await sessionStorage.commitSession(authSession)); + headers.append("Set-Cookie", await commitSession(session)); + + await trackAndClearReferralSource(request, userId, headers); + + return redirect(redirectTo, { headers }); } export default function LoginMfaPage() { diff --git a/apps/webapp/app/routes/magic.tsx b/apps/webapp/app/routes/magic.tsx index c45b6882ca..682f0ef46e 100644 --- a/apps/webapp/app/routes/magic.tsx +++ b/apps/webapp/app/routes/magic.tsx @@ -6,6 +6,7 @@ import { authenticator } from "~/services/auth.server"; import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server"; import { getRedirectTo } from "~/services/redirectTo.server"; import { commitSession, getSession } from "~/services/sessionStorage.server"; +import { trackAndClearReferralSource } from "~/services/referralSource.server"; export async function loader({ request }: LoaderFunctionArgs) { const redirectTo = await getRedirectTo(request); @@ -53,5 +54,7 @@ export async function loader({ request }: LoaderFunctionArgs) { headers.append("Set-Cookie", await commitSession(session)); headers.append("Set-Cookie", await setLastAuthMethodHeader("email")); + await trackAndClearReferralSource(request, auth.userId, headers); + return redirect(redirectTo ?? "/", { headers }); } diff --git a/apps/webapp/app/routes/resources.environments.$environmentId.regenerate-api-key.tsx b/apps/webapp/app/routes/resources.environments.$environmentId.regenerate-api-key.tsx index 7ad6b1c6c5..534a07d7fb 100644 --- a/apps/webapp/app/routes/resources.environments.$environmentId.regenerate-api-key.tsx +++ b/apps/webapp/app/routes/resources.environments.$environmentId.regenerate-api-key.tsx @@ -2,8 +2,10 @@ import type { ActionFunctionArgs } from "@remix-run/server-runtime"; import { z } from "zod"; import { environmentFullTitle } from "~/components/environments/EnvironmentLabel"; import { regenerateApiKey } from "~/models/api-key.server"; +import { VercelIntegrationRepository } from "~/models/vercelIntegration.server"; import { jsonWithErrorMessage, jsonWithSuccessMessage } from "~/models/message.server"; import { requireUserId } from "~/services/session.server"; +import { logger } from "~/services/logger.server"; const ParamsSchema = z.object({ environmentId: z.string(), @@ -22,6 +24,13 @@ export async function action({ request, params }: ActionFunctionArgs) { try { const updatedEnvironment = await regenerateApiKey({ userId, environmentId }); + // Sync the regenerated API key to Vercel if integration exists + await syncApiKeyToVercelInBackground( + updatedEnvironment.projectId, + updatedEnvironment.type as "PRODUCTION" | "STAGING" | "PREVIEW" | "DEVELOPMENT", + updatedEnvironment.apiKey + ); + return jsonWithSuccessMessage( { ok: true }, request, @@ -37,3 +46,28 @@ export async function action({ request, params }: ActionFunctionArgs) { ); } } + +/** + * Sync the API key to Vercel. + * Errors are logged but won't fail the API key regeneration. + */ +async function syncApiKeyToVercelInBackground( + projectId: string, + environmentType: "PRODUCTION" | "STAGING" | "PREVIEW" | "DEVELOPMENT", + apiKey: string +): Promise { + try { + const result = await VercelIntegrationRepository.syncSingleApiKeyToVercel({ + projectId, + environmentType, + apiKey, + }); + } catch (error) { + // Log but don't throw - we don't want to fail the main operation + logger.warn("Error syncing regenerated API key to Vercel", { + projectId, + environmentType, + error, + }); + } +} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx index bb7406ed44..2673a9c464 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx @@ -330,12 +330,15 @@ export function ConnectGitHubRepoModal({ projectSlug, environmentSlug, redirectUrl, + preventDismiss, }: { gitHubAppInstallations: GitHubAppInstallation[]; organizationSlug: string; projectSlug: string; environmentSlug: string; redirectUrl?: string; + /** When true, prevents closing the modal via Escape key or clicking outside */ + preventDismiss?: boolean; }) { const [isModalOpen, setIsModalOpen] = useState(false); const lastSubmission = useActionData() as any; @@ -385,13 +388,34 @@ export function ConnectGitHubRepoModal({ const actionUrl = gitHubResourcePath(organizationSlug, projectSlug, environmentSlug); return ( - + { + // When preventDismiss is true, only allow opening, not closing + if (preventDismiss && !open) { + return; + } + setIsModalOpen(open); + }} + > - + { + if (preventDismiss) { + e.preventDefault(); + } + }} + onEscapeKeyDown={(e) => { + if (preventDismiss) { + e.preventDefault(); + } + }} + > Connect GitHub repository
diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx new file mode 100644 index 0000000000..2d6f71010b --- /dev/null +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx @@ -0,0 +1,962 @@ +import { useForm } from "@conform-to/react"; +import { parse } from "@conform-to/zod"; +import { + CheckCircleIcon, + ExclamationTriangleIcon, +} from "@heroicons/react/20/solid"; +import { + Form, + useActionData, + useFetcher, + useNavigation, + useLocation, +} from "@remix-run/react"; +import { + type ActionFunctionArgs, + type LoaderFunctionArgs, + json, +} from "@remix-run/server-runtime"; +import { typedjson, useTypedFetcher } from "remix-typedjson"; +import { z } from "zod"; +import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; +import { DialogClose } from "@radix-ui/react-dialog"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { Callout } from "~/components/primitives/Callout"; +import { Fieldset } from "~/components/primitives/Fieldset"; +import { FormButtons } from "~/components/primitives/FormButtons"; +import { FormError } from "~/components/primitives/FormError"; +import { Hint } from "~/components/primitives/Hint"; +import { InputGroup } from "~/components/primitives/InputGroup"; +import { Label } from "~/components/primitives/Label"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { Select, SelectItem } from "~/components/primitives/Select"; +import { SpinnerWhite } from "~/components/primitives/Spinner"; +import { DateTime } from "~/components/primitives/DateTime"; +import { VercelLogo } from "~/components/integrations/VercelLogo"; +import { BuildSettingsFields } from "~/components/integrations/VercelBuildSettings"; +import { + redirectBackWithErrorMessage, + redirectWithSuccessMessage, + redirectWithErrorMessage, +} from "~/models/message.server"; +import { findProjectBySlug } from "~/models/project.server"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; +import { logger } from "~/services/logger.server"; +import { requireUserId } from "~/services/session.server"; +import { EnvironmentParamSchema, v3ProjectSettingsPath, vercelAppInstallPath } from "~/utils/pathBuilder"; +import { + VercelSettingsPresenter, + type VercelOnboardingData, +} from "~/presenters/v3/VercelSettingsPresenter.server"; +import { VercelIntegrationService } from "~/services/vercelIntegration.server"; +import { VercelIntegrationRepository } from "~/models/vercelIntegration.server"; +import { + type VercelProjectIntegrationData, + type SyncEnvVarsMapping, + type EnvSlug, + jsonArrayField, + envTypeToSlug, + getAvailableEnvSlugs, + getAvailableEnvSlugsForBuildSettings, +} from "~/v3/vercel/vercelProjectIntegrationSchema"; +import { fromPromise } from "neverthrow"; +import { useEffect, useState } from "react"; + +export type ConnectedVercelProject = { + id: string; + vercelProjectId: string; + vercelProjectName: string; + vercelTeamId: string | null; + integrationData: VercelProjectIntegrationData; + createdAt: Date; +}; + +function parseVercelStagingEnvironment( + value: string | null | undefined +): { environmentId: string; displayName: string } | null { + if (!value) return null; + try { + const parsed = JSON.parse(value) as { environmentId?: string; displayName?: string }; + if (parsed?.environmentId && parsed?.displayName) { + return { environmentId: parsed.environmentId, displayName: parsed.displayName }; + } + return null; + } catch { + return null; + } +} + +const UpdateVercelConfigFormSchema = z.object({ + action: z.literal("update-config"), + atomicBuilds: jsonArrayField, + pullEnvVarsBeforeBuild: jsonArrayField, + discoverEnvVars: jsonArrayField, + vercelStagingEnvironment: z.string().nullable().optional(), +}); + +const DisconnectVercelFormSchema = z.object({ + action: z.literal("disconnect"), +}); + +const CompleteOnboardingFormSchema = z.object({ + action: z.literal("complete-onboarding"), + vercelStagingEnvironment: z.string().nullable().optional(), + pullEnvVarsBeforeBuild: jsonArrayField, + atomicBuilds: jsonArrayField, + discoverEnvVars: jsonArrayField, + syncEnvVarsMapping: z.string().optional(), + next: z.string().optional(), + skipRedirect: z.string().optional().transform((val) => val === "true"), +}); + +const SkipOnboardingFormSchema = z.object({ + action: z.literal("skip-onboarding"), +}); + +const SelectVercelProjectFormSchema = z.object({ + action: z.literal("select-vercel-project"), + vercelProjectId: z.string().min(1, "Please select a Vercel project"), + vercelProjectName: z.string().min(1), +}); + +const UpdateEnvMappingFormSchema = z.object({ + action: z.literal("update-env-mapping"), + vercelStagingEnvironment: z.string().nullable().optional(), +}); + +const DisableAutoAssignFormSchema = z.object({ + action: z.literal("disable-auto-assign"), +}); + +const VercelActionSchema = z.discriminatedUnion("action", [ + UpdateVercelConfigFormSchema, + DisconnectVercelFormSchema, + CompleteOnboardingFormSchema, + SkipOnboardingFormSchema, + SelectVercelProjectFormSchema, + UpdateEnvMappingFormSchema, + DisableAutoAssignFormSchema, +]); + +export async function loader({ request, params }: LoaderFunctionArgs) { + try { + const userId = await requireUserId(request); + const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) { + throw new Response("Not Found", { status: 404 }); + } + + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) { + throw new Response("Not Found", { status: 404 }); + } + + const presenter = new VercelSettingsPresenter(); + const resultOrFail = await presenter.call({ + projectId: project.id, + organizationId: project.organizationId, + }); + + if (!resultOrFail?.isOk()) { + throw new Response("Failed to load Vercel settings", { status: 500 }); + } + + const result = resultOrFail.value; + const url = new URL(request.url); + const needsOnboarding = url.searchParams.get("vercelOnboarding") === "true"; + const vercelEnvironmentId = url.searchParams.get("vercelEnvironmentId") || undefined; + + let onboardingData: VercelOnboardingData | null = null; + if (needsOnboarding) { + onboardingData = await presenter.getOnboardingData( + project.id, + project.organizationId, + vercelEnvironmentId + ); + } + + const authInvalid = onboardingData?.authInvalid || result.authInvalid || false; + + return typedjson({ + ...result, + authInvalid: authInvalid || result.authInvalid, + onboardingData, + organizationSlug, + projectSlug: projectParam, + environmentSlug: envParam, + projectId: project.id, + organizationId: project.organizationId, + }); + } catch (error) { + if (error instanceof Response) { + throw error; + } + + logger.error("Unexpected error in Vercel settings loader", { + url: request.url, + params, + error, + }); + + throw new Response("Internal Server Error", { status: 500 }); + } +} + +export async function action({ request, params }: ActionFunctionArgs) { + const userId = await requireUserId(request); + const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) { + throw new Response("Not Found", { status: 404 }); + } + + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) { + throw new Response("Not Found", { status: 404 }); + } + + const formData = await request.formData(); + const submission = parse(formData, { schema: VercelActionSchema }); + + if (!submission.value || submission.intent !== "submit") { + return json(submission); + } + + const settingsPath = v3ProjectSettingsPath( + { slug: organizationSlug }, + { slug: projectParam }, + { slug: envParam } + ); + + const vercelService = new VercelIntegrationService(); + const { action: actionType } = submission.value; + + switch (actionType) { + case "update-config": { + const { + atomicBuilds, + pullEnvVarsBeforeBuild, + discoverEnvVars, + vercelStagingEnvironment, + } = submission.value; + + const parsedStagingEnv = parseVercelStagingEnvironment(vercelStagingEnvironment); + + const result = await vercelService.updateVercelIntegrationConfig(project.id, { + atomicBuilds: atomicBuilds as EnvSlug[] | null, + pullEnvVarsBeforeBuild: pullEnvVarsBeforeBuild as EnvSlug[] | null, + discoverEnvVars: discoverEnvVars as EnvSlug[] | null, + vercelStagingEnvironment: parsedStagingEnv, + }); + + if (result) { + return redirectWithSuccessMessage(settingsPath, request, "Vercel settings updated successfully"); + } + + return redirectWithErrorMessage(settingsPath, request, "Failed to update Vercel settings"); + } + + case "disconnect": { + const success = await vercelService.disconnectVercelProject(project.id); + + if (success) { + return redirectWithSuccessMessage(settingsPath, request, "Vercel project disconnected"); + } + + return redirectWithErrorMessage(settingsPath, request, "Failed to disconnect Vercel project"); + } + + case "complete-onboarding": { + const { + vercelStagingEnvironment, + pullEnvVarsBeforeBuild, + atomicBuilds, + discoverEnvVars, + syncEnvVarsMapping, + next, + skipRedirect, + } = submission.value; + + let parsedMapping: SyncEnvVarsMapping = {}; + if (syncEnvVarsMapping) { + try { + parsedMapping = JSON.parse(syncEnvVarsMapping) as SyncEnvVarsMapping; + } catch (e) { + logger.error("Failed to parse syncEnvVarsMapping", { error: e }); + } + } + + const parsedStagingEnv = parseVercelStagingEnvironment(vercelStagingEnvironment); + + const result = await vercelService.completeOnboarding(project.id, { + vercelStagingEnvironment: parsedStagingEnv, + pullEnvVarsBeforeBuild: pullEnvVarsBeforeBuild as EnvSlug[] | null, + atomicBuilds: atomicBuilds as EnvSlug[] | null, + discoverEnvVars: discoverEnvVars as EnvSlug[] | null, + syncEnvVarsMapping: parsedMapping, + }); + + if (result) { + if (skipRedirect) { + return json({ success: true }); + } + + if (next) { + try { + const nextUrl = new URL(next); + // Only allow https URLs for security + if (nextUrl.protocol === "https:") { + return json({ success: true, redirectTo: next }); + } + } catch (e) { + logger.warn("Invalid next URL provided", { next, error: e }); + } + } + + return json({ success: true, redirectTo: settingsPath }); + } + + return redirectWithErrorMessage(settingsPath, request, "Failed to complete Vercel setup"); + } + + case "update-env-mapping": { + const { vercelStagingEnvironment } = submission.value; + + const parsedStagingEnv = parseVercelStagingEnvironment(vercelStagingEnvironment); + + const result = await vercelService.updateVercelIntegrationConfig(project.id, { + vercelStagingEnvironment: parsedStagingEnv, + }); + + if (result) { + return json({ success: true }); + } + + return json({ success: false, error: "Failed to update environment mapping" }, { status: 400 }); + } + + case "skip-onboarding": { + return redirectWithSuccessMessage(settingsPath, request, "Vercel integration setup skipped"); + } + + case "select-vercel-project": { + const { vercelProjectId, vercelProjectName } = submission.value; + + const selectResult = await fromPromise( + vercelService.selectVercelProject({ + organizationId: project.organizationId, + projectId: project.id, + vercelProjectId, + vercelProjectName, + userId, + }), + (error) => error + ); + + if (selectResult.isErr()) { + logger.error("Failed to select Vercel project", { error: selectResult.error }); + return json({ + error: "Failed to connect Vercel project. Please try again.", + }); + } + + const { integration, syncResult } = selectResult.value; + + if (!syncResult.success && syncResult.errors.length > 0) { + logger.warn("Failed to send trigger secrets to Vercel", { + projectId: project.id, + vercelProjectId, + errors: syncResult.errors, + }); + } + + return json({ + success: true, + integrationId: integration.id, + syncErrors: syncResult.errors, + }); + } + + case "disable-auto-assign": { + const disableResult = await fromPromise( + (async () => { + const orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationForProject( + project.id + ); + + if (!orgIntegration) { + return { success: false as const, errorMessage: "No Vercel integration found" }; + } + + const client = await VercelIntegrationRepository.getVercelClient(orgIntegration); + const projectIntegration = await vercelService.getVercelProjectIntegration(project.id); + + if (!projectIntegration) { + return { success: false as const, errorMessage: "No Vercel project connected" }; + } + + const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration); + const result = await VercelIntegrationRepository.disableAutoAssignCustomDomains( + client, + projectIntegration.parsedIntegrationData.vercelProjectId, + teamId + ); + + return { success: result.success, errorMessage: null }; + })(), + (error) => error + ); + + if (disableResult.isErr()) { + logger.error("Failed to disable auto-assign custom domains", { error: disableResult.error }); + return redirectWithErrorMessage(settingsPath, request, "Failed to disable auto-assign custom domains"); + } + + const { success: disableSuccess, errorMessage } = disableResult.value; + + if (disableSuccess) { + return redirectWithSuccessMessage(settingsPath, request, "Auto-assign custom domains disabled"); + } + + return redirectWithErrorMessage( + settingsPath, + request, + errorMessage ?? "Failed to disable auto-assign custom domains" + ); + } + + default: { + submission.value satisfies never; + return redirectBackWithErrorMessage(request, "Failed to process request"); + } + } +} + +export function vercelResourcePath( + organizationSlug: string, + projectSlug: string, + environmentSlug: string +) { + return `/resources/orgs/${organizationSlug}/projects/${projectSlug}/env/${environmentSlug}/vercel`; +} + + +function VercelConnectionPrompt({ + organizationSlug, + projectSlug, + environmentSlug, + hasOrgIntegration, + isGitHubConnected, + onOpenModal, + isLoading, +}: { + organizationSlug: string; + projectSlug: string; + environmentSlug: string; + hasOrgIntegration: boolean; + isGitHubConnected: boolean; + onOpenModal?: () => void; + isLoading?: boolean; +}) { + const installPath = vercelAppInstallPath(organizationSlug, projectSlug); + + const handleConnectProject = () => { + if (onOpenModal) { + onOpenModal(); + } + }; + + const isLoadingProjects = isLoading ?? false; + const isDisabled = isLoadingProjects || !onOpenModal; + + return ( +
+ +
+
+ {hasOrgIntegration ? ( + <> + + + Vercel app is installed + + {!onOpenModal && ( + + Please reconnect Vercel to continue + + )} + + ) : ( + <> + } + > + Install Vercel app + + + )} +
+
+
+
+ ); +} + +function VercelAuthInvalidBanner({ + organizationSlug, + projectSlug, +}: { + organizationSlug: string; + projectSlug: string; +}) { + const installUrl = vercelAppInstallPath(organizationSlug, projectSlug); + + return ( + +
+
+

+ Vercel connection expired +

+

+ Your Vercel access token has expired or been revoked. Please reconnect to restore functionality. +

+ + Reconnect Vercel + +
+
+
+ ); +} + +function VercelGitHubWarning() { + return ( + +

+ GitHub integration is not connected. Vercel integration cannot pull environment variables or + spawn Trigger.dev builds without a properly installed GitHub integration. +

+
+ ); +} + +function envSlugLabel(slug: EnvSlug): string { + switch (slug) { + case "prod": + return "Production"; + case "stg": + return "Staging"; + case "preview": + return "Preview"; + case "dev": + return "Development"; + } +} + +function ConnectedVercelProjectForm({ + connectedProject, + hasStagingEnvironment, + hasPreviewEnvironment, + customEnvironments, + autoAssignCustomDomains, + organizationSlug, + projectSlug, + environmentSlug, +}: { + connectedProject: ConnectedVercelProject; + hasStagingEnvironment: boolean; + hasPreviewEnvironment: boolean; + customEnvironments: Array<{ id: string; slug: string }>; + autoAssignCustomDomains: boolean | null; + organizationSlug: string; + projectSlug: string; + environmentSlug: string; +}) { + const lastSubmission = useActionData() as any; + const navigation = useNavigation(); + + const [hasConfigChanges, setHasConfigChanges] = useState(false); + const [configValues, setConfigValues] = useState({ + atomicBuilds: connectedProject.integrationData.config.atomicBuilds ?? [], + pullEnvVarsBeforeBuild: connectedProject.integrationData.config.pullEnvVarsBeforeBuild ?? [], + discoverEnvVars: connectedProject.integrationData.config.discoverEnvVars ?? [], + vercelStagingEnvironment: + connectedProject.integrationData.config.vercelStagingEnvironment ?? null, + }); + + const originalAtomicBuilds = connectedProject.integrationData.config.atomicBuilds ?? []; + const originalPullEnvVars = connectedProject.integrationData.config.pullEnvVarsBeforeBuild ?? []; + const originalDiscoverEnvVars = connectedProject.integrationData.config.discoverEnvVars ?? []; + const originalStagingEnv = connectedProject.integrationData.config.vercelStagingEnvironment ?? null; + + useEffect(() => { + const atomicBuildsChanged = + JSON.stringify([...configValues.atomicBuilds].sort()) !== + JSON.stringify([...originalAtomicBuilds].sort()); + const pullEnvVarsChanged = + JSON.stringify([...configValues.pullEnvVarsBeforeBuild].sort()) !== + JSON.stringify([...originalPullEnvVars].sort()); + const discoverEnvVarsChanged = + JSON.stringify([...configValues.discoverEnvVars].sort()) !== + JSON.stringify([...originalDiscoverEnvVars].sort()); + const stagingEnvChanged = configValues.vercelStagingEnvironment?.environmentId !== originalStagingEnv?.environmentId; + + setHasConfigChanges(atomicBuildsChanged || pullEnvVarsChanged || discoverEnvVarsChanged || stagingEnvChanged); + }, [configValues, originalAtomicBuilds, originalPullEnvVars, originalDiscoverEnvVars, originalStagingEnv]); + + const [configForm, fields] = useForm({ + id: "update-vercel-config", + lastSubmission: lastSubmission, + shouldRevalidate: "onSubmit", + onValidate({ formData }) { + return parse(formData, { + schema: UpdateVercelConfigFormSchema, + }); + }, + }); + + const isConfigLoading = + navigation.formData?.get("action") === "update-config" && + (navigation.state === "submitting" || navigation.state === "loading"); + + const actionUrl = vercelResourcePath(organizationSlug, projectSlug, environmentSlug); + + const availableEnvSlugs = getAvailableEnvSlugs(hasStagingEnvironment, hasPreviewEnvironment); + const availableEnvSlugsForBuildSettings = getAvailableEnvSlugsForBuildSettings(hasStagingEnvironment, hasPreviewEnvironment); + + const formatSelectedEnvs = (selected: EnvSlug[], availableSlugs: EnvSlug[] = availableEnvSlugs): string => { + if (selected.length === 0) return "None selected"; + if (selected.length === availableSlugs.length) return "All environments"; + return selected.map(envSlugLabel).join(", "); + }; + + return ( + <> +
+
+ + + {connectedProject.vercelProjectName} + + + + +
+ + + + + + Disconnect Vercel project +
+ + Are you sure you want to disconnect{" "} + {connectedProject.vercelProjectName}? + This will stop pulling environment variables and disable atomic deployments. + + + + + + } + cancelButton={ + + + + } + /> +
+
+
+
+ + {/* Configuration form */} +
+ + + + + +
+ +
+ {/* Staging environment mapping */} + {hasStagingEnvironment && customEnvironments && customEnvironments.length > 0 && ( +
+ + + Select which custom Vercel environment should map to Trigger.dev's Staging + environment. + + +
+ )} + + + setConfigValues((prev) => ({ ...prev, pullEnvVarsBeforeBuild: slugs })) + } + discoverEnvVars={configValues.discoverEnvVars} + onDiscoverEnvVarsChange={(slugs) => + setConfigValues((prev) => ({ ...prev, discoverEnvVars: slugs })) + } + atomicBuilds={configValues.atomicBuilds} + onAtomicBuildsChange={(slugs) => + setConfigValues((prev) => ({ ...prev, atomicBuilds: slugs })) + } + envVarsConfigLink={`/orgs/${organizationSlug}/projects/${projectSlug}/env/${environmentSlug}/environment-variables`} + /> + + {/* Warning: autoAssignCustomDomains must be disabled for atomic deployments */} + {autoAssignCustomDomains !== false && + configValues.atomicBuilds.includes("prod") && ( + +
+

+ Atomic deployments require the "Auto-assign Custom Domains" setting to be + disabled on your Vercel project. Without this, Vercel will promote + deployments before Trigger.dev is ready. +

+ + + + +
+
+ )} +
+ + {configForm.error} +
+ + + Save + + } + /> +
+ + + ); +} + +function VercelSettingsPanel({ + organizationSlug, + projectSlug, + environmentSlug, + onOpenVercelModal, + isLoadingVercelData, +}: { + organizationSlug: string; + projectSlug: string; + environmentSlug: string; + onOpenVercelModal?: () => void; + isLoadingVercelData?: boolean; +}) { + const fetcher = useTypedFetcher(); + const location = useLocation(); + const data = fetcher.data; + const [hasError, setHasError] = useState(false); + const [hasFetched, setHasFetched] = useState(false); + + useEffect(() => { + if (!data?.authInvalid && !hasError && !data && !hasFetched) { + fetcher.load(vercelResourcePath(organizationSlug, projectSlug, environmentSlug)); + setHasFetched(true); + } + }, [organizationSlug, projectSlug, environmentSlug, data?.authInvalid, hasError, data, hasFetched]); + + useEffect(() => { + if (hasFetched && fetcher.state === "idle" && fetcher.data === undefined && !hasError) { + setHasError(true); + } + }, [fetcher.state, fetcher.data, hasError, hasFetched]); + + if (hasError) { + return ( +
+
+ +
+

Failed to load Vercel settings

+

+ There was an error loading the Vercel integration settings. Please refresh the page to try again. +

+
+
+
+ ); + } + + if (fetcher.state === "loading" && !data) { + return ( +
+ + Loading Vercel settings... +
+ ); + } + + if (!data || !data.enabled) { + return null; + } + + const showGitHubWarning = data.connectedProject && !data.isGitHubConnected; + const showAuthInvalid = data.authInvalid || data.onboardingData?.authInvalid; + + if (data.connectedProject) { + return ( + <> + {showAuthInvalid && } + {showGitHubWarning && } + {!showAuthInvalid && ()} + + ); + } + + return ( +
+ {showAuthInvalid && } + {!showAuthInvalid && ( + <> + + + {data.hasOrgIntegration + ? "Connect your Vercel project to pull environment variables and trigger builds automatically." + : "Install the Vercel app to connect your projects and pull environment variables."} + + {!data.isGitHubConnected && ( + + GitHub integration is not connected. Vercel integration cannot pull environment variables or + spawn Trigger.dev builds without a properly installed GitHub integration. + + )} + + )} +
+ ); +} + + +import { VercelOnboardingModal } from "~/components/integrations/VercelOnboardingModal"; + +export { VercelSettingsPanel, VercelOnboardingModal }; diff --git a/apps/webapp/app/routes/vercel.callback.ts b/apps/webapp/app/routes/vercel.callback.ts new file mode 100644 index 0000000000..e42951c5e8 --- /dev/null +++ b/apps/webapp/app/routes/vercel.callback.ts @@ -0,0 +1,74 @@ +import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { redirect } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { logger } from "~/services/logger.server"; +import { getUserId } from "~/services/session.server"; +import { setReferralSourceCookie } from "~/services/referralSource.server"; +import { requestUrl } from "~/utils/requestUrl.server"; + +const VercelCallbackSchema = z + .object({ + code: z.string().optional(), + state: z.string().optional(), + error: z.string().optional(), + error_description: z.string().optional(), + configurationId: z.string().optional(), + next: z.string().optional() + }) + .passthrough(); + +export async function loader({ request }: LoaderFunctionArgs) { + if (request.method.toUpperCase() !== "GET") { + throw new Response("Method Not Allowed", { status: 405 }); + } + + const userId = await getUserId(request); + if (!userId) { + const currentUrl = new URL(request.url); + const redirectTo = `${currentUrl.pathname}${currentUrl.search}`; + const referralCookie = await setReferralSourceCookie("vercel"); + + const headers = new Headers(); + headers.append("Set-Cookie", referralCookie); + + throw redirect(`/login?redirectTo=${encodeURIComponent(redirectTo)}`, { headers }); + } + + const url = requestUrl(request); + const parsed = VercelCallbackSchema.safeParse(Object.fromEntries(url.searchParams)); + + if (!parsed.success) { + logger.error("Invalid Vercel callback params", { error: parsed.error }); + throw new Response("Invalid callback parameters", { status: 400 }); + } + + const { code, state, error, error_description, configurationId, next: nextUrl } = parsed.data; + + if (error) { + logger.error("Vercel OAuth error", { error, error_description }); + throw new Response("Vercel OAuth error", { status: 500 }); + } + + if (!code) { + logger.error("Missing authorization code from Vercel callback"); + throw new Response("Missing authorization code", { status: 400 }); + } + + // Route with state: dashboard-invoked flow + if (state) { + const params = new URLSearchParams({ state, code, origin: "dashboard" }); + if (configurationId) params.set("configurationId", configurationId); + if (nextUrl) params.set("next", nextUrl); + return redirect(`/vercel/connect?${params.toString()}`); + } + + // Route without state but with configurationId: marketplace-invoked flow + if (configurationId) { + const params = new URLSearchParams({ code, configurationId, origin: "marketplace" }); + if (nextUrl) params.set("next", nextUrl); + return redirect(`/vercel/onboarding?${params.toString()}`); + } + + logger.error("Missing both state and configurationId from Vercel callback"); + throw new Response("Missing state or configurationId parameter", { status: 400 }); +} diff --git a/apps/webapp/app/routes/vercel.configure.tsx b/apps/webapp/app/routes/vercel.configure.tsx new file mode 100644 index 0000000000..f79d333509 --- /dev/null +++ b/apps/webapp/app/routes/vercel.configure.tsx @@ -0,0 +1,50 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { redirect } from "@remix-run/node"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { organizationVercelIntegrationPath } from "~/utils/pathBuilder"; + +const SearchParamsSchema = z.object({ + configurationId: z.string(), +}); + +/** + * Endpoint to handle Vercel integration configuration request coming from marketplace + */ +export const loader = async ({ request }: LoaderFunctionArgs) => { + const url = new URL(request.url); + const searchParams = Object.fromEntries(url.searchParams); + + const { configurationId } = SearchParamsSchema.parse(searchParams); + + // Find the organization integration by configurationId (installationId in integrationData) + const integration = await prisma.organizationIntegration.findFirst({ + where: { + service: "VERCEL", + deletedAt: null, + integrationData: { + path: ["installationId"], + equals: configurationId, + }, + }, + include: { + organization: { + select: { + slug: true, + }, + }, + }, + }); + + if (!integration) { + throw new Response("Integration not found", { status: 404 }); + } + + // Redirect to the organization's Vercel integration page + return redirect(organizationVercelIntegrationPath(integration.organization)); +}; + +// This route doesn't render anything, it just redirects +export default function VercelConfigurePage() { + return null; +} \ No newline at end of file diff --git a/apps/webapp/app/routes/vercel.connect.tsx b/apps/webapp/app/routes/vercel.connect.tsx new file mode 100644 index 0000000000..3fdf9653f2 --- /dev/null +++ b/apps/webapp/app/routes/vercel.connect.tsx @@ -0,0 +1,169 @@ +import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { redirect } from "@remix-run/server-runtime"; +import { fromPromise } from "neverthrow"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { VercelIntegrationRepository, type TokenResponse } from "~/models/vercelIntegration.server"; +import { logger } from "~/services/logger.server"; +import { requireUserId } from "~/services/session.server"; +import { requestUrl } from "~/utils/requestUrl.server"; +import { v3ProjectSettingsPath } from "~/utils/pathBuilder"; +import { validateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server"; + +const VercelConnectSchema = z.object({ + state: z.string(), + configurationId: z.string(), + code: z.string(), + next: z.string().optional(), + origin: z.enum(["marketplace", "dashboard"]), +}); + +async function createOrFindVercelIntegration( + organizationId: string, + projectId: string, + tokenResponse: TokenResponse, + configurationId: string, + origin: 'marketplace' | 'dashboard' +): Promise { + const project = await prisma.project.findUnique({ + where: { id: projectId }, + include: { organization: true }, + }); + + if (!project) { + throw new Error("Project not found"); + } + + let orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationByTeamId( + organizationId, + tokenResponse.teamId ?? null + ); + + if (orgIntegration) { + await VercelIntegrationRepository.updateVercelOrgIntegrationToken({ + integrationId: orgIntegration.id, + accessToken: tokenResponse.accessToken, + tokenType: tokenResponse.tokenType, + teamId: tokenResponse.teamId ?? null, + userId: tokenResponse.userId, + installationId: configurationId, + raw: tokenResponse.raw + }); + } else { + await VercelIntegrationRepository.createVercelOrgIntegration({ + accessToken: tokenResponse.accessToken, + tokenType: tokenResponse.tokenType, + teamId: tokenResponse.teamId ?? null, + userId: tokenResponse.userId, + installationId: configurationId, + organization: project.organization, + raw: tokenResponse.raw, + origin, + }); + } +} + +export async function loader({ request }: LoaderFunctionArgs) { + const userId = await requireUserId(request); + const url = requestUrl(request); + + const parsed = VercelConnectSchema.safeParse(Object.fromEntries(url.searchParams)); + if (!parsed.success) { + logger.error("Invalid Vercel connect params", { error: parsed.error }); + throw new Response("Invalid parameters", { status: 400 }); + } + + const { state, configurationId, code, next, origin } = parsed.data; + + const validationResult = await validateVercelOAuthState(state); + if (!validationResult.ok) { + logger.error("Invalid Vercel OAuth state JWT", { error: validationResult.error }); + + if ( + validationResult.error?.includes("expired") || + validationResult.error?.includes("Token has expired") + ) { + const params = new URLSearchParams({ error: "expired" }); + return redirect(`/vercel/onboarding?${params.toString()}`); + } + + throw new Response("Invalid state", { status: 400 }); + } + + const stateData = validationResult.state; + + const project = await prisma.project.findFirst({ + where: { + id: stateData.projectId, + organizationId: stateData.organizationId, + deletedAt: null, + organization: { + members: { + some: { userId }, + }, + }, + }, + include: { + organization: true, + }, + }); + + if (!project) { + logger.error("Project not found or access denied", { + projectId: stateData.projectId, + userId, + }); + throw new Response("Project not found", { status: 404 }); + } + + const tokenResponse = await VercelIntegrationRepository.exchangeCodeForToken(code); + if (!tokenResponse) { + const params = new URLSearchParams({ error: "expired" }); + return redirect(`/vercel/onboarding?${params.toString()}`); + } + + const environment = await prisma.runtimeEnvironment.findFirst({ + where: { + projectId: project.id, + slug: stateData.environmentSlug, + archivedAt: null, + }, + }); + + if (!environment) { + logger.error("Environment not found", { + projectId: project.id, + environmentSlug: stateData.environmentSlug, + }); + throw new Response("Environment not found", { status: 404 }); + } + + const settingsPath = v3ProjectSettingsPath( + { slug: stateData.organizationSlug }, + { slug: stateData.projectSlug }, + { slug: environment.slug } + ); + + const result = await fromPromise( + createOrFindVercelIntegration(stateData.organizationId, stateData.projectId, tokenResponse, configurationId, origin), + (error) => error + ); + + if (result.isErr()) { + logger.error("Failed to complete Vercel integration", { error: result.error }); + throw redirect(settingsPath); + } + + logger.info("Vercel organization integration created successfully", { + organizationId: stateData.organizationId, + projectId: stateData.projectId, + teamId: tokenResponse.teamId, + }); + + const params = new URLSearchParams({ vercelOnboarding: "true", origin }); + if (next) { + params.set("next", next); + } + + return redirect(`${settingsPath}?${params.toString()}`); +} diff --git a/apps/webapp/app/routes/vercel.install.tsx b/apps/webapp/app/routes/vercel.install.tsx new file mode 100644 index 0000000000..6a1ca4d7a6 --- /dev/null +++ b/apps/webapp/app/routes/vercel.install.tsx @@ -0,0 +1,73 @@ +import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { redirect } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { $replica } from "~/db.server"; +import { requireUser } from "~/services/session.server"; +import { logger } from "~/services/logger.server"; +import { OrgIntegrationRepository } from "~/models/orgIntegration.server"; +import { generateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server"; +import { findProjectBySlug } from "~/models/project.server"; + +const QuerySchema = z.object({ + org_slug: z.string(), + project_slug: z.string(), +}); + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const searchParams = new URL(request.url).searchParams; + const parsed = QuerySchema.safeParse(Object.fromEntries(searchParams)); + + if (!parsed.success) { + logger.warn("Vercel App installation redirect with invalid params", { + searchParams, + error: parsed.error, + }); + throw redirect("/"); + } + + const { org_slug, project_slug } = parsed.data; + const user = await requireUser(request); + + // Find the organization + const org = await $replica.organization.findFirst({ + where: { slug: org_slug, members: { some: { userId: user.id } }, deletedAt: null }, + orderBy: { createdAt: "desc" }, + select: { + id: true, + }, + }); + + if (!org) { + throw redirect("/"); + } + + // Find the project + const project = await findProjectBySlug(org_slug, project_slug, user.id); + if (!project) { + logger.warn("Vercel App installation attempt for non-existent project", { + org_slug, + project_slug, + userId: user.id, + }); + throw redirect("/"); + } + + // Use "prod" as the default environment slug for the redirect + // The callback will redirect to the settings page for this environment + const environmentSlug = "prod"; + + // Generate JWT state token + const stateToken = await generateVercelOAuthState({ + organizationId: org.id, + projectId: project.id, + environmentSlug, + organizationSlug: org_slug, + projectSlug: project_slug, + }); + + // Generate Vercel install URL + const vercelInstallUrl = OrgIntegrationRepository.vercelInstallUrl(stateToken); + + return redirect(vercelInstallUrl); +}; + diff --git a/apps/webapp/app/routes/vercel.onboarding.tsx b/apps/webapp/app/routes/vercel.onboarding.tsx new file mode 100644 index 0000000000..d9406f25b1 --- /dev/null +++ b/apps/webapp/app/routes/vercel.onboarding.tsx @@ -0,0 +1,454 @@ +import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { json, redirect } from "@remix-run/server-runtime"; +import { fromPromise } from "neverthrow"; +import { useEffect, useState } from "react"; +import { Form, useNavigation } from "@remix-run/react"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { z } from "zod"; +import { BuildingOfficeIcon, FolderIcon } from "@heroicons/react/20/solid"; +import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout"; +import { BackgroundWrapper } from "~/components/BackgroundWrapper"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { Fieldset } from "~/components/primitives/Fieldset"; +import { FormButtons } from "~/components/primitives/FormButtons"; +import { FormTitle } from "~/components/primitives/FormTitle"; +import { InputGroup } from "~/components/primitives/InputGroup"; +import { Label } from "~/components/primitives/Label"; +import { Select, SelectItem } from "~/components/primitives/Select"; +import { ButtonSpinner } from "~/components/primitives/Spinner"; +import { prisma } from "~/db.server"; +import { logger } from "~/services/logger.server"; +import { requireUserId } from "~/services/session.server"; +import { confirmBasicDetailsPath, newProjectPath } from "~/utils/pathBuilder"; +import { redirectWithErrorMessage } from "~/models/message.server"; +import { generateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server"; + +const LoaderParamsSchema = z.object({ + organizationId: z.string().optional().nullable(), + code: z.string(), + configurationId: z.string().optional().nullable(), + next: z.string().optional().nullable(), + error: z.string().optional().nullable(), +}); + +const SelectOrgActionSchema = z.object({ + action: z.literal("select-org"), + organizationId: z.string(), + code: z.string(), + configurationId: z.string().optional().nullable(), + next: z.string().optional(), +}); + +const SelectProjectActionSchema = z.object({ + action: z.literal("select-project"), + projectId: z.string(), + organizationId: z.string(), + code: z.string(), + configurationId: z.string().optional().nullable(), + next: z.string().optional().nullable(), +}); + +const ActionSchema = z.discriminatedUnion("action", [ + SelectOrgActionSchema, + SelectProjectActionSchema, +]); + +export async function loader({ request }: LoaderFunctionArgs) { + const userId = await requireUserId(request); + const url = new URL(request.url); + + const params = LoaderParamsSchema.safeParse({ + organizationId: url.searchParams.get("organizationId"), + code: url.searchParams.get("code"), + configurationId: url.searchParams.get("configurationId"), + next: url.searchParams.get("next"), + error: url.searchParams.get("error"), + }); + + if (!params.success) { + logger.error("Invalid params for Vercel onboarding", { error: params.error }); + throw redirectWithErrorMessage( + "/", + request, + "Invalid installation parameters. Please try again from Vercel." + ); + } + + const { error } = params.data; + if (error === "expired") { + return typedjson({ + step: "error" as const, + error: "Your installation session has expired. Please start the installation again.", + code: params.data.code, + configurationId: params.data.configurationId ?? null, + next: params.data.next ?? null, + }); + } + + const organizations = await prisma.organization.findMany({ + where: { + members: { + some: { userId }, + }, + deletedAt: null, + }, + select: { + id: true, + title: true, + slug: true, + projects: { + where: { + deletedAt: null, + }, + select: { + id: true, + name: true, + slug: true, + }, + orderBy: { + createdAt: "asc", + }, + }, + }, + orderBy: { + createdAt: "asc", + }, + }); + + // New user: no organizations + if (organizations.length === 0) { + const onboardingParams = new URLSearchParams(); + onboardingParams.set("code", params.data.code); + if (params.data.configurationId) { + onboardingParams.set("configurationId", params.data.configurationId); + } + onboardingParams.set("integration", "vercel"); + if (params.data.next) { + onboardingParams.set("next", params.data.next); + } + throw redirect(`${confirmBasicDetailsPath()}?${onboardingParams.toString()}`); + } + + // If organizationId is provided, show project selection + if (params.data.organizationId) { + const organization = organizations.find((org) => org.id === params.data.organizationId); + + if (!organization) { + logger.error("Organization not found or access denied", { + organizationId: params.data.organizationId, + userId, + }); + throw redirectWithErrorMessage( + "/", + request, + "Organization not found. Please try again." + ); + } + + return typedjson({ + step: "project" as const, + organization, + organizations, + code: params.data.code, + configurationId: params.data.configurationId ?? null, + next: params.data.next ?? null, + }); + } + + return typedjson({ + step: "org" as const, + organizations, + code: params.data.code, + configurationId: params.data.configurationId ?? null, + next: params.data.next ?? null, + }); +} + +export async function action({ request }: ActionFunctionArgs) { + const userId = await requireUserId(request); + const formData = await request.formData(); + + const submission = ActionSchema.safeParse({ + action: formData.get("action"), + organizationId: formData.get("organizationId"), + projectId: formData.get("projectId"), + code: formData.get("code"), + configurationId: formData.get("configurationId"), + next: formData.get("next"), + }); + + if (!submission.success) { + return json({ error: "Invalid submission" }, { status: 400 }); + } + + const { code, configurationId, next } = submission.data; + + // Handle org selection + if (submission.data.action === "select-org") { + const { organizationId } = submission.data; + + const projectParams = new URLSearchParams(); + projectParams.set("organizationId", organizationId); + projectParams.set("code", code); + if (configurationId) { + projectParams.set("configurationId", configurationId); + } + if (next) { + projectParams.set("next", next); + } + + return redirect(`/vercel/onboarding?${projectParams.toString()}`); + } + + // Handle project selection + const { projectId, organizationId } = submission.data; + + const project = await prisma.project.findFirst({ + where: { + id: projectId, + organizationId, + deletedAt: null, + organization: { + members: { some: { userId } }, + }, + }, + include: { + organization: true, + }, + }); + + if (!project) { + logger.error("Project not found or access denied", { projectId, userId }); + return json({ error: "Project not found" }, { status: 404 }); + } + + const environment = await prisma.runtimeEnvironment.findFirst({ + where: { + projectId: project.id, + slug: "prod", + archivedAt: null, + }, + }); + + if (!environment) { + logger.error("Environment not found", { projectId: project.id }); + return json({ error: "Environment not found" }, { status: 404 }); + } + + const stateResult = await fromPromise( + generateVercelOAuthState({ + organizationId: project.organizationId, + projectId: project.id, + environmentSlug: environment.slug, + organizationSlug: project.organization.slug, + projectSlug: project.slug, + }), + (error) => error + ); + + if (stateResult.isErr()) { + logger.error("Failed to generate Vercel OAuth state", { error: stateResult.error }); + return json({ error: "Failed to generate installation state" }, { status: 500 }); + } + + const params = new URLSearchParams(); + params.set("state", stateResult.value); + params.set("code", code); + if (configurationId) { + params.set("configurationId", configurationId); + } + params.set("origin", "marketplace"); + if (next) { + params.set("next", next); + } + + return redirect(`/vercel/connect?${params.toString()}`, 303); +} + +export default function VercelOnboardingPage() { + const data = useTypedLoaderData(); + const navigation = useNavigation(); + const isSubmitting = navigation.state === "submitting"; + const [isInstalling, setIsInstalling] = useState(false); + + // Reset isInstalling when navigation returns to idle (e.g. on error) + useEffect(() => { + if (navigation.state === "idle" && isInstalling) { + setIsInstalling(false); + } + }, [navigation.state, isInstalling]); + + if (data.step === "error") { + return ( + + + + + + + + + ); + } + + if (data.step === "org") { + const newOrgUrl = (() => { + const params = new URLSearchParams(); + params.set("code", data.code); + if (data.configurationId) { + params.set("configurationId", data.configurationId); + } + params.set("integration", "vercel"); + if (data.next) { + params.set("next", data.next); + } + return `/orgs/new?${params.toString()}`; + })(); + + return ( + + + + } + title="Select Organization" + description="Choose which organization to install the Vercel integration into." + /> +
+ + + {data.configurationId && ( + + )} + {data.next && } + +
+ + + + + + + + New Organization + + +
+ } + /> + + + + + + ); + } + + const newProjectUrl = (() => { + const params = new URLSearchParams(); + params.set("code", data.code); + if (data.configurationId) { + params.set("configurationId", data.configurationId); + } + params.set("integration", "vercel"); + params.set("organizationId", data.organization.id); + if (data.next) { + params.set("next", data.next); + } + return `${newProjectPath({ slug: data.organization.slug })}?${params.toString()}`; + })(); + + const isLoading = isSubmitting || isInstalling; + + return ( + + + + } + title="Select Project" + description={`Choose which project in "${data.organization.title}" to install the Vercel integration into.`} + /> +
setIsInstalling(true)}> + + + + {data.configurationId && ( + + )} + {data.next && } + +
+ + + + + + + + New Project + + +
+ } + /> + + + + + + ); +} diff --git a/apps/webapp/app/services/org.server.ts b/apps/webapp/app/services/org.server.ts new file mode 100644 index 0000000000..75c1467ab2 --- /dev/null +++ b/apps/webapp/app/services/org.server.ts @@ -0,0 +1,20 @@ +import { prisma } from "~/db.server"; +import { requireUserId } from "./session.server"; + +export async function requireOrganization(request: Request, organizationSlug: string) { + const userId = await requireUserId(request); + + const organization = await prisma.organization.findFirst({ + where: { + slug: organizationSlug, + members: { some: { userId } }, + deletedAt: null, + }, + }); + + if (!organization) { + throw new Response("Organization not found", { status: 404 }); + } + + return { organization, userId }; +} diff --git a/apps/webapp/app/services/postAuth.server.ts b/apps/webapp/app/services/postAuth.server.ts index 39e914129a..feb42ccaef 100644 --- a/apps/webapp/app/services/postAuth.server.ts +++ b/apps/webapp/app/services/postAuth.server.ts @@ -10,5 +10,8 @@ export async function postAuthentication({ loginMethod: User["authenticationMethod"]; isNewUser: boolean; }) { - telemetry.user.identify({ user, isNewUser }); + telemetry.user.identify({ + user, + isNewUser, + }); } diff --git a/apps/webapp/app/services/referralSource.server.ts b/apps/webapp/app/services/referralSource.server.ts new file mode 100644 index 0000000000..fbc4d6c76b --- /dev/null +++ b/apps/webapp/app/services/referralSource.server.ts @@ -0,0 +1,52 @@ +import { createCookie } from "@remix-run/node"; +import { prisma } from "~/db.server"; +import { env } from "~/env.server"; +import { telemetry } from "~/services/telemetry.server"; + +export type ReferralSource = "vercel"; + +// Cookie that persists for 1 hour to track referral source during login flow +export const referralSourceCookie = createCookie("referral-source", { + maxAge: 60 * 60, // 1 hour + httpOnly: true, + sameSite: "lax", + secure: env.NODE_ENV === "production", +}); + +export async function getReferralSource(request: Request): Promise { + const cookie = request.headers.get("Cookie"); + const value = await referralSourceCookie.parse(cookie); + if (value === "vercel") { + return value; + } + return null; +} + +export async function setReferralSourceCookie(source: ReferralSource): Promise { + return referralSourceCookie.serialize(source); +} + +export async function clearReferralSourceCookie(): Promise { + return referralSourceCookie.serialize("", { + maxAge: 0, + }); +} + +export async function trackAndClearReferralSource( + request: Request, + userId: string, + headers: Headers +): Promise { + const referralSource = await getReferralSource(request); + if (!referralSource) return; + + headers.append("Set-Cookie", await clearReferralSourceCookie()); + + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user) return; + + const userAge = Date.now() - user.createdAt.getTime(); + if (userAge >= 30 * 1000) return; + + telemetry.user.identify({ user, isNewUser: true, referralSource }); +} diff --git a/apps/webapp/app/services/telemetry.server.ts b/apps/webapp/app/services/telemetry.server.ts index 98ca11ed90..f8bd3d3d99 100644 --- a/apps/webapp/app/services/telemetry.server.ts +++ b/apps/webapp/app/services/telemetry.server.ts @@ -28,18 +28,32 @@ class Telemetry { } user = { - identify: ({ user, isNewUser }: { user: User; isNewUser: boolean }) => { + identify: ({ + user, + isNewUser, + referralSource, + }: { + user: User; + isNewUser: boolean; + referralSource?: string; + }) => { if (this.#posthogClient) { + const properties: Record = { + email: user.email, + name: user.name, + authenticationMethod: user.authenticationMethod, + admin: user.admin, + createdAt: user.createdAt, + isNewUser, + }; + + if (referralSource) { + properties.referralSource = referralSource; + } + this.#posthogClient.identify({ distinctId: user.id, - properties: { - email: user.email, - name: user.name, - authenticationMethod: user.authenticationMethod, - admin: user.admin, - createdAt: user.createdAt, - isNewUser, - }, + properties, }); } if (isNewUser) { diff --git a/apps/webapp/app/services/vercelIntegration.server.ts b/apps/webapp/app/services/vercelIntegration.server.ts new file mode 100644 index 0000000000..f089369536 --- /dev/null +++ b/apps/webapp/app/services/vercelIntegration.server.ts @@ -0,0 +1,600 @@ +import type { + PrismaClient, + OrganizationProjectIntegration, + OrganizationIntegration, + SecretReference, +} from "@trigger.dev/database"; +import { prisma } from "~/db.server"; +import { logger } from "~/services/logger.server"; +import { VercelIntegrationRepository } from "~/models/vercelIntegration.server"; +import { findCurrentWorkerDeployment } from "~/v3/models/workerDeployment.server"; +import { + VercelProjectIntegrationDataSchema, + VercelProjectIntegrationData, + VercelIntegrationConfig, + SyncEnvVarsMapping, + TriggerEnvironmentType, + EnvSlug, + envTypeToSlug, + createDefaultVercelIntegrationData, +} 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, + migrateIfNeeded: boolean = false + ): Promise { + 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 { + 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 createVercelProjectIntegration(params: { + organizationIntegrationId: string; + projectId: string; + vercelProjectId: string; + vercelProjectName: string; + vercelTeamId: string | null; + installedByUserId?: string; + }): Promise { + const integrationData = createDefaultVercelIntegrationData( + params.vercelProjectId, + params.vercelProjectName, + params.vercelTeamId + ); + + 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 existing = await this.getVercelProjectIntegration(params.projectId); + if (existing) { + const updated = await this.#prismaClient.organizationProjectIntegration.update({ + where: { id: existing.id }, + data: { + externalEntityId: params.vercelProjectId, + integrationData: { + ...existing.parsedIntegrationData, + vercelProjectId: params.vercelProjectId, + vercelProjectName: params.vercelProjectName, + vercelTeamId: teamId, + }, + }, + }); + + const syncResult = await VercelIntegrationRepository.syncApiKeysToVercel({ + projectId: params.projectId, + vercelProjectId: params.vercelProjectId, + teamId, + vercelStagingEnvironment: existing.parsedIntegrationData.config.vercelStagingEnvironment, + orgIntegration, + }); + + return { integration: updated, syncResult }; + } + + const integration = await this.createVercelProjectIntegration({ + organizationIntegrationId: orgIntegration.id, + projectId: params.projectId, + vercelProjectId: params.vercelProjectId, + vercelProjectName: params.vercelProjectName, + vercelTeamId: teamId, + installedByUserId: params.userId, + }); + + const syncResult = await VercelIntegrationRepository.syncApiKeysToVercel({ + projectId: params.projectId, + vercelProjectId: params.vercelProjectId, + teamId, + vercelStagingEnvironment: null, + orgIntegration, + }); + + try { + const client = await VercelIntegrationRepository.getVercelClient(orgIntegration); + await VercelIntegrationRepository.disableAutoAssignCustomDomains( + client, + params.vercelProjectId, + teamId + ); + } catch (error) { + logger.warn("Failed to disable autoAssignCustomDomains during project selection", { + projectId: params.projectId, + vercelProjectId: params.vercelProjectId, + error, + }); + } + + 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 + ): Promise { + const existing = await this.getVercelProjectIntegration(projectId); + if (!existing) { + return null; + } + + const updatedConfig = { + ...existing.parsedIntegrationData.config, + ...configUpdates, + }; + + 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 updateSyncEnvVarsMapping( + projectId: string, + syncEnvVarsMapping: SyncEnvVarsMapping + ): Promise { + 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 { + 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 { + 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; + } + ): Promise { + const existing = await this.getVercelProjectIntegration(projectId); + if (!existing) { + return null; + } + + const updatedData: VercelProjectIntegrationData = { + ...existing.parsedIntegrationData, + config: { + ...existing.parsedIntegrationData.config, + pullEnvVarsBeforeBuild: params.pullEnvVarsBeforeBuild ?? null, + atomicBuilds: params.atomicBuilds ?? null, + discoverEnvVars: params.discoverEnvVars ?? null, + vercelStagingEnvironment: params.vercelStagingEnvironment ?? null, + }, + syncEnvVarsMapping: params.syncEnvVarsMapping ?? existing.parsedIntegrationData.syncEnvVarsMapping, + }; + + const updated = await this.#prismaClient.organizationProjectIntegration.update({ + where: { id: existing.id }, + data: { + integrationData: updatedData, + }, + }); + + try { + const orgIntegration = await VercelIntegrationRepository.findVercelOrgIntegrationForProject( + projectId + ); + + if (!orgIntegration) { + return { ...updated, parsedIntegrationData: updatedData }; + } + + const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration); + + logger.info("Vercel onboarding: pulling env vars from Vercel", { + projectId, + vercelProjectId: updatedData.vercelProjectId, + teamId, + vercelStagingEnvironment: params.vercelStagingEnvironment, + syncEnvVarsMappingKeys: Object.keys(params.syncEnvVarsMapping), + }); + + const pullResult = await VercelIntegrationRepository.pullEnvVarsFromVercel({ + projectId, + vercelProjectId: updatedData.vercelProjectId, + teamId, + vercelStagingEnvironment: params.vercelStagingEnvironment, + syncEnvVarsMapping: params.syncEnvVarsMapping, + orgIntegration, + }); + + if (!pullResult.success) { + logger.warn("Some errors occurred while pulling env vars from Vercel", { + projectId, + vercelProjectId: updatedData.vercelProjectId, + errors: pullResult.errors, + syncedCount: pullResult.syncedCount, + }); + } else { + logger.info("Successfully pulled env vars from Vercel", { + projectId, + vercelProjectId: updatedData.vercelProjectId, + syncedCount: pullResult.syncedCount, + }); + } + + await this.#syncTriggerVersionToVercelProduction( + projectId, + updatedData.config.atomicBuilds, + orgIntegration + ); + } catch (error) { + logger.error("Failed to pull env vars from Vercel during onboarding", { + projectId, + vercelProjectId: updatedData.vercelProjectId, + error, + }); + } + + return { + ...updated, + parsedIntegrationData: updatedData, + }; + } + + async #syncTriggerVersionToVercelProduction( + projectId: string, + atomicBuilds: string[] | null | undefined, + orgIntegration: OrganizationIntegration & { tokenReference: SecretReference } + ): Promise { + try { + 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 client = await VercelIntegrationRepository.getVercelClient(orgIntegration); + 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.success) { + logger.warn("Failed to fetch Vercel env vars for TRIGGER_VERSION sync", { + projectId, + vercelProjectId, + error: envVarsResult.error, + }); + return; + } + + const existingTriggerVersion = envVarsResult.data.find( + (env) => env.key === "TRIGGER_VERSION" && env.target.includes("production") + ); + + if (existingTriggerVersion) { + return; + } + + // Push TRIGGER_VERSION to Vercel production + await client.projects.createProjectEnv({ + idOrName: vercelProjectId, + ...(teamId && { teamId }), + upsert: "true", + requestBody: { + key: "TRIGGER_VERSION", + value: currentDeployment.version, + target: ["production"] as any, + type: "encrypted", + }, + }); + + logger.info("Synced TRIGGER_VERSION to Vercel production", { + projectId, + vercelProjectId, + version: currentDeployment.version, + }); + } catch (error) { + logger.error("Failed to sync TRIGGER_VERSION to Vercel production", { + projectId, + error, + }); + } + } + + async disconnectVercelProject(projectId: string): Promise { + 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; + } +} + diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 639f2f7294..d82d945ae7 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -121,6 +121,14 @@ export function organizationSettingsPath(organization: OrgForPath) { return `${organizationPath(organization)}/settings`; } +export function organizationIntegrationsPath(organization: OrgForPath) { + return `${organizationPath(organization)}/settings/integrations`; +} + +export function organizationVercelIntegrationPath(organization: OrgForPath) { + return `${organizationIntegrationsPath(organization)}/vercel`; +} + function organizationParam(organization: OrgForPath) { return organization.slug; } @@ -151,6 +159,24 @@ export function githubAppInstallPath(organizationSlug: string, redirectTo: strin )}`; } +export function vercelAppInstallPath(organizationSlug: string, projectSlug: string) { + return `/vercel/install?org_slug=${organizationSlug}&project_slug=${projectSlug}`; +} + +export function vercelCallbackPath() { + return `/vercel/callback`; +} + +export function vercelResourcePath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath +) { + return `/resources/orgs/${organizationParam(organization)}/projects/${projectParam( + project + )}/env/${environmentParam(environment)}/vercel`; +} + export function v3EnvironmentPath( organization: OrgForPath, project: ProjectForPath, diff --git a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts index 0ade9436d4..40a25f212e 100644 --- a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts +++ b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts @@ -6,9 +6,12 @@ import { env } from "~/env.server"; import { getSecretStore } from "~/services/secrets/secretStore.server"; import { generateFriendlyId } from "../friendlyIdentifiers"; import { + type CreateEnvironmentVariables, type CreateResult, type DeleteEnvironmentVariable, type DeleteEnvironmentVariableValue, + type EditEnvironmentVariable, + type EditEnvironmentVariableValue, type EnvironmentVariable, type EnvironmentVariableWithSecret, type ProjectEnvironmentVariable, @@ -45,18 +48,7 @@ const SecretValue = z.object({ secret: z.string() }); export class EnvironmentVariablesRepository implements Repository { constructor(private prismaClient: PrismaClient = prisma) {} - async create( - projectId: string, - options: { - override: boolean; - environmentIds: string[]; - isSecret?: boolean; - variables: { - key: string; - value: string; - }[]; - } - ): Promise { + async create(projectId: string, options: CreateEnvironmentVariables): Promise { const project = await this.prismaClient.project.findFirst({ where: { id: projectId, @@ -164,10 +156,49 @@ export class EnvironmentVariablesRepository implements Repository { prismaClient: tx, }); + // If parentEnvironmentId is provided and isSecret is not explicitly set, + // look up if the parent has this variable marked as secret + let inheritedIsSecret: boolean | undefined = undefined; + if (options.isSecret === undefined && options.parentEnvironmentId) { + const parentVariableValue = await tx.environmentVariableValue.findFirst({ + where: { + variableId: environmentVariable.id, + environmentId: options.parentEnvironmentId, + }, + select: { + isSecret: true, + }, + }); + if (parentVariableValue?.isSecret) { + inheritedIsSecret = true; + } + } + + const effectiveIsSecret = options.isSecret ?? inheritedIsSecret; + //set the secret values and references for (const environmentId of options.environmentIds) { const key = secretKey(projectId, environmentId, variable.key); + const existingValueRecord = await tx.environmentVariableValue.findFirst({ + where: { + variableId: environmentVariable.id, + environmentId, + }, + }); + + // Check if value already exists and is the same, and no metadata change (e.g. isSecret toggle) + const existingSecret = await secretStore.getSecret(SecretValue, key); + const canSkip = + existingSecret && + existingSecret.secret === variable.value && + existingValueRecord && + (options.isSecret === undefined || + existingValueRecord.isSecret === options.isSecret); + if (canSkip) { + continue; + } + //create the secret reference const secretReference = await tx.secretReference.upsert({ where: { @@ -180,23 +211,36 @@ export class EnvironmentVariablesRepository implements Repository { update: {}, }); - const variableValue = await tx.environmentVariableValue.upsert({ - where: { - variableId_environmentId: { + if (existingValueRecord) { + await tx.environmentVariableValue.update({ + where: { + id: existingValueRecord.id, + }, + data: { + version: { + increment: 1, + }, + lastUpdatedBy: options.lastUpdatedBy ? options.lastUpdatedBy : undefined, + valueReferenceId: secretReference.id, + ...(options.isSecret !== undefined + ? { + isSecret: options.isSecret, + } + : {}), + }, + }); + } else { + await tx.environmentVariableValue.create({ + data: { variableId: environmentVariable.id, - environmentId, + environmentId: environmentId, + valueReferenceId: secretReference.id, + isSecret: effectiveIsSecret, + version: 1, + lastUpdatedBy: options.lastUpdatedBy ? options.lastUpdatedBy : Prisma.JsonNull, }, - }, - create: { - variableId: environmentVariable.id, - environmentId: environmentId, - valueReferenceId: secretReference.id, - isSecret: options.isSecret, - }, - update: { - isSecret: options.isSecret, - }, - }); + }); + } await secretStore.setSecret<{ secret: string }>(key, { secret: variable.value, @@ -226,14 +270,7 @@ export class EnvironmentVariablesRepository implements Repository { } } - async edit( - projectId: string, - options: { - values: { value: string; environmentId: string }[]; - id: string; - keepEmptyValues?: boolean; - } - ): Promise { + async edit(projectId: string, options: EditEnvironmentVariable): Promise { const project = await this.prismaClient.project.findFirst({ where: { id: projectId, @@ -323,6 +360,20 @@ export class EnvironmentVariablesRepository implements Repository { await secretStore.setSecret<{ secret: string }>(key, { secret: value.value, }); + await tx.environmentVariableValue.update({ + where: { + variableId_environmentId: { + variableId: environmentVariable.id, + environmentId: value.environmentId, + }, + }, + data: { + version: { + increment: 1, + }, + lastUpdatedBy: options.lastUpdatedBy ? options.lastUpdatedBy : undefined, + }, + }); } continue; } @@ -340,6 +391,8 @@ export class EnvironmentVariablesRepository implements Repository { variableId: environmentVariable.id, environmentId: value.environmentId, valueReferenceId: secretReference.id, + version: 1, + lastUpdatedBy: options.lastUpdatedBy ? options.lastUpdatedBy : Prisma.JsonNull, }, }); @@ -360,14 +413,7 @@ export class EnvironmentVariablesRepository implements Repository { } } - async editValue( - projectId: string, - options: { - id: string; - environmentId: string; - value: string; - } - ): Promise { + async editValue(projectId: string, options: EditEnvironmentVariableValue): Promise { const project = await this.prismaClient.project.findFirst({ where: { id: projectId, @@ -426,6 +472,21 @@ export class EnvironmentVariablesRepository implements Repository { await secretStore.setSecret<{ secret: string }>(key, { secret: options.value, }); + + await tx.environmentVariableValue.update({ + where: { + variableId_environmentId: { + variableId: environmentVariable.id, + environmentId: options.environmentId, + }, + }, + data: { + version: { + increment: 1, + }, + lastUpdatedBy: options.lastUpdatedBy ? options.lastUpdatedBy : undefined, + }, + }); }); return { diff --git a/apps/webapp/app/v3/environmentVariables/repository.ts b/apps/webapp/app/v3/environmentVariables/repository.ts index 521e22f7a2..ea027bc2ca 100644 --- a/apps/webapp/app/v3/environmentVariables/repository.ts +++ b/apps/webapp/app/v3/environmentVariables/repository.ts @@ -6,9 +6,25 @@ export const EnvironmentVariableKey = z .nonempty("Key is required") .regex(/^\w+$/, "Keys can only use alphanumeric characters and underscores"); +export const EnvironmentVariableUpdaterSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("user"), + userId: z.string(), + }), + z.object({ + type: z.literal("integration"), + integration: z.string(), + }), +]); +export type EnvironmentVariableUpdater = z.infer; + export const CreateEnvironmentVariables = z.object({ + override: z.boolean(), environmentIds: z.array(z.string()), + isSecret: z.boolean().optional(), + parentEnvironmentId: z.string().optional(), variables: z.array(z.object({ key: EnvironmentVariableKey, value: z.string() })), + lastUpdatedBy: EnvironmentVariableUpdaterSchema.optional(), }); export type CreateEnvironmentVariables = z.infer; @@ -32,6 +48,7 @@ export const EditEnvironmentVariable = z.object({ }) ), keepEmptyValues: z.boolean().optional(), + lastUpdatedBy: EnvironmentVariableUpdaterSchema.optional(), }); export type EditEnvironmentVariable = z.infer; @@ -51,6 +68,7 @@ export const EditEnvironmentVariableValue = z.object({ id: z.string(), environmentId: z.string(), value: z.string(), + lastUpdatedBy: EnvironmentVariableUpdaterSchema.optional(), }); export type EditEnvironmentVariableValue = z.infer; diff --git a/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts b/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts index a27d738094..debb176da5 100644 --- a/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts +++ b/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts @@ -22,6 +22,7 @@ import { environmentTitle } from "~/components/environments/EnvironmentLabel"; import { type Prisma, type prisma, type PrismaClientOrTransaction } from "~/db.server"; import { env } from "~/env.server"; import { + isIntegrationForService, type OrganizationIntegrationForService, OrgIntegrationRepository, } from "~/models/orgIntegration.server"; @@ -644,7 +645,7 @@ export class DeliverAlertService extends BaseService { }, }); - if (!integration) { + if (!integration || !isIntegrationForService(integration, "SLACK")) { logger.error("[DeliverAlert] Slack integration not found", { alert, }); diff --git a/apps/webapp/app/v3/services/initializeDeployment.server.ts b/apps/webapp/app/v3/services/initializeDeployment.server.ts index 52a968792c..96439d94d6 100644 --- a/apps/webapp/app/v3/services/initializeDeployment.server.ts +++ b/apps/webapp/app/v3/services/initializeDeployment.server.ts @@ -221,6 +221,7 @@ export class InitializeDeploymentService extends BaseService { imageReference: imageRef, imagePlatform: env.DEPLOY_IMAGE_PLATFORM, git: payload.gitMeta ?? undefined, + commitSHA: payload.gitMeta?.commitSha ?? undefined, runtime: payload.runtime ?? undefined, triggeredVia: payload.triggeredVia ?? undefined, startedAt: initialStatus === "BUILDING" ? new Date() : undefined, diff --git a/apps/webapp/app/v3/vercel/index.ts b/apps/webapp/app/v3/vercel/index.ts new file mode 100644 index 0000000000..f34f0b64c6 --- /dev/null +++ b/apps/webapp/app/v3/vercel/index.ts @@ -0,0 +1,17 @@ +export * from "./vercelProjectIntegrationSchema"; + +export function getVercelInstallParams(request: Request) { + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const configurationId = url.searchParams.get("configurationId"); + const integration = url.searchParams.get("integration"); + const next = url.searchParams.get("next"); + + if (code && configurationId && (integration === "vercel" || !integration)) { + return { code, configurationId, next }; + } + + return null; +} + + diff --git a/apps/webapp/app/v3/vercel/vercelOAuthState.server.ts b/apps/webapp/app/v3/vercel/vercelOAuthState.server.ts new file mode 100644 index 0000000000..31f42acc87 --- /dev/null +++ b/apps/webapp/app/v3/vercel/vercelOAuthState.server.ts @@ -0,0 +1,40 @@ +import { generateJWT, validateJWT } from "@trigger.dev/core/v3/jwt"; +import { z } from "zod"; +import { env } from "~/env.server"; + +export const VercelOAuthStateSchema = z.object({ + organizationId: z.string(), + projectId: z.string(), + environmentSlug: z.string(), + organizationSlug: z.string(), + projectSlug: z.string(), +}); + +export type VercelOAuthState = z.infer; + +export async function generateVercelOAuthState( + params: VercelOAuthState +): Promise { + return generateJWT({ + secretKey: env.ENCRYPTION_KEY, + payload: params, + expirationTime: "15m", + }); +} + +export async function validateVercelOAuthState( + token: string +): Promise<{ ok: true; state: VercelOAuthState } | { ok: false; error: string }> { + const result = await validateJWT(token, env.ENCRYPTION_KEY); + + if (!result.ok) { + return { ok: false, error: result.error }; + } + + const parseResult = VercelOAuthStateSchema.safeParse(result.payload); + if (!parseResult.success) { + return { ok: false, error: "Invalid state payload" }; + } + + return { ok: true, state: parseResult.data }; +} diff --git a/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts b/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts new file mode 100644 index 0000000000..c8ad68bbb7 --- /dev/null +++ b/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts @@ -0,0 +1,201 @@ +import { z } from "zod"; + +export const EnvSlugSchema = z.enum(["dev", "stg", "prod", "preview"]); +export type EnvSlug = z.infer; + +export const ALL_ENV_SLUGS: EnvSlug[] = ["dev", "stg", "prod", "preview"]; + +/** + * Zod transform for form fields that submit JSON-encoded arrays. + * Parses the string as JSON and returns the array, or null if invalid. + */ +export const jsonArrayField = z.string().optional().transform((val) => { + if (!val) return null; + try { + const parsed = JSON.parse(val); + return Array.isArray(parsed) ? parsed : null; + } catch { + return null; + } +}); + +export const VercelIntegrationConfigSchema = z.object({ + atomicBuilds: z.array(EnvSlugSchema).nullable().optional(), + pullEnvVarsBeforeBuild: z.array(EnvSlugSchema).nullable().optional(), + /** Maps a custom Vercel environment to Trigger.dev's staging environment. */ + vercelStagingEnvironment: z.object({ + environmentId: z.string(), + displayName: z.string(), + }).nullable().optional(), + discoverEnvVars: z.array(EnvSlugSchema).nullable().optional(), +}); + +export type VercelIntegrationConfig = z.infer; + +export const TriggerEnvironmentType = z.enum(["PRODUCTION", "STAGING", "PREVIEW", "DEVELOPMENT"]); +export type TriggerEnvironmentType = z.infer; + +/** + * Per-environment, per-variable sync settings. + * Missing env slug = sync all vars. Missing var in env = sync by default. + * Only explicitly `false` entries disable sync. + */ +export const SyncEnvVarsMappingSchema = z.record(EnvSlugSchema, z.record(z.string(), z.boolean())).default({}); + +export type SyncEnvVarsMapping = z.infer; + +export const VercelProjectIntegrationDataSchema = z.object({ + config: VercelIntegrationConfigSchema, + syncEnvVarsMapping: SyncEnvVarsMappingSchema, + vercelProjectName: z.string(), + vercelTeamId: z.string().nullable(), + vercelProjectId: z.string(), +}); + +export type VercelProjectIntegrationData = z.infer; + +export function createDefaultVercelIntegrationData( + vercelProjectId: string, + vercelProjectName: string, + vercelTeamId: string | null +): VercelProjectIntegrationData { + return { + config: { + atomicBuilds: ["prod"], + pullEnvVarsBeforeBuild: ["prod", "stg", "preview"], + discoverEnvVars: ["prod", "stg", "preview"], + vercelStagingEnvironment: null, + }, + syncEnvVarsMapping: {}, + vercelProjectId, + vercelProjectName, + vercelTeamId, + }; +} + +/** + * Maps a Trigger.dev environment type to its Vercel target identifier(s). + * Returns null for STAGING when no custom environment is configured. + */ +export function envTypeToVercelTarget( + envType: TriggerEnvironmentType, + stagingEnvironmentId?: string | null +): string[] | null { + switch (envType) { + case "PRODUCTION": + return ["production"]; + case "STAGING": + return stagingEnvironmentId ? [stagingEnvironmentId] : null; + case "PREVIEW": + return ["preview"]; + case "DEVELOPMENT": + return ["development"]; + } +} + +export function getAvailableEnvSlugs( + hasStagingEnvironment: boolean, + hasPreviewEnvironment: boolean +): EnvSlug[] { + return ALL_ENV_SLUGS.filter((s) => { + if (s === "stg" && !hasStagingEnvironment) return false; + if (s === "preview" && !hasPreviewEnvironment) return false; + return true; + }); +} + +export function getAvailableEnvSlugsForBuildSettings( + hasStagingEnvironment: boolean, + hasPreviewEnvironment: boolean +): EnvSlug[] { + return getAvailableEnvSlugs(hasStagingEnvironment, hasPreviewEnvironment).filter((s) => s !== "dev"); +} + +export function isDiscoverEnvVarsEnabledForEnvironment( + discoverEnvVars: EnvSlug[] | null | undefined, + environmentType: TriggerEnvironmentType +): boolean { + if (!discoverEnvVars || discoverEnvVars.length === 0) { + return false; + } + const envSlug = envTypeToSlug(environmentType); + return discoverEnvVars.includes(envSlug); +} + +export function envTypeToSlug(environmentType: TriggerEnvironmentType): EnvSlug { + switch (environmentType) { + case "DEVELOPMENT": + return "dev"; + case "STAGING": + return "stg"; + case "PRODUCTION": + return "prod"; + case "PREVIEW": + return "preview"; + } +} + +export function envSlugToType(slug: EnvSlug): TriggerEnvironmentType { + switch (slug) { + case "dev": + return "DEVELOPMENT"; + case "stg": + return "STAGING"; + case "prod": + return "PRODUCTION"; + case "preview": + return "PREVIEW"; + } +} + +export function shouldSyncEnvVar( + mapping: SyncEnvVarsMapping, + envVarName: string, + environmentType: TriggerEnvironmentType +): boolean { + const envSlug = envTypeToSlug(environmentType); + const envSettings = mapping[envSlug]; + if (!envSettings) { + return true; + } + return envSettings[envVarName] !== false; +} + +export function shouldSyncEnvVarForAnyEnvironment( + mapping: SyncEnvVarsMapping, + envVarName: string +): boolean { + for (const slug of ALL_ENV_SLUGS) { + const envSettings = mapping[slug]; + if (!envSettings) { + return true; + } + if (envSettings[envVarName] !== false) { + return true; + } + } + + return false; +} + +export function isPullEnvVarsEnabledForEnvironment( + pullEnvVarsBeforeBuild: EnvSlug[] | null | undefined, + environmentType: TriggerEnvironmentType +): boolean { + if (!pullEnvVarsBeforeBuild || pullEnvVarsBeforeBuild.length === 0) { + return false; + } + const envSlug = envTypeToSlug(environmentType); + return pullEnvVarsBeforeBuild.includes(envSlug); +} + +export function isAtomicBuildsEnabledForEnvironment( + atomicBuilds: EnvSlug[] | null | undefined, + environmentType: TriggerEnvironmentType +): boolean { + if (!atomicBuilds || atomicBuilds.length === 0) { + return false; + } + const envSlug = envTypeToSlug(environmentType); + return atomicBuilds.includes(envSlug); +} diff --git a/apps/webapp/package.json b/apps/webapp/package.json index 51a468b50c..8d3376ecee 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -129,6 +129,7 @@ "@unkey/cache": "^1.5.0", "@unkey/error": "^0.2.0", "@upstash/ratelimit": "^1.1.3", + "@vercel/sdk": "^1.18.5", "@whatwg-node/fetch": "^0.9.14", "ai": "^4.3.19", "assert-never": "^1.2.1", diff --git a/internal-packages/database/prisma/migrations/20260126175159_add_environment_variable_versioning/migration.sql b/internal-packages/database/prisma/migrations/20260126175159_add_environment_variable_versioning/migration.sql new file mode 100644 index 0000000000..17f013f388 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260126175159_add_environment_variable_versioning/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "public"."EnvironmentVariableValue" ADD COLUMN "lastUpdatedBy" JSONB, +ADD COLUMN "version" INTEGER NOT NULL DEFAULT 1; diff --git a/internal-packages/database/prisma/migrations/20260129162621_add_organization_project_integration/migration.sql b/internal-packages/database/prisma/migrations/20260129162621_add_organization_project_integration/migration.sql new file mode 100644 index 0000000000..2c18bd2e1d --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260129162621_add_organization_project_integration/migration.sql @@ -0,0 +1,29 @@ +-- CreateTable +CREATE TABLE "public"."OrganizationProjectIntegration" ( + "id" TEXT NOT NULL, + "organizationIntegrationId" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "externalEntityId" TEXT NOT NULL, + "integrationData" JSONB NOT NULL, + "installedBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "OrganizationProjectIntegration_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "OrganizationProjectIntegration_projectId_idx" ON "public"."OrganizationProjectIntegration"("projectId"); + +-- CreateIndex +CREATE INDEX "OrganizationProjectIntegration_projectId_organizationIntegr_idx" ON "public"."OrganizationProjectIntegration"("projectId", "organizationIntegrationId"); + +-- CreateIndex +CREATE INDEX "OrganizationProjectIntegration_externalEntityId_idx" ON "public"."OrganizationProjectIntegration"("externalEntityId"); + +-- AddForeignKey +ALTER TABLE "public"."OrganizationProjectIntegration" ADD CONSTRAINT "OrganizationProjectIntegration_organizationIntegrationId_fkey" FOREIGN KEY ("organizationIntegrationId") REFERENCES "public"."OrganizationIntegration"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."OrganizationProjectIntegration" ADD CONSTRAINT "OrganizationProjectIntegration_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/internal-packages/database/prisma/migrations/20260129162810_add_integration_deployment/migration.sql b/internal-packages/database/prisma/migrations/20260129162810_add_integration_deployment/migration.sql new file mode 100644 index 0000000000..5594398d06 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260129162810_add_integration_deployment/migration.sql @@ -0,0 +1,21 @@ +-- CreateTable +CREATE TABLE "public"."IntegrationDeployment" ( + "id" TEXT NOT NULL, + "integrationName" TEXT NOT NULL, + "integrationDeploymentId" TEXT NOT NULL, + "commitSHA" TEXT NOT NULL, + "deploymentId" TEXT, + "status" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "IntegrationDeployment_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "IntegrationDeployment_deploymentId_idx" ON "public"."IntegrationDeployment"("deploymentId"); + +-- CreateIndex +CREATE INDEX "IntegrationDeployment_commitSHA_idx" ON "public"."IntegrationDeployment"("commitSHA"); + +-- AddForeignKey +ALTER TABLE "public"."IntegrationDeployment" ADD CONSTRAINT "IntegrationDeployment_deploymentId_fkey" FOREIGN KEY ("deploymentId") REFERENCES "public"."WorkerDeployment"("id") ON DELETE SET NULL ON UPDATE CASCADE; \ No newline at end of file diff --git a/internal-packages/database/prisma/migrations/20260129162946_alter_tables_for_integrations_data/migration.sql b/internal-packages/database/prisma/migrations/20260129162946_alter_tables_for_integrations_data/migration.sql new file mode 100644 index 0000000000..345d337f18 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260129162946_alter_tables_for_integrations_data/migration.sql @@ -0,0 +1,9 @@ +-- AlterEnum +ALTER TYPE "public"."IntegrationService" ADD VALUE 'VERCEL'; + +-- AlterTable +ALTER TABLE "public"."OrganizationIntegration" ADD COLUMN "deletedAt" TIMESTAMP(3), +ADD COLUMN "externalOrganizationId" TEXT; + +-- AlterTable +ALTER TABLE "public"."WorkerDeployment" ADD COLUMN "commitSHA" TEXT; diff --git a/internal-packages/database/prisma/migrations/20260129165555_add_organization_integration_idx/migration.sql b/internal-packages/database/prisma/migrations/20260129165555_add_organization_integration_idx/migration.sql new file mode 100644 index 0000000000..ac24fc4bdb --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260129165555_add_organization_integration_idx/migration.sql @@ -0,0 +1,3 @@ +-- CreateIndex +CREATE INDEX CONCURRENTLY IF NOT EXISTS "OrganizationIntegration_externalOrganizationId_idx" ON "public"."OrganizationIntegration"("externalOrganizationId"); + diff --git a/internal-packages/database/prisma/migrations/20260129165809_add_worker_deployment_idx/migration.sql b/internal-packages/database/prisma/migrations/20260129165809_add_worker_deployment_idx/migration.sql new file mode 100644 index 0000000000..fcf74c0d97 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260129165809_add_worker_deployment_idx/migration.sql @@ -0,0 +1,3 @@ + +-- CreateIndex +CREATE INDEX CONCURRENTLY IF NOT EXISTS "WorkerDeployment_commitSHA_idx" ON "public"."WorkerDeployment"("commitSHA"); \ No newline at end of file diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index c76b411412..d0fd08b45a 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -384,28 +384,29 @@ model Project { /// The master queues they are allowed to use (impacts what they can set as default and trigger runs with) allowedWorkerQueues String[] @default([]) @map("allowedMasterQueues") - environments RuntimeEnvironment[] - backgroundWorkers BackgroundWorker[] - backgroundWorkerTasks BackgroundWorkerTask[] - taskRuns TaskRun[] - runTags TaskRunTag[] - taskQueues TaskQueue[] - environmentVariables EnvironmentVariable[] - checkpoints Checkpoint[] - WorkerDeployment WorkerDeployment[] - CheckpointRestoreEvent CheckpointRestoreEvent[] - taskSchedules TaskSchedule[] - alertChannels ProjectAlertChannel[] - alerts ProjectAlert[] - alertStorages ProjectAlertStorage[] - bulkActionGroups BulkActionGroup[] - BackgroundWorkerFile BackgroundWorkerFile[] - waitpoints Waitpoint[] - taskRunWaitpoints TaskRunWaitpoint[] - taskRunCheckpoints TaskRunCheckpoint[] - waitpointTags WaitpointTag[] - connectedGithubRepository ConnectedGithubRepository? - customerQueries CustomerQuery[] + environments RuntimeEnvironment[] + backgroundWorkers BackgroundWorker[] + backgroundWorkerTasks BackgroundWorkerTask[] + taskRuns TaskRun[] + runTags TaskRunTag[] + taskQueues TaskQueue[] + environmentVariables EnvironmentVariable[] + checkpoints Checkpoint[] + WorkerDeployment WorkerDeployment[] + CheckpointRestoreEvent CheckpointRestoreEvent[] + taskSchedules TaskSchedule[] + alertChannels ProjectAlertChannel[] + alerts ProjectAlert[] + alertStorages ProjectAlertStorage[] + bulkActionGroups BulkActionGroup[] + BackgroundWorkerFile BackgroundWorkerFile[] + waitpoints Waitpoint[] + taskRunWaitpoints TaskRunWaitpoint[] + taskRunCheckpoints TaskRunCheckpoint[] + waitpointTags WaitpointTag[] + connectedGithubRepository ConnectedGithubRepository? + organizationProjectIntegration OrganizationProjectIntegration[] + customerQueries CustomerQuery[] buildSettings Json? taskScheduleInstances TaskScheduleInstance[] @@ -1712,6 +1713,9 @@ model EnvironmentVariableValue { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + version Int @default(1) + lastUpdatedBy Json? + @@unique([variableId, environmentId]) } @@ -1825,9 +1829,10 @@ model WorkerDeployment { worker BackgroundWorker? @relation(fields: [workerId], references: [id], onDelete: Cascade, onUpdate: Cascade) workerId String? @unique - triggeredBy User? @relation(fields: [triggeredById], references: [id], onDelete: SetNull, onUpdate: Cascade) - triggeredById String? - triggeredVia String? + triggeredBy User? @relation(fields: [triggeredById], references: [id], onDelete: SetNull, onUpdate: Cascade) + triggeredById String? + triggeredVia String? + commitSHA String? startedAt DateTime? installedAt DateTime? @@ -1846,12 +1851,14 @@ model WorkerDeployment { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - promotions WorkerDeploymentPromotion[] - alerts ProjectAlert[] - workerInstance WorkerInstance[] + promotions WorkerDeploymentPromotion[] + alerts ProjectAlert[] + workerInstance WorkerInstance[] + integrationDeployments IntegrationDeployment[] @@unique([projectId, shortCode]) @@unique([environmentId, version]) + @@index([commitSHA]) } enum WorkerDeploymentStatus { @@ -2088,7 +2095,8 @@ model OrganizationIntegration { friendlyId String @unique - service IntegrationService + service IntegrationService + externalOrganizationId String? /// Identifier for external, integration's organization (e.g. Vercel's team) integrationData Json @@ -2100,12 +2108,39 @@ model OrganizationIntegration { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + deletedAt DateTime? - alertChannels ProjectAlertChannel[] + alertChannels ProjectAlertChannel[] + organizationProjectIntegration OrganizationProjectIntegration[] + + @@index([externalOrganizationId]) +} + +model OrganizationProjectIntegration { + id String @id @default(cuid()) + + organizationIntegration OrganizationIntegration @relation(fields: [organizationIntegrationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + organizationIntegrationId String + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String + + externalEntityId String /// Identifier for webhooks, for example Vercel's projectId + integrationData Json /// Save useful data like config or external entity name + installedBy String? /// UserId who installed the integration + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + @@index([projectId]) + @@index([projectId, organizationIntegrationId]) + @@index([externalEntityId]) } enum IntegrationService { SLACK + VERCEL } /// Bulk actions, like canceling and replaying runs @@ -2486,3 +2521,20 @@ model CustomerQuery { /// For Stripe metering job - find unprocessed queries @@index([createdAt]) } + +model IntegrationDeployment { + id String @id @default(cuid()) + + integrationName String /// For example Vercel + integrationDeploymentId String /// External ID + commitSHA String + deploymentId String? + status String? /// External deployment status + + workerDeployment WorkerDeployment? @relation(fields: [deploymentId], references: [id], onDelete: SetNull, onUpdate: Cascade) + + createdAt DateTime @default(now()) + + @@index([commitSHA]) + @@index([deploymentId]) +} diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index 0291d2a05c..4cb5c96503 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -694,6 +694,7 @@ export const GetDeploymentResponseBody = z.object({ version: z.string(), imageReference: z.string().nullish(), imagePlatform: z.string(), + commitSHA: z.string().nullish(), externalBuildData: ExternalBuildData.optional().nullable(), errorData: DeploymentErrorData.nullish(), worker: z @@ -710,6 +711,17 @@ export const GetDeploymentResponseBody = z.object({ ), }) .optional(), + integrationDeployments: z + .array( + z.object({ + id: z.string(), + integrationName: z.string(), + integrationDeploymentId: z.string(), + commitSHA: z.string(), + createdAt: z.coerce.date(), + }) + ) + .nullish(), }); export type GetDeploymentResponseBody = z.infer; @@ -1139,6 +1151,12 @@ export const ImportEnvironmentVariablesRequestBody = z.object({ variables: z.record(z.string()), parentVariables: z.record(z.string()).optional(), override: z.boolean().optional(), + source: z + .discriminatedUnion("type", [ + z.object({ type: z.literal("user"), userId: z.string() }), + z.object({ type: z.literal("integration"), integration: z.string() }), + ]) + .optional(), }); export type ImportEnvironmentVariablesRequestBody = z.infer< diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 99024a016b..b963c79a92 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -521,6 +521,9 @@ importers: '@upstash/ratelimit': specifier: ^1.1.3 version: 1.1.3(patch_hash=e5922e50fbefb7b2b24950c4b1c5c9ddc4cd25464439c9548d2298c432debe74) + '@vercel/sdk': + specifier: ^1.18.5 + version: 1.18.5 '@whatwg-node/fetch': specifier: ^0.9.14 version: 0.9.14 @@ -11115,6 +11118,10 @@ packages: engines: {node: '>=18.14'} deprecated: '@vercel/postgres is deprecated. You can either choose an alternate storage solution from the Vercel Marketplace if you want to set up a new database. Or you can follow this guide to migrate your existing Vercel Postgres db: https://neon.com/docs/guides/vercel-postgres-transition-guide' + '@vercel/sdk@1.18.5': + resolution: {integrity: sha512-tzxGuUxYZQpKsf5WrImp4gnCZu3xhHA4j6KLSAQdXgpi/Lfk3JV5YGEDU6ZZBIwXDMdny5DozLd20tz/bKZsfg==} + hasBin: true + '@vitest/coverage-v8@3.1.4': resolution: {integrity: sha512-G4p6OtioySL+hPV7Y6JHlhpsODbJzt1ndwHAFkyk6vVjpK03PFsKnauZIzcd0PrK4zAbc5lc+jeZ+eNGiMA+iw==} peerDependencies: @@ -31330,6 +31337,15 @@ snapshots: transitivePeerDependencies: - utf-8-validate + '@vercel/sdk@1.18.5': + dependencies: + '@modelcontextprotocol/sdk': 1.25.2(hono@4.5.11)(supports-color@10.0.0)(zod@3.25.76) + zod: 3.25.76 + transitivePeerDependencies: + - '@cfworker/json-schema' + - hono + - supports-color + '@vitest/coverage-v8@3.1.4(vitest@3.1.4(@types/debug@4.1.12)(@types/node@20.14.14)(lightningcss@1.29.2)(terser@5.44.1))': dependencies: '@ampproject/remapping': 2.3.0