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