Skip to content

Commit 47dce1d

Browse files
committed
feat(vercel): add install param parser and org requirement helper
- Add getVercelInstallParams(request) to extract Vercel installation parameters (code, configurationId, next) from a request URL and only return them when the integration param is "vercel" or absent. This centralizes install URL parsing for Vercel flows. - Add requireOrganization(request, organizationSlug) helper that ensures the caller is an authenticated member of the organization, returning the organization and userId or throwing a 404 response. This enforces access control for org-scoped routes. - Refactor EnvironmentVariablesPresenter to use a Vercel integration service for fetching project integration and remove in-presenter legacy migration/parsing logic. The presenter now relies on VercelIntegrationService.getVercelProjectIntegration(projectId, true) to obtain parsed integration data, simplifying responsibilities and consolidating Vercel-related parsing/migration in the service layer. These changes improve separation of concerns, centralize Vercel integration parsing, and add a reusable org auth helper for request handlers.
1 parent 479540b commit 47dce1d

File tree

8 files changed

+60
-174
lines changed

8 files changed

+60
-174
lines changed

.vscode/settings.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,5 @@
88
},
99
"vitest.disableWorkspaceWarning": true,
1010
"typescript.experimental.useTsgo": false,
11-
"chat.agent.maxRequests": 0
11+
"chat.agent.maxRequests": 10000
1212
}

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

Lines changed: 5 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,12 @@
1-
import { flipCauseOption } from "effect/Cause";
21
import { PrismaClient, prisma } from "~/db.server";
32
import { Project } from "~/models/project.server";
43
import { User } from "~/models/user.server";
54
import { filterOrphanedEnvironments, sortEnvironments } from "~/utils/environmentSort";
65
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
76
import {
8-
VercelProjectIntegrationDataSchema,
97
SyncEnvVarsMapping,
10-
isLegacySyncEnvVarsMapping,
11-
migrateLegacySyncEnvVarsMapping,
128
} from "~/v3/vercel/vercelProjectIntegrationSchema";
13-
import { logger } from "~/services/logger.server";
9+
import { VercelIntegrationService } from "~/services/vercelIntegration.server";
1410

1511
type Result = Awaited<ReturnType<EnvironmentVariablesPresenter["call"]>>;
1612
export type EnvironmentVariableWithSetValues = Result["environmentVariables"][number];
@@ -102,71 +98,15 @@ export class EnvironmentVariablesPresenter {
10298
const variables = await repository.getProject(project.id);
10399

104100
// Get Vercel integration data if it exists
105-
const vercelIntegration = await this.#prismaClient.organizationProjectIntegration.findFirst({
106-
where: {
107-
projectId: project.id,
108-
deletedAt: null,
109-
organizationIntegration: {
110-
service: "VERCEL",
111-
deletedAt: null,
112-
},
113-
},
114-
});
101+
const vercelService = new VercelIntegrationService(this.#prismaClient);
102+
const vercelIntegration = await vercelService.getVercelProjectIntegration(project.id, true);
115103

116104
let vercelSyncEnvVarsMapping: SyncEnvVarsMapping = {};
117105
let vercelPullEnvVarsEnabled = false;
118106

119107
if (vercelIntegration) {
120-
let parsedData = VercelProjectIntegrationDataSchema.safeParse(
121-
vercelIntegration.integrationData
122-
);
123-
124-
// Handle migration from legacy format if needed
125-
if (!parsedData.success) {
126-
const rawData = vercelIntegration.integrationData as Record<string, unknown>;
127-
128-
if (rawData && isLegacySyncEnvVarsMapping(rawData.syncEnvVarsMapping)) {
129-
logger.info("Migrating legacy Vercel sync mapping format in presenter", {
130-
projectId: project.id,
131-
integrationId: vercelIntegration.id,
132-
});
133-
134-
// Migrate the legacy format
135-
const migratedMapping = migrateLegacySyncEnvVarsMapping(
136-
rawData.syncEnvVarsMapping as Record<string, boolean>
137-
);
138-
139-
// Update the data with migrated mapping
140-
const migratedData = {
141-
...rawData,
142-
syncEnvVarsMapping: migratedMapping,
143-
};
144-
145-
// Try parsing again with migrated data
146-
parsedData = VercelProjectIntegrationDataSchema.safeParse(migratedData);
147-
148-
if (parsedData.success) {
149-
// Save the migrated data back to the database (fire and forget)
150-
this.#prismaClient.organizationProjectIntegration.update({
151-
where: { id: vercelIntegration.id },
152-
data: {
153-
integrationData: migratedData as any,
154-
},
155-
}).catch((error) => {
156-
logger.error("Failed to save migrated Vercel sync mapping", {
157-
projectId: project.id,
158-
integrationId: vercelIntegration.id,
159-
error,
160-
});
161-
});
162-
}
163-
}
164-
}
165-
166-
if (parsedData.success) {
167-
vercelSyncEnvVarsMapping = parsedData.data.syncEnvVarsMapping;
168-
vercelPullEnvVarsEnabled = parsedData.data.config.pullEnvVarsFromVercel;
169-
}
108+
vercelSyncEnvVarsMapping = vercelIntegration.parsedIntegrationData.syncEnvVarsMapping;
109+
vercelPullEnvVarsEnabled = vercelIntegration.parsedIntegrationData.config.pullEnvVarsFromVercel;
170110
}
171111

