Skip to content

Commit 05d877a

Browse files
committed
feat(vercel): persist team slug and surface Vercel joins in deployments
Add vercelTeamSlug to OrganizationProjectIntegration data and persist it when creating or updating Vercel project integrations. Retrieve the team slug (when available) from the Vercel client and store it in the integration record so downstream logic can reference the Vercel team identifier without making extra API calls. Enhance DeploymentListPresenter to detect presence of a Vercel project integration and, when available, include a LEFT JOIN to fetch the most recent integration deployment id for each worker deployment. Parse the stored integration data using the VercelProjectIntegrationDataSchema and expose hasVercelIntegration, integrationDeploymentId, and cached Vercel fields for use when rendering deployment entries. These changes reduce repeated Vercel API calls and enable linking of deployments to their Vercel counterparts in the UI.
1 parent 12c62a0 commit 05d877a

File tree

5 files changed

+120
-4
lines changed

5 files changed

+120
-4
lines changed

apps/webapp/app/models/vercelIntegration.server.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,25 @@ export class VercelIntegrationRepository {
308308
);
309309
}
310310

311+
static getTeamSlug(
312+
client: Vercel,
313+
teamId: string | null
314+
): ResultAsync<string, VercelApiError> {
315+
if (teamId) {
316+
return wrapVercelCall(
317+
client.teams.getTeam({ teamId }),
318+
"Failed to fetch Vercel team",
319+
{ teamId }
320+
).map((response) => response.slug);
321+
}
322+
323+
return wrapVercelCall(
324+
client.user.getAuthUser(),
325+
"Failed to fetch Vercel user",
326+
{}
327+
).map((response) => response?.user.username ?? "unknown");
328+
}
329+
311330
static validateVercelToken(
312331
integration: OrganizationIntegration & { tokenReference: SecretReference }
313332
): ResultAsync<{ isValid: boolean }, VercelApiError> {

apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {
2-
type Prisma,
2+
Prisma,
33
type WorkerDeploymentStatus,
44
type WorkerInstanceGroupType,
55
} from "@trigger.dev/database";
@@ -10,6 +10,7 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
1010
import { type User } from "~/models/user.server";
1111
import { processGitMetadata } from "./BranchesPresenter.server";
1212
import { BranchTrackingConfigSchema, getTrackedBranchForEnvironment } from "~/v3/github";
13+
import { VercelProjectIntegrationDataSchema } from "~/v3/vercel/vercelProjectIntegrationSchema";
1314

1415
const pageSize = 20;
1516

@@ -105,6 +106,51 @@ export class DeploymentListPresenter {
105106
},
106107
});
107108

