feat(environment): introduce isDefault flag for environments

- Added `isDefault` boolean column to the environment schema, defaulting to false.
- Updated environment creation and deletion logic to handle default environments, allowing the production environment to be created and renamed.
- Enhanced error handling for environment updates and deletions to prevent modifications to default environments.
- Updated UI components to reflect changes in environment selection based on the new default logic.
This commit is contained in:
Mauricio Siu
2025-12-10 00:10:05 -06:00
parent 48be8544cf
commit 99aa34f27e
8 changed files with 7027 additions and 50 deletions

View File

@@ -102,7 +102,9 @@ export const AdvancedEnvironmentSelector = ({
setName("");
setDescription("");
} catch (error) {
toast.error("Failed to create environment");
toast.error(
`Failed to create environment: ${error instanceof Error ? error.message : error}`,
);
}
};
@@ -123,7 +125,9 @@ export const AdvancedEnvironmentSelector = ({
setName("");
setDescription("");
} catch (error) {
toast.error("Failed to update environment");
toast.error(
`Failed to update environment: ${error instanceof Error ? error.message : error}`,
);
}
};
@@ -140,15 +144,18 @@ export const AdvancedEnvironmentSelector = ({
setIsDeleteDialogOpen(false);
setSelectedEnvironment(null);
// Redirect to production if we deleted the current environment
// Redirect to first available environment if we deleted the current environment
if (selectedEnvironment.environmentId === currentEnvironmentId) {
const productionEnv = environments?.find(
(env) => env.name === "production",
const firstEnv = environments?.find(
(env) => env.environmentId !== selectedEnvironment.environmentId,
);
if (productionEnv) {
if (firstEnv) {
router.push(
`/dashboard/project/${projectId}/environment/${productionEnv.environmentId}`,
`/dashboard/project/${projectId}/environment/${firstEnv.environmentId}`,
);
} else {
// No other environments, redirect to project page
router.push(`/dashboard/project/${projectId}`);
}
}
} catch (error) {
@@ -239,8 +246,8 @@ export const AdvancedEnvironmentSelector = ({
)}
</div>
</DropdownMenuItem>
{environment.name !== "production" && (
<div className="flex items-center gap-1 px-2">
<div className="flex items-center gap-1 px-2">
{!environment.isDefault && (
<Button
variant="ghost"
size="sm"
@@ -252,22 +259,21 @@ export const AdvancedEnvironmentSelector = ({
>
<PencilIcon className="h-3 w-3" />
</Button>
{canDeleteEnvironments && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
onClick={(e) => {
e.stopPropagation();
openDeleteDialog(environment);
}}
>
<TrashIcon className="h-3 w-3" />
</Button>
)}
</div>
)}
)}
{canDeleteEnvironments && !environment.isDefault && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
onClick={(e) => {
e.stopPropagation();
openDeleteDialog(environment);
}}
>
<TrashIcon className="h-3 w-3" />
</Button>
)}
</div>
</div>
);
})}

View File

@@ -89,24 +89,26 @@ export const SearchCommand = () => {
<CommandGroup heading={"Projects"}>
<CommandList>
{data?.map((project) => {
const productionEnvironment = project.environments.find(
(environment) => environment.name === "production",
);
// Find default environment, or fall back to first environment
const defaultEnvironment =
project.environments.find(
(environment) => environment.isDefault,
) || project?.environments?.[0];
if (!productionEnvironment) return null;
if (!defaultEnvironment) return null;
return (
<CommandItem
key={project.projectId}
onSelect={() => {
router.push(
`/dashboard/project/${project.projectId}/environment/${productionEnvironment!.environmentId}`,
`/dashboard/project/${project.projectId}/environment/${defaultEnvironment.environmentId}`,
);
setOpen(false);
}}
>
<BookIcon className="size-4 text-muted-foreground mr-2" />
{project.name} / {productionEnvironment!.name}
{project.name} / {defaultEnvironment.name}
</CommandItem>
);
})}

View File

@@ -0,0 +1,4 @@
ALTER TABLE "environment" ADD COLUMN "isDefault" boolean DEFAULT false NOT NULL;
-- Set isDefault to true for existing production environments
UPDATE "environment" SET "isDefault" = true WHERE "name" = 'production';

File diff suppressed because it is too large Load Diff

View File

@@ -925,6 +925,13 @@
"when": 1765342621312,
"tag": "0131_volatile_beast",
"breakpoints": true
},
{
"idx": 132,
"version": "7",
"when": 1765346573500,
"tag": "0132_clean_layla_miller",
"breakpoints": true
}
]
}

View File

@@ -66,10 +66,12 @@ export const environmentRouter = createTRPCRouter({
if (input.name === "production") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Environment name cannot be production",
message:
"You cannot create a environment with the name 'production'",
});
}
// Allow users to create environments with any name, including "production"
const environment = await createEnvironment(input);
if (ctx.user.role === "member") {
@@ -243,13 +245,7 @@ export const environmentRouter = createTRPCRouter({
try {
const { environmentId, ...updateData } = input;
if (updateData.name === "production") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Environment name cannot be production",
});
}
// Allow users to rename environments to any name, including "production"
if (ctx.user.role === "member") {
await checkEnvironmentAccess(
ctx.user.id,
@@ -259,6 +255,13 @@ export const environmentRouter = createTRPCRouter({
);
}
const currentEnvironment = await findEnvironmentById(environmentId);
if (currentEnvironment.isDefault) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "You cannot update the default environment",
});
}
if (
currentEnvironment.project.organizationId !==
ctx.session.activeOrganizationId

View File

@@ -1,5 +1,5 @@
import { relations } from "drizzle-orm";
import { pgTable, text } from "drizzle-orm/pg-core";
import { boolean, pgTable, text } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
@@ -26,6 +26,7 @@ export const environments = pgTable("environment", {
projectId: text("projectId")
.notNull()
.references(() => projects.projectId, { onDelete: "cascade" }),
isDefault: boolean("isDefault").notNull().default(false),
});
export const environmentRelations = relations(
@@ -69,9 +70,14 @@ export const apiRemoveEnvironment = createSchema
})
.required();
export const apiUpdateEnvironment = createSchema.partial().extend({
environmentId: z.string().min(1),
});
export const apiUpdateEnvironment = createSchema
.partial()
.extend({
environmentId: z.string().min(1),
})
.omit({
isDefault: true,
});
export const apiDuplicateEnvironment = createSchema
.pick({

View File

@@ -103,10 +103,10 @@ export const findEnvironmentsByProjectId = async (projectId: string) => {
export const deleteEnvironment = async (environmentId: string) => {
const currentEnvironment = await findEnvironmentById(environmentId);
if (currentEnvironment.name === "production") {
if (currentEnvironment.isDefault) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "You cannot delete the production environment",
message: "You cannot delete the default environment",
});
}
const deletedEnvironment = await db
@@ -162,9 +162,23 @@ export const duplicateEnvironment = async (
};
export const createProductionEnvironment = async (projectId: string) => {
return createEnvironment({
name: "production",
description: "Production environment",
projectId,
});
const newEnvironment = await db
.insert(environments)
.values({
name: "production",
description: "Production environment",
projectId,
isDefault: true,
})
.returning()
.then((value) => value[0]);
if (!newEnvironment) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error creating the production environment",
});
}
return newEnvironment;
};