172112
return {

apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx

Lines changed: 5 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import {
1212
Dialog,
1313
DialogContent,
1414
DialogDescription,
15-
DialogFooter,
1615
DialogHeader,
1716
DialogTitle,
1817
DialogTrigger,
@@ -22,10 +21,9 @@ import { Header1 } from "~/components/primitives/Headers";
2221
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
2322
import { Paragraph } from "~/components/primitives/Paragraph";
2423
import { Table, TableBlankRow, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow } from "~/components/primitives/Table";
25-
import { useOrganization } from "~/hooks/useOrganizations";
2624
import { VercelIntegrationRepository } from "~/models/vercelIntegration.server";
2725
import { $transaction, prisma } from "~/db.server";
28-
import { requireUserId } from "~/services/session.server";
26+
import { requireOrganization } from "~/services/org.server";
2927
import { OrganizationParamsSchema } from "~/utils/pathBuilder";
3028
import { logger } from "~/services/logger.server";
3129
import { TrashIcon } from "@heroicons/react/20/solid";
@@ -44,29 +42,15 @@ function formatDate(date: Date): string {
4442
}).format(date);
4543
}
4644

47-
const SearchParamsSchema = z.object({
45+
const SearchParamsSchema = OrganizationParamsSchema.extend({
4846
configurationId: z.string().optional(),
4947
});
5048

5149
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
52-
const userId = await requireUserId(request);
53-
const { organizationSlug } = OrganizationParamsSchema.parse(params);
50+
const { organizationSlug, configurationId } = SearchParamsSchema.parse(params);
51+
const { organization } = await requireOrganization(request, organizationSlug);
5452

5553
const url = new URL(request.url);
56-
const { configurationId } = SearchParamsSchema.parse(Object.fromEntries(url.searchParams));
57-
58-
// Check user has access to organization
59-
const organization = await prisma.organization.findFirst({
60-
where: {
61-
slug: organizationSlug,
62-
members: { some: { userId } },
63-
deletedAt: null,
64-
},
65-
});
66-
67-
if (!organization) {
68-
throw new Response("Not found", { status: 404 });
69-
}
7054

7155
// Find Vercel integration for this organization
7256
let vercelIntegration = await prisma.organizationIntegration.findFirst({
@@ -136,28 +120,9 @@ const ActionSchema = z.object({
136120
});
137121

138122
export const action = async ({ request, params }: ActionFunctionArgs) => {
139-
const userId = await requireUserId(request);
140123
const { organizationSlug } = OrganizationParamsSchema.parse(params);
124+
const { organization, userId } = await requireOrganization(request, organizationSlug);
141125

142-
const formData = await request.formData();
143-
const { intent } = ActionSchema.parse(Object.fromEntries(formData));
144-
145-
if (intent !== "uninstall") {
146-
throw new Response("Invalid intent", { status: 400 });
147-
}
148-
149-
// Check user has access to organization
150-
const organization = await prisma.organization.findFirst({
151-
where: {
152-
slug: organizationSlug,
153-
members: { some: { userId } },
154-
deletedAt: null,
155-
},
156-
});
157-
158-
if (!organization) {
159-
throw new Response("Not found", { status: 404 });
160-
}
161126

162127
// Find Vercel integration
163128
const vercelIntegration = await prisma.organizationIntegration.findFirst({

apps/webapp/app/routes/confirm-basic-details.tsx

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { redirectWithSuccessMessage } from "~/models/message.server";
2525
import { updateUser } from "~/models/user.server";
2626
import { requireUserId } from "~/services/session.server";
2727
import { rootPath } from "~/utils/pathBuilder";
28+
import { getVercelInstallParams } from "~/v3/vercel";
2829

2930
function createSchema(
3031
constraints: {
@@ -106,22 +107,18 @@ export const action: ActionFunction = async ({ request }) => {
106107
});
107108

108109
// Preserve Vercel integration params if present
109-
const url = new URL(request.url);
110-
const code = url.searchParams.get("code");
111-
const configurationId = url.searchParams.get("configurationId");
112-
const integration = url.searchParams.get("integration");
113-
const next = url.searchParams.get("next");
110+
const vercelParams = getVercelInstallParams(request);
114111
let redirectUrl = rootPath();
115112

116-
if (code && configurationId && integration === "vercel") {
113+
if (vercelParams) {
117114
// Redirect to orgs/new with params preserved
118115
const params = new URLSearchParams({
119-
code,
120-
configurationId,
121-
integration,
116+
code: vercelParams.code,
117+
configurationId: vercelParams.configurationId,
118+
integration: "vercel",
122119
});
123-
if (next) {
124-
params.set("next", next);
120+
if (vercelParams.next) {
121+
params.set("next", vercelParams.next);
125122
}
126123
redirectUrl = `/orgs/new?${params.toString()}`;
127124
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { prisma } from "~/db.server";
2+
import { requireUserId } from "./session.server";
3+
4+
export async function requireOrganization(request: Request, organizationSlug: string) {
5+
const userId = await requireUserId(request);
6+
7+
const organization = await prisma.organization.findFirst({
8+
where: {
9+
slug: organizationSlug,
10+
members: { some: { userId } },
11+
deletedAt: null,
12+
},
13+
});
14+
15+
if (!organization) {
16+
throw new Response("Organization not found", { status: 404 });
17+
}
18+
19+
return { organization, userId };
20+
}

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ export class VercelIntegrationService {
3939
}
4040

4141
async getVercelProjectIntegration(
42-
projectId: string
42+
projectId: string,
43+
migrateIfNeeded: boolean = false
4344
): Promise<VercelProjectIntegrationWithData | null> {
4445
const integration = await this.#prismaClient.organizationProjectIntegration.findFirst({
4546
where: {
@@ -60,6 +61,7 @@ export class VercelIntegrationService {
6061
}
6162

6263
const parsedData = VercelProjectIntegrationDataSchema.safeParse(integration.integrationData);
64+
6365
if (!parsedData.success) {
6466
logger.error("Failed to parse Vercel integration data", {
6567
projectId,

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,21 @@
66

77
export * from "./vercelProjectIntegrationSchema";
88

9+
/**
10+
* Extract Vercel installation parameters from a request URL.
11+
*/
12+
export function getVercelInstallParams(request: Request) {
13+
const url = new URL(request.url);
14+
const code = url.searchParams.get("code");
15+
const configurationId = url.searchParams.get("configurationId");
16+
const integration = url.searchParams.get("integration");
17+
const next = url.searchParams.get("next");
18+
19+
if (code && configurationId && (integration === "vercel" || !integration)) {
20+
return { code, configurationId, next };
21+
}
22+
23+
return null;
24+
}
25+
26+

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

Lines changed: 0 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -83,13 +83,6 @@ export const SyncEnvVarsMappingSchema = z.record(z.string(), EnvVarSyncSettingsS
8383

8484
export type SyncEnvVarsMapping = z.infer<typeof SyncEnvVarsMappingSchema>;
8585

86-
/**
87-
* Legacy mapping format (simple boolean per env var)
88-
* Used for migration from old format to new format.
89-
*/
90-
export const LegacySyncEnvVarsMappingSchema = z.record(z.string(), z.boolean());
91-
export type LegacySyncEnvVarsMapping = z.infer<typeof LegacySyncEnvVarsMappingSchema>;
92-
9386
/**
9487
* The complete integrationData schema for OrganizationProjectIntegration
9588
* when the integration service is VERCEL.
@@ -192,52 +185,3 @@ export function shouldSyncEnvVarForAnyEnvironment(
192185
const environments: TriggerEnvironmentType[] = ["PRODUCTION", "STAGING", "PREVIEW", "DEVELOPMENT"];
193186
return environments.some((env) => envVarSettings[env] !== false);
194187
}
195-
196-
/**
197-
* Check if this is a legacy format mapping (simple boolean per env var)
198-
*/
199-
export function isLegacySyncEnvVarsMapping(mapping: unknown): mapping is LegacySyncEnvVarsMapping {
200-
if (!mapping || typeof mapping !== "object") {
201-
return false;
202-
}
203-
// Check if any value is a boolean (legacy format)
204-
// vs an object (new format)
205-
for (const value of Object.values(mapping)) {
206-
if (typeof value === "boolean") {
207-
return true;
208-
}
209-
// If it's an object, it's the new format
210-
if (typeof value === "object" && value !== null) {
211-
return false;
212-
}
213-
}
214-
// Empty object could be either, treat as new format
215-
return false;
216-
}
217-
218-
/**
219-
* Migrate legacy sync mapping format to new per-environment format.
220-
* If the env var was disabled in legacy format, it will be disabled for ALL environments.
221-
* If it was enabled (or not present), it will be enabled for all environments.
222-
*/
223-
export function migrateLegacySyncEnvVarsMapping(
224-
legacyMapping: LegacySyncEnvVarsMapping
225-
): SyncEnvVarsMapping {
226-
const newMapping: SyncEnvVarsMapping = {};
227-
const environments: TriggerEnvironmentType[] = ["PRODUCTION", "STAGING", "PREVIEW", "DEVELOPMENT"];
228-
229-
for (const [key, enabled] of Object.entries(legacyMapping)) {
230-
if (enabled === false) {
231-
// If disabled in legacy format, disable for all environments
232-
newMapping[key] = {
233-
PRODUCTION: false,
234-
STAGING: false,
235-
PREVIEW: false,
236-
DEVELOPMENT: false,
237-
};
238-
}
239-
// If enabled (true), we don't need to add it since default is enabled
240-
}
241-
242-
return newMapping;
243-
}

0 commit comments

Comments
 (0)