109+
// Check for Vercel integration before the main query so we can conditionally LEFT JOIN
110+
let hasVercelIntegration = false;
111+
let vercelTeamSlug: string | undefined;
112+
let vercelProjectName: string | undefined;
113+
114+
const vercelProjectIntegration =
115+
await this.#prismaClient.organizationProjectIntegration.findFirst({
116+
where: {
117+
projectId: project.id,
118+
deletedAt: null,
119+
organizationIntegration: {
120+
service: "VERCEL",
121+
deletedAt: null,
122+
},
123+
},
124+
select: {
125+
integrationData: true,
126+
},
127+
});
128+
129+
if (vercelProjectIntegration) {
130+
const parsed = VercelProjectIntegrationDataSchema.safeParse(
131+
vercelProjectIntegration.integrationData
132+
);
133+
134+
if (parsed.success && parsed.data.vercelTeamSlug) {
135+
hasVercelIntegration = true;
136+
vercelTeamSlug = parsed.data.vercelTeamSlug;
137+
vercelProjectName = parsed.data.vercelProjectName;
138+
}
139+
}
140+
141+
const vercelSelect = hasVercelIntegration
142+
? Prisma.sql`, id_dep."integrationDeploymentId"`
143+
: Prisma.sql``;
144+
const vercelJoin = hasVercelIntegration
145+
? Prisma.sql`LEFT JOIN LATERAL (
146+
SELECT id_inner."integrationDeploymentId"
147+
FROM ${sqlDatabaseSchema}."IntegrationDeployment" as id_inner
148+
WHERE id_inner."deploymentId" = wd."id" AND id_inner."integrationName" = 'vercel'
149+
ORDER BY id_inner."createdAt" DESC
150+
LIMIT 1
151+
) id_dep ON true`
152+
: Prisma.sql``;
153+
108154
const deployments = await this.#prismaClient.$queryRaw<
109155
{
110156
id: string;
@@ -123,6 +169,7 @@ export class DeploymentListPresenter {
123169
userAvatarUrl: string | null;
124170
type: WorkerInstanceGroupType;
125171
git: Prisma.JsonValue | null;
172+
integrationDeploymentId: string | null;
126173
}[]
127174
>`
128175
SELECT
@@ -142,10 +189,12 @@ export class DeploymentListPresenter {
142189
wd."deployedAt",
143190
wd."type",
144191
wd."git"
192+
${vercelSelect}
145193
FROM
146194
${sqlDatabaseSchema}."WorkerDeployment" as wd
147195
LEFT JOIN
148196
${sqlDatabaseSchema}."User" as u ON wd."triggeredById" = u."id"
197+
${vercelJoin}
149198
WHERE
150199
wd."projectId" = ${project.id}
151200
AND wd."environmentId" = ${environment.id}
@@ -173,13 +222,20 @@ LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`;
173222
return {
174223
currentPage: page,
175224
totalPages: Math.ceil(totalCount / pageSize),
225+
hasVercelIntegration,
176226
connectedGithubRepository: project.connectedGithubRepository ?? undefined,
177227
environmentGitHubBranch,
178228
deployments: deployments.map((deployment, index) => {
179229
const label = labeledDeployments.find(
180230
(labeledDeployment) => labeledDeployment.deploymentId === deployment.id
181231
);
182232

233+
let vercelDeploymentUrl: string | null = null;
234+
if (hasVercelIntegration && deployment.integrationDeploymentId && vercelTeamSlug && vercelProjectName) {
235+
const vercelId = deployment.integrationDeploymentId.replace(/^dpl_/, "");
236+
vercelDeploymentUrl = `https://vercel.com/${vercelTeamSlug}/${vercelProjectName}/${vercelId}`;
237+
}
238+
183239
return {
184240
id: deployment.id,
185241
shortCode: deployment.shortCode,
@@ -210,6 +266,7 @@ LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`;
210266
}
211267
: undefined,
212268
git: processGitMetadata(deployment.git),
269+
vercelDeploymentUrl,
213270
};
214271
}),
215272
};

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { useEffect } from "react";
1919
import { typedjson, useTypedLoaderData } from "remix-typedjson";
2020
import { z } from "zod";
2121
import { PromoteIcon } from "~/assets/icons/PromoteIcon";
22+
import { VercelLogo } from "~/components/integrations/VercelLogo";
2223
import { DeploymentsNone, DeploymentsNoneDev } from "~/components/BlankStatePanels";
2324
import { OctoKitty } from "~/components/GitHubLoginButton";
2425
import { GitMetadata } from "~/components/GitMetadata";
@@ -55,6 +56,7 @@ import {
5556
TableHeaderCell,
5657
TableRow,
5758
} from "~/components/primitives/Table";
59+
import { SimpleTooltip } from "~/components/primitives/Tooltip";
5860
import {
5961
DeploymentStatus,
6062
deploymentStatusDescription,
@@ -160,6 +162,7 @@ export default function Page() {
160162
connectedGithubRepository,
161163
environmentGitHubBranch,
162164
autoReloadPollIntervalMs,
165+
hasVercelIntegration,
163166
} = useTypedLoaderData<typeof loader>();
164167
const hasDeployments = totalPages > 0;
165168

@@ -234,6 +237,7 @@ export default function Page() {
234237
<TableHeaderCell>Deployed at</TableHeaderCell>
235238
<TableHeaderCell>Deployed by</TableHeaderCell>
236239
<TableHeaderCell>Git</TableHeaderCell>
240+
{hasVercelIntegration && <TableHeaderCell>Linked</TableHeaderCell>}
237241
<TableHeaderCell hiddenLabel>Go to page</TableHeaderCell>
238242
</TableRow>
239243
</TableHeader>
@@ -307,6 +311,28 @@ export default function Page() {
307311
<GitMetadata git={deployment.git} />
308312
</div>
309313
</TableCell>
314+
{hasVercelIntegration && (
315+
<TableCell isSelected={isSelected}>
316+
{deployment.vercelDeploymentUrl ? (
317+
<SimpleTooltip
318+
button={
319+
<a
320+
href={deployment.vercelDeploymentUrl}
321+
target="_blank"
322+
rel="noreferrer noopener"
323+
className="flex items-center text-text-dimmed transition-colors hover:text-text-bright"
324+
onClick={(e) => e.stopPropagation()}
325+
>
326+
<VercelLogo className="size-3.5" />
327+
</a>
328+
}
329+
content="View on Vercel"
330+
/>
331+
) : (
332+
"–"
333+
)}
334+
</TableCell>
335+
)}
310336
<DeploymentActionsCell
311337
deployment={deployment}
312338
path={path}
@@ -317,7 +343,7 @@ export default function Page() {
317343
);
318344
})
319345
) : (
320-
<TableBlankRow colSpan={8}>
346+
<TableBlankRow colSpan={hasVercelIntegration ? 9 : 8}>
321347
<Paragraph className="flex items-center justify-center">
322348
No deploys match your filters
323349
</Paragraph>

apps/webapp/app/services/vercelIntegration.server.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,12 +131,14 @@ export class VercelIntegrationService {
131131
vercelProjectId: string;
132132
vercelProjectName: string;
133133
vercelTeamId: string | null;
134+
vercelTeamSlug?: string;
134135
installedByUserId?: string;
135136
}): Promise<OrganizationProjectIntegration> {
136137
const integrationData = createDefaultVercelIntegrationData(
137138
params.vercelProjectId,
138139
params.vercelProjectName,
139-
params.vercelTeamId
140+
params.vercelTeamId,
141+
params.vercelTeamSlug
140142
);
141143

142144
return this.#prismaClient.organizationProjectIntegration.create({
@@ -170,6 +172,13 @@ export class VercelIntegrationService {
170172

171173
const teamId = await VercelIntegrationRepository.getTeamIdFromIntegration(orgIntegration);
172174

175+
const vercelTeamSlug = await VercelIntegrationRepository.getVercelClient(orgIntegration)
176+
.andThen((client) => VercelIntegrationRepository.getTeamSlug(client, teamId))
177+
.match(
178+
(slug) => slug,
179+
() => undefined
180+
);
181+
173182
const existing = await this.getVercelProjectIntegration(params.projectId);
174183
if (existing) {
175184
const updated = await this.#prismaClient.organizationProjectIntegration.update({
@@ -181,6 +190,7 @@ export class VercelIntegrationService {
181190
vercelProjectId: params.vercelProjectId,
182191
vercelProjectName: params.vercelProjectName,
183192
vercelTeamId: teamId,
193+
vercelTeamSlug,
184194
},
185195
},
186196
});
@@ -205,6 +215,7 @@ export class VercelIntegrationService {
205215
vercelProjectId: params.vercelProjectId,
206216
vercelProjectName: params.vercelProjectName,
207217
vercelTeamId: teamId,
218+
vercelTeamSlug,
208219
installedByUserId: params.userId,
209220
});
210221

apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export const VercelProjectIntegrationDataSchema = z.object({
5353
syncEnvVarsMapping: SyncEnvVarsMappingSchema,
5454
vercelProjectName: z.string(),
5555
vercelTeamId: z.string().nullable(),
56+
vercelTeamSlug: z.string().optional(),
5657
vercelProjectId: z.string(),
5758
onboardingCompleted: z.boolean().optional(),
5859
});
@@ -62,7 +63,8 @@ export type VercelProjectIntegrationData = z.infer<typeof VercelProjectIntegrati
6263
export function createDefaultVercelIntegrationData(
6364
vercelProjectId: string,
6465
vercelProjectName: string,
65-
vercelTeamId: string | null
66+
vercelTeamId: string | null,
67+
vercelTeamSlug?: string
6668
): VercelProjectIntegrationData {
6769
return {
6870
config: {
@@ -75,6 +77,7 @@ export function createDefaultVercelIntegrationData(
7577
vercelProjectId,
7678
vercelProjectName,
7779
vercelTeamId,
80+
vercelTeamSlug,
7881
};
7982
}
8083

0 commit comments

Comments
 (0)