diff --git a/apps/web/app/routes/ws/deployments/_components/EnvironmentActionsPanel.tsx b/apps/web/app/routes/ws/deployments/_components/EnvironmentActionsPanel.tsx
index d73455d06..b3a468190 100644
--- a/apps/web/app/routes/ws/deployments/_components/EnvironmentActionsPanel.tsx
+++ b/apps/web/app/routes/ws/deployments/_components/EnvironmentActionsPanel.tsx
@@ -1,9 +1,12 @@
import type { WorkspaceEngine } from "@ctrlplane/workspace-engine-sdk";
+import type React from "react";
import _ from "lodash";
import { CheckCircle, Server, Shield } from "lucide-react";
+import { toast } from "sonner";
import { trpc } from "~/api/trpc";
import { Badge } from "~/components/ui/badge";
+import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
@@ -20,6 +23,7 @@ import { useWorkspace } from "~/components/WorkspaceProvider";
type DeploymentVersion = WorkspaceEngine["schemas"]["DeploymentVersion"];
type ReleaseTarget = WorkspaceEngine["schemas"]["ReleaseTargetWithState"];
+type Environment = WorkspaceEngine["schemas"]["Environment"];
const getReleaseTargetKey = (rt: ReleaseTarget) => {
return `${rt.releaseTarget.resourceId}-${rt.releaseTarget.environmentId}-${rt.releaseTarget.deploymentId}`;
@@ -54,6 +58,63 @@ const PoliciesSection: React.FC<{ policies: string[] }> = ({ policies }) => {
);
};
+const PendingActionsSection: React.FC<{
+ version: DeploymentVersion;
+ environment: Environment;
+}> = ({ version, environment }) => {
+ const { workspace } = useWorkspace();
+ const approveMutation = trpc.deploymentVersions.approve.useMutation();
+ const decisionsQuery = trpc.decisions.environmentVersion.useQuery({
+ workspaceId: workspace.id,
+ environmentId: environment.id,
+ versionId: version.id,
+ });
+ const pendingActions = (decisionsQuery.data ?? []).flatMap(
+ (action) => action.ruleResults,
+ );
+ const approvalAction = pendingActions.find(
+ (action) => action.actionType === "approval",
+ );
+
+ if (approvalAction == null) return null;
+
+ const onClick = () =>
+ approveMutation
+ .mutateAsync({
+ workspaceId: workspace.id,
+ deploymentVersionId: version.id,
+ environmentId: environment.id,
+ status: "approved",
+ })
+ .then(() => toast.success("Approval record queued successfully"));
+
+ return (
+
{/* Policies */}
{/*
*/}
+
{/* Resources grouped by version */}
diff --git a/packages/trpc/src/root.ts b/packages/trpc/src/root.ts
index 31420f4e5..0e62152a9 100644
--- a/packages/trpc/src/root.ts
+++ b/packages/trpc/src/root.ts
@@ -1,3 +1,5 @@
+import { decisionsRouter } from "./routes/decisions.js";
+import { deploymentVersionsRouter } from "./routes/deployment-versions.js";
import { deploymentsRouter } from "./routes/deployments.js";
import { environmentRouter } from "./routes/environments.js";
import { githubRouter } from "./routes/github.js";
@@ -19,6 +21,7 @@ export const appRouter = router({
resource: resourcesRouter,
workspace: workspaceRouter,
deployment: deploymentsRouter,
+ deploymentVersions: deploymentVersionsRouter,
system: systemsRouter,
environment: environmentRouter,
validate: validateRouter,
@@ -29,4 +32,5 @@ export const appRouter = router({
resourceProviders: resourceProvidersRouter,
jobAgents: jobAgentsRouter,
github: githubRouter,
+ decisions: decisionsRouter,
});
diff --git a/packages/trpc/src/routes/decisions.ts b/packages/trpc/src/routes/decisions.ts
new file mode 100644
index 000000000..956217245
--- /dev/null
+++ b/packages/trpc/src/routes/decisions.ts
@@ -0,0 +1,90 @@
+import type { WorkspaceEngine } from "@ctrlplane/workspace-engine-sdk";
+import { z } from "zod";
+
+import { getClientFor } from "@ctrlplane/workspace-engine-sdk";
+
+import { protectedProcedure, router } from "../trpc.js";
+
+const getOneReleaseTarget = async (
+ workspaceId: string,
+ environmentId: string,
+ deploymentId: string,
+) => {
+ const response = await getClientFor(workspaceId).GET(
+ "/v1/workspaces/{workspaceId}/environments/{environmentId}/release-targets",
+ { params: { path: { workspaceId, environmentId } } },
+ );
+ return (response.data?.items ?? []).find(
+ (target) => target.deployment.id === deploymentId,
+ );
+};
+
+const getDeploymentVersion = async (
+ workspaceId: string,
+ deploymentVersionId: string,
+) => {
+ const response = await getClientFor(workspaceId).GET(
+ "/v1/workspaces/{workspaceId}/deploymentversions/{deploymentVersionId}",
+ { params: { path: { workspaceId, deploymentVersionId } } },
+ );
+ if (response.data == null) throw new Error("Deployment version not found");
+ return response.data;
+};
+
+const getPolicyResults = async (
+ workspaceId: string,
+ releaseTarget: WorkspaceEngine["schemas"]["ReleaseTargetWithState"],
+ version: WorkspaceEngine["schemas"]["DeploymentVersion"],
+) => {
+ const decision = await getClientFor(workspaceId).POST(
+ "/v1/workspaces/{workspaceId}/release-targets/evaluate",
+ {
+ params: { path: { workspaceId } },
+ body: {
+ releaseTarget: {
+ deploymentId: releaseTarget.deployment.id,
+ environmentId: releaseTarget.environment.id,
+ resourceId: releaseTarget.resource.id,
+ },
+ version,
+ },
+ },
+ );
+ return decision.data?.versionDecision?.policyResults ?? [];
+};
+
+const getEnvironmentScopedResults = (
+ policyResults: WorkspaceEngine["schemas"]["DeployDecision"]["policyResults"],
+) =>
+ policyResults.filter((result) =>
+ result.ruleResults.some(
+ (rule) => rule.actionType === "approval" && !rule.allowed,
+ ),
+ );
+
+export const decisionsRouter = router({
+ environmentVersion: protectedProcedure
+ .input(
+ z.object({
+ workspaceId: z.uuid(),
+ environmentId: z.uuid(),
+ versionId: z.uuid(),
+ }),
+ )
+ .query(async ({ input }) => {
+ const { workspaceId, environmentId, versionId } = input;
+ const version = await getDeploymentVersion(workspaceId, versionId);
+ const releaseTarget = await getOneReleaseTarget(
+ workspaceId,
+ environmentId,
+ version.deploymentId,
+ );
+ if (releaseTarget == null) return [];
+ const policyResults = await getPolicyResults(
+ workspaceId,
+ releaseTarget,
+ version,
+ );
+ return getEnvironmentScopedResults(policyResults);
+ }),
+});
diff --git a/packages/trpc/src/routes/deployment-versions.ts b/packages/trpc/src/routes/deployment-versions.ts
new file mode 100644
index 000000000..2750f773f
--- /dev/null
+++ b/packages/trpc/src/routes/deployment-versions.ts
@@ -0,0 +1,38 @@
+import type { WorkspaceEngine } from "@ctrlplane/workspace-engine-sdk";
+import { z } from "zod";
+
+import { Event, sendGoEvent } from "@ctrlplane/events";
+
+import { protectedProcedure, router } from "../trpc.js";
+
+export const deploymentVersionsRouter = router({
+ approve: protectedProcedure
+ .input(
+ z.object({
+ workspaceId: z.string().uuid(),
+ deploymentVersionId: z.string(),
+ environmentId: z.string(),
+ status: z.enum(["approved", "rejected"]).default("approved"),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const userId = ctx.session.user.id;
+
+ const record: WorkspaceEngine["schemas"]["UserApprovalRecord"] = {
+ userId,
+ versionId: input.deploymentVersionId,
+ environmentId: input.environmentId,
+ status: input.status,
+ createdAt: new Date().toISOString(),
+ };
+
+ await sendGoEvent({
+ workspaceId: input.workspaceId,
+ eventType: Event.UserApprovalRecordCreated,
+ timestamp: Date.now(),
+ data: record,
+ });
+
+ return record;
+ }),
+});