841 lines
24 KiB
TypeScript
841 lines
24 KiB
TypeScript
import type {
|
|
PrismaClient,
|
|
OrganizationProjectIntegration,
|
|
OrganizationIntegration,
|
|
SecretReference,
|
|
} from "@trigger.dev/database";
|
|
import { ResultAsync } from "neverthrow";
|
|
import { prisma, $transaction } from "~/db.server";
|
|
import { logger } from "~/services/logger.server";
|
|
import { VercelIntegrationRepository } from "~/models/vercelIntegration.server";
|
|
import { findCurrentWorkerDeployment } from "~/v3/models/workerDeployment.server";
|
|
import type {
|
|
VercelProjectIntegrationData,
|
|
VercelIntegrationConfig,
|
|
SyncEnvVarsMapping,
|
|
TriggerEnvironmentType,
|
|
EnvSlug,
|
|
} from "~/v3/vercel/vercelProjectIntegrationSchema";
|
|
import {
|
|
VercelProjectIntegrationDataSchema,
|
|
envTypeToSlug,
|
|
createDefaultVercelIntegrationData,
|
|
} from "~/v3/vercel/vercelProjectIntegrationSchema";
|
|
|
|
export type VercelProjectIntegrationWithParsedData = OrganizationProjectIntegration & {
|
|
parsedIntegrationData: VercelProjectIntegrationData;
|
|
};
|
|
|
|
export type VercelProjectIntegrationWithData = VercelProjectIntegrationWithParsedData & {
|
|
organizationIntegration: OrganizationIntegration;
|
|
};
|
|
|
|
export type VercelProjectIntegrationWithProject = VercelProjectIntegrationWithData & {
|
|
project: {
|
|
id: string;
|
|
name: string;
|
|
slug: string;
|
|
};
|
|
};
|
|
|
|
export class VercelIntegrationService {
|
|
#prismaClient: PrismaClient;
|
|
|
|
constructor(prismaClient: PrismaClient = prisma) {
|
|
this.#prismaClient = prismaClient;
|
|
}
|
|
|
|
async getVercelProjectIntegration(
|
|
projectId: string
|
|
): Promise<VercelProjectIntegrationWithData | null> {
|
|
const integration = await this.#prismaClient.organizationProjectIntegration.findFirst({
|
|
where: {
|
|
projectId,
|
|
deletedAt: null,
|
|
organizationIntegration: {
|
|
service: "VERCEL",
|
|
deletedAt: null,
|
|
},
|
|
},
|
|
include: {
|
|
organizationIntegration: true,
|
|
},
|
|
});
|
|
|
|
if (!integration) {
|
|
return null;
|
|
}
|
|
|
|
const parsedData = VercelProjectIntegrationDataSchema.safeParse(integration.integrationData);
|
|
|
|
if (!parsedData.success) {
|
|
logger.error("Failed to parse Vercel integration data", {
|
|
projectId,
|
|
integrationId: integration.id,
|
|
error: parsedData.error,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
...integration,
|
|
parsedIntegrationData: parsedData.data,
|
|
};
|
|
}
|
|
|
|
async getConnectedVercelProjects(
|
|
organizationId: string
|
|
): Promise<VercelProjectIntegrationWithProject[]> {
|
|
const integrations = await this.#prismaClient.organizationProjectIntegration.findMany({
|
|
where: {
|
|
deletedAt: null,
|
|
organizationIntegration: {
|
|
organizationId,
|
|
service: "VERCEL",
|
|
deletedAt: null,
|
|
},
|
|
},
|
|
include: {
|
|
organizationIntegration: true,
|
|
project: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
slug: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
return integrations
|
|
.map((integration) => {
|
|
const parsedData = VercelProjectIntegrationDataSchema.safeParse(
|
|
integration.integrationData
|
|
);
|
|
if (!parsedData.success) {
|
|
logger.error("Failed to parse Vercel integration data", {
|
|
integrationId: integration.id,
|
|
error: parsedData.error,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
...integration,
|
|
parsedIntegrationData: parsedData.data,
|
|
};
|
|
})
|
|
.filter((i): i is VercelProjectIntegrationWithProject => i !== null);
|
|
}
|
|
|
|
async createVercelProjectIntegration(params: {
|
|
organizationIntegrationId: string;
|
|
projectId: string;
|
|
vercelProjectId: string;
|
|
vercelProjectName: string;
|
|
vercelTeamId: string | null;
|
|
vercelTeamSlug?: string;
|
|
installedByUserId?: string;
|
|
}): Promise<OrganizationProjectIntegration> {
|
|
const integrationData = createDefaultVercelIntegrationData(
|
|
params.vercelProjectId,
|
|
params.vercelProjectName,
|
|
params.vercelTeamId,
|
|
params.vercelTeamSlug
|
|
);
|
|
|
|
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<VercelIntegrationConfig>
|
|
): Promise<VercelProjectIntegrationWithParsedData | null> {
|
|
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<VercelProjectIntegrationWithParsedData | null> {
|
|
const existing = await this.getVercelProjectIntegration(projectId);
|
|
if (!existing) {
|
|
return null;
|
|
}
|
|
|
|
const updatedData: VercelProjectIntegrationData = {
|
|
...existing.parsedIntegrationData,
|
|
syncEnvVarsMapping,
|
|
};
|
|
|
|
const updated = await this.#prismaClient.organizationProjectIntegration.update({
|
|
where: { id: existing.id },
|
|
data: {
|
|
integrationData: updatedData,
|
|
},
|
|
});
|
|
|
|
return {
|
|
...updated,
|
|
parsedIntegrationData: updatedData,
|
|
};
|
|
}
|
|
|
|
async updateSyncEnvVarForEnvironment(
|
|
projectId: string,
|
|
envVarKey: string,
|
|
environmentType: TriggerEnvironmentType,
|
|
syncEnabled: boolean
|
|
): Promise<VercelProjectIntegrationWithParsedData | null> {
|
|
const existing = await this.getVercelProjectIntegration(projectId);
|
|
if (!existing) {
|
|
return null;
|
|
}
|
|
|
|
const currentMapping = existing.parsedIntegrationData.syncEnvVarsMapping || {};
|
|
const envSlug = envTypeToSlug(environmentType);
|
|
|
|
const currentEnvSettings = currentMapping[envSlug] || {};
|
|
|
|
const updatedMapping: SyncEnvVarsMapping = {
|
|
...currentMapping,
|
|
[envSlug]: {
|
|
...currentEnvSettings,
|
|
[envVarKey]: syncEnabled,
|
|
},
|
|
};
|
|
|
|
const updatedData: VercelProjectIntegrationData = {
|
|
...existing.parsedIntegrationData,
|
|
syncEnvVarsMapping: updatedMapping,
|
|
};
|
|
|
|
const updated = await this.#prismaClient.organizationProjectIntegration.update({
|
|
where: { id: existing.id },
|
|
data: {
|
|
integrationData: updatedData,
|
|
},
|
|
});
|
|
|
|
return {
|
|
...updated,
|
|
parsedIntegrationData: updatedData,
|
|
};
|
|
}
|
|
|
|
async removeSyncEnvVarForEnvironment(
|
|
projectId: string,
|
|
envVarKey: string,
|
|
environmentType: TriggerEnvironmentType
|
|
): Promise<void> {
|
|
const existing = await this.getVercelProjectIntegration(projectId);
|
|
if (!existing) return;
|
|
|
|
const currentMapping = existing.parsedIntegrationData.syncEnvVarsMapping || {};
|
|
const envSlug = envTypeToSlug(environmentType);
|
|
const currentEnvSettings = currentMapping[envSlug];
|
|
if (!currentEnvSettings || !(envVarKey in currentEnvSettings)) return;
|
|
|
|
const { [envVarKey]: _, ...rest } = currentEnvSettings;
|
|
const updatedMapping = { ...currentMapping, [envSlug]: rest };
|
|
|
|
await this.#prismaClient.organizationProjectIntegration.update({
|
|
where: { id: existing.id },
|
|
data: {
|
|
integrationData: {
|
|
...existing.parsedIntegrationData,
|
|
syncEnvVarsMapping: updatedMapping,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
async completeOnboarding(
|
|
projectId: string,
|
|
params: {
|
|
vercelStagingEnvironment?: { environmentId: string; displayName: string } | null;
|
|
pullEnvVarsBeforeBuild?: EnvSlug[] | null;
|
|
atomicBuilds?: EnvSlug[] | null;
|
|
discoverEnvVars?: EnvSlug[] | null;
|
|
syncEnvVarsMapping?: SyncEnvVarsMapping;
|
|
origin?: "marketplace" | "dashboard";
|
|
}
|
|
): Promise<VercelProjectIntegrationWithParsedData | null> {
|
|
const existing = await this.getVercelProjectIntegration(projectId);
|
|
if (!existing) {
|
|
return null;
|
|
}
|
|
|
|
const syncEnvVarsMapping = params.syncEnvVarsMapping ?? {
|
|
dev: {},
|
|
stg: {},
|
|
prod: {},
|
|
preview: {},
|
|
};
|
|
const 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<void> {
|
|
if (!atomicBuilds?.includes("prod")) {
|
|
return;
|
|
}
|
|
|
|
const prodEnvironment = await this.#prismaClient.runtimeEnvironment.findFirst({
|
|
where: {
|
|
projectId,
|
|
type: "PRODUCTION",
|
|
},
|
|
select: {
|
|
id: true,
|
|
},
|
|
});
|
|
|
|
if (!prodEnvironment) {
|
|
return;
|
|
}
|
|
|
|
const currentDeployment = await findCurrentWorkerDeployment({
|
|
environmentId: prodEnvironment.id,
|
|
});
|
|
|
|
if (!currentDeployment?.version) {
|
|
return;
|
|
}
|
|
|
|
const clientResult = await VercelIntegrationRepository.getVercelClient(orgIntegration);
|
|
if (clientResult.isErr()) {
|
|
logger.error("Failed to get Vercel client for TRIGGER_VERSION sync", {
|
|
projectId,
|
|
error: clientResult.error.message,
|
|
});
|
|
return;
|
|
}
|
|
const client = clientResult.value;
|
|
const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration);
|
|
|
|
// Get the Vercel project ID from the project integration
|
|
const projectIntegration = await this.#prismaClient.organizationProjectIntegration.findFirst({
|
|
where: {
|
|
projectId,
|
|
organizationIntegrationId: orgIntegration.id,
|
|
deletedAt: null,
|
|
},
|
|
select: {
|
|
externalEntityId: true,
|
|
},
|
|
});
|
|
|
|
if (!projectIntegration) {
|
|
return;
|
|
}
|
|
|
|
const vercelProjectId = projectIntegration.externalEntityId;
|
|
|
|
// Check if TRIGGER_VERSION already exists targeting production
|
|
const envVarsResult = await VercelIntegrationRepository.getVercelEnvironmentVariables(
|
|
client,
|
|
vercelProjectId,
|
|
teamId
|
|
);
|
|
|
|
if (envVarsResult.isErr()) {
|
|
logger.warn("Failed to fetch Vercel env vars for TRIGGER_VERSION sync", {
|
|
projectId,
|
|
vercelProjectId,
|
|
error: envVarsResult.error.message,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const existingTriggerVersion = envVarsResult.value.find(
|
|
(env) => env.key === "TRIGGER_VERSION" && env.target.includes("production")
|
|
);
|
|
|
|
if (existingTriggerVersion) {
|
|
return;
|
|
}
|
|
|
|
// Push TRIGGER_VERSION to Vercel production
|
|
const createResult = await ResultAsync.fromPromise(
|
|
client.projects.createProjectEnv({
|
|
idOrName: vercelProjectId,
|
|
...(teamId && { teamId }),
|
|
upsert: "true",
|
|
requestBody: {
|
|
key: "TRIGGER_VERSION",
|
|
value: currentDeployment.version,
|
|
target: ["production"] as any,
|
|
type: "encrypted",
|
|
},
|
|
}),
|
|
(error) => error
|
|
);
|
|
|
|
if (createResult.isErr()) {
|
|
logger.error("Failed to sync TRIGGER_VERSION to Vercel production", {
|
|
projectId,
|
|
vercelProjectId,
|
|
error:
|
|
createResult.error instanceof Error
|
|
? createResult.error.message
|
|
: String(createResult.error),
|
|
});
|
|
return;
|
|
}
|
|
|
|
logger.info("Synced TRIGGER_VERSION to Vercel production", {
|
|
projectId,
|
|
vercelProjectId,
|
|
version: currentDeployment.version,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns true when TRIGGER_VERSION is no longer pinned on Vercel production after the call
|
|
* (either we cleared it or it wasn't set to begin with). Returns false when we failed to
|
|
* verify or perform the delete — callers should surface that to the user so they can clear
|
|
* it manually.
|
|
*/
|
|
async clearTriggerVersionFromVercelProduction(projectId: string): Promise<boolean> {
|
|
const orgIntegration =
|
|
await VercelIntegrationRepository.findVercelOrgIntegrationForProject(projectId);
|
|
if (!orgIntegration) {
|
|
return false;
|
|
}
|
|
|
|
const clientResult = await VercelIntegrationRepository.getVercelClient(orgIntegration);
|
|
if (clientResult.isErr()) {
|
|
logger.error("Failed to get Vercel client for TRIGGER_VERSION clear", {
|
|
projectId,
|
|
error: clientResult.error.message,
|
|
});
|
|
return false;
|
|
}
|
|
const client = clientResult.value;
|
|
const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration);
|
|
|
|
const projectIntegration = await this.#prismaClient.organizationProjectIntegration.findFirst({
|
|
where: {
|
|
projectId,
|
|
organizationIntegrationId: orgIntegration.id,
|
|
deletedAt: null,
|
|
},
|
|
select: {
|
|
externalEntityId: true,
|
|
},
|
|
});
|
|
|
|
if (!projectIntegration) {
|
|
return false;
|
|
}
|
|
|
|
const vercelProjectId = projectIntegration.externalEntityId;
|
|
|
|
const envVarsResult = await VercelIntegrationRepository.getVercelEnvironmentVariables(
|
|
client,
|
|
vercelProjectId,
|
|
teamId
|
|
);
|
|
|
|
if (envVarsResult.isErr()) {
|
|
logger.warn("Failed to fetch Vercel env vars for TRIGGER_VERSION clear", {
|
|
projectId,
|
|
vercelProjectId,
|
|
error: envVarsResult.error.message,
|
|
});
|
|
return false;
|
|
}
|
|
|
|
const existingTriggerVersion = envVarsResult.value.find(
|
|
(env) => env.key === "TRIGGER_VERSION" && env.target.includes("production")
|
|
);
|
|
|
|
if (!existingTriggerVersion) {
|
|
logger.info("TRIGGER_VERSION not present on Vercel production — nothing to clear", {
|
|
projectId,
|
|
vercelProjectId,
|
|
});
|
|
return true;
|
|
}
|
|
|
|
const removeResult = await ResultAsync.fromPromise(
|
|
client.projects.batchRemoveProjectEnv({
|
|
idOrName: vercelProjectId,
|
|
...(teamId && { teamId }),
|
|
requestBody: { ids: [existingTriggerVersion.id] },
|
|
}),
|
|
(error) => error
|
|
);
|
|
|
|
if (removeResult.isErr()) {
|
|
logger.error("Failed to clear TRIGGER_VERSION from Vercel production", {
|
|
projectId,
|
|
vercelProjectId,
|
|
error:
|
|
removeResult.error instanceof Error
|
|
? removeResult.error.message
|
|
: String(removeResult.error),
|
|
});
|
|
return false;
|
|
}
|
|
|
|
logger.info("Cleared TRIGGER_VERSION from Vercel production", {
|
|
projectId,
|
|
vercelProjectId,
|
|
});
|
|
return true;
|
|
}
|
|
|
|
async disconnectVercelProject(projectId: string): Promise<boolean> {
|
|
const existing = await this.getVercelProjectIntegration(projectId);
|
|
if (!existing) {
|
|
return false;
|
|
}
|
|
|
|
await this.#prismaClient.organizationProjectIntegration.update({
|
|
where: { id: existing.id },
|
|
data: {
|
|
deletedAt: new Date(),
|
|
},
|
|
});
|
|
|
|
return true;
|
|
}
|
|
}
|