Skip to content

Commit 29e0c2c

Browse files
[dev] [tofikwest] tofik/policy-versions-ui-update (#2093)
* feat(policies): enhance policy versioning and status handling * refactor(policies): simplify policy header layout and actions * refactor(policies): update publish action to handle empty content and add pdfUrl * refactor(policies): clear approval fields in publish action * refactor(policies): sync draft content with published version in publish action * refactor(policies): optimize initialVersionId change detection in PolicyDetails * refactor(policies): add draft badge to policy details for better status indication * refactor(policies): add lastPublishedAt to policy details and tabs for status display * refactor(policies): implement transaction in publish action to prevent orphaned versions * refactor(cloud-tests): update legacy integration filtering for cloud providers --------- Co-authored-by: Tofik Hasanov <annexcies@gmail.com>
1 parent 3a37196 commit 29e0c2c

19 files changed

Lines changed: 1304 additions & 966 deletions

File tree

.cursor/rules/ui.mdc

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
---
2-
description: Use when building or modifying React components, styling, or working with the design system
3-
alwaysApply: false
2+
alwaysApply: true
43
---
54
# UI Components
65

apps/api/src/trigger/tasks/onboarding/migrate-policies-for-org.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { db, type Prisma } from '@db';
1+
import { db, PolicyStatus, type Prisma } from '@db';
22
import { logger, schemaTask } from '@trigger.dev/sdk';
33
import { z } from 'zod';
44

@@ -20,6 +20,7 @@ export const migratePoliciesForOrg = schemaTask({
2020
id: true,
2121
content: true,
2222
pdfUrl: true,
23+
status: true,
2324
},
2425
});
2526

@@ -86,9 +87,17 @@ export const migratePoliciesForOrg = schemaTask({
8687
},
8788
});
8889

90+
// Update policy - respect current policy status
91+
const isPublished = policy.status === PolicyStatus.published;
92+
8993
await tx.policy.update({
9094
where: { id: policy.id },
91-
data: { currentVersionId: version.id },
95+
data: {
96+
currentVersionId: version.id,
97+
draftContent: (policy.content as Prisma.InputJsonValue[]) || [],
98+
// Only set lastPublishedAt if policy is published
99+
...(isPublished ? { lastPublishedAt: new Date() } : {}),
100+
},
92101
});
93102

94103
migrated++;

apps/app/src/actions/policies/deny-requested-policy-changes.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,9 @@ export const denyRequestedPolicyChangesAction = authActionClient
5151
}
5252

5353
// Update policy status
54-
// If there's a current published version, keep status as published
55-
// Otherwise, set to draft
56-
const newStatus = policy.currentVersionId ? PolicyStatus.published : PolicyStatus.draft;
54+
// If the policy was previously published (has lastPublishedAt), keep status as published
55+
// Otherwise, set to draft (the policy was never published)
56+
const newStatus = policy.lastPublishedAt ? PolicyStatus.published : PolicyStatus.draft;
5757

5858
await db.policy.update({
5959
where: {

apps/app/src/actions/policies/migrate-policies-to-versioning.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,15 +91,17 @@ export const migratePoliciesAction = authActionClient
9191
});
9292

9393
// Update policy to set currentVersionId
94+
// Preserve the original status (draft, needs_review, or published)
95+
const isPublished = policy.status === PolicyStatus.published;
96+
9497
await tx.policy.update({
9598
where: { id: policy.id },
9699
data: {
97100
currentVersionId: version.id,
98-
// Ensure status is set properly
99-
status:
100-
policy.status === PolicyStatus.published
101-
? PolicyStatus.published
102-
: PolicyStatus.draft,
101+
draftContent: (policy.content as Prisma.InputJsonValue[]) || [],
102+
// Only set lastPublishedAt if policy is published
103+
...(isPublished ? { lastPublishedAt: new Date() } : {}),
104+
// Status is preserved - no change needed
103105
},
104106
});
105107

@@ -153,6 +155,8 @@ export async function ensurePolicyHasVersion(
153155
}
154156

155157
// Create version 1
158+
const isPublished = policy.status === PolicyStatus.published;
159+
156160
const version = await db.$transaction(async (tx) => {
157161
const newVersion = await tx.policyVersion.create({
158162
data: {
@@ -169,6 +173,9 @@ export async function ensurePolicyHasVersion(
169173
where: { id: policy.id },
170174
data: {
171175
currentVersionId: newVersion.id,
176+
draftContent: (policy.content as Prisma.InputJsonValue[]) || [],
177+
// Only set lastPublishedAt if policy is published
178+
...(isPublished ? { lastPublishedAt: new Date() } : {}),
172179
},
173180
});
174181

apps/app/src/actions/policies/publish-all.ts

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use server';
22

33
import { sendPublishAllPoliciesEmail } from '@/trigger/tasks/email/publish-all-policies-email';
4-
import { db, PolicyStatus, Role } from '@db';
4+
import { db, PolicyStatus, Role, type Prisma } from '@db';
55
import { revalidatePath } from 'next/cache';
66
import { z } from 'zod';
77
import { authActionClient } from '../safe-action';
@@ -62,8 +62,12 @@ export const publishAllPoliciesAction = authActionClient
6262
}
6363

6464
try {
65+
// Get all policies that are not published (draft or needs_review)
6566
const policies = await db.policy.findMany({
66-
where: { organizationId: parsedInput.organizationId, status: PolicyStatus.draft },
67+
where: {
68+
organizationId: parsedInput.organizationId,
69+
status: { in: [PolicyStatus.draft, PolicyStatus.needs_review] },
70+
},
6771
});
6872

6973
if (!policies || policies.length === 0) {
@@ -75,14 +79,61 @@ export const publishAllPoliciesAction = authActionClient
7579

7680
for (const policy of policies) {
7781
try {
78-
const updatedPolicy = await db.policy.update({
79-
where: { id: policy.id },
80-
data: {
81-
status: PolicyStatus.published,
82-
assigneeId: member.id,
83-
reviewDate: new Date(new Date().setDate(new Date().getDate() + 90)),
84-
},
85-
});
82+
// Check if policy has a current version, if not create version 1
83+
if (!policy.currentVersionId) {
84+
// Use transaction to prevent orphaned versions on partial failure
85+
await db.$transaction(async (tx) => {
86+
// Create version 1 from current policy content
87+
const newVersion = await tx.policyVersion.create({
88+
data: {
89+
policyId: policy.id,
90+
version: 1,
91+
content: (policy.content as Prisma.InputJsonValue[]) || [],
92+
pdfUrl: policy.pdfUrl,
93+
publishedById: member.id,
94+
changelog: 'Initial published version',
95+
},
96+
});
97+
98+
// Update policy with the new version and publish
99+
await tx.policy.update({
100+
where: { id: policy.id },
101+
data: {
102+
status: PolicyStatus.published,
103+
currentVersionId: newVersion.id,
104+
assigneeId: member.id,
105+
reviewDate: new Date(new Date().setDate(new Date().getDate() + 90)),
106+
lastPublishedAt: new Date(),
107+
draftContent: (policy.content as Prisma.InputJsonValue[]) || [],
108+
// Clear approval fields (in case policy was in needs_review)
109+
approverId: null,
110+
pendingVersionId: null,
111+
},
112+
});
113+
});
114+
} else {
115+
// Policy already has a version, just update status
116+
// Get the current version content to sync draftContent
117+
const currentVersion = await db.policyVersion.findUnique({
118+
where: { id: policy.currentVersionId },
119+
select: { content: true },
120+
});
121+
122+
await db.policy.update({
123+
where: { id: policy.id },
124+
data: {
125+
status: PolicyStatus.published,
126+
assigneeId: member.id,
127+
reviewDate: new Date(new Date().setDate(new Date().getDate() + 90)),
128+
lastPublishedAt: new Date(),
129+
// Sync draftContent with the published version content
130+
draftContent: (currentVersion?.content as Prisma.InputJsonValue[]) || (policy.content as Prisma.InputJsonValue[]) || [],
131+
// Clear approval fields (in case policy was in needs_review)
132+
approverId: null,
133+
pendingVersionId: null,
134+
},
135+
});
136+
}
86137
} catch (policyError) {
87138
console.error(`[publish-all-policies] Failed to update policy ${policy.id}:`, {
88139
error: policyError,

apps/app/src/actions/policies/submit-version-for-approval.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,10 @@ export const submitVersionForApprovalAction = authActionClient
5353
return { success: false, error: 'Version not found' };
5454
}
5555

56-
// Cannot submit the already-active version for approval
57-
if (versionId === policy.currentVersionId) {
58-
return { success: false, error: 'Cannot submit the currently published version for approval' };
56+
// Cannot submit the already-published version for approval
57+
// Only block if the policy is already published AND this is the current version
58+
if (versionId === policy.currentVersionId && policy.status === PolicyStatus.published) {
59+
return { success: false, error: 'This version is already published' };
5960
}
6061

6162
// Verify approver exists and belongs to organization

apps/app/src/actions/policies/update-version-content.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { revalidatePath } from 'next/cache';
44
import { z } from 'zod';
5-
import { db } from '@db';
5+
import { db, PolicyStatus } from '@db';
66
import type { Prisma } from '@db';
77
import { authActionClient } from '../safe-action';
88

@@ -66,12 +66,21 @@ export const updateVersionContentAction = authActionClient
6666
})
6767
.action(async ({ parsedInput, ctx }) => {
6868
const { policyId, versionId, content } = parsedInput;
69-
const { activeOrganizationId } = ctx.session;
69+
const { activeOrganizationId, userId } = ctx.session;
7070

7171
if (!activeOrganizationId) {
7272
return { success: false, error: 'Not authorized' };
7373
}
7474

75+
// Get member ID for tracking who updated
76+
const member = await db.member.findFirst({
77+
where: {
78+
organizationId: activeOrganizationId,
79+
userId,
80+
},
81+
select: { id: true },
82+
});
83+
7584
// Verify version exists and belongs to organization
7685
const version = await db.policyVersion.findUnique({
7786
where: { id: versionId },
@@ -82,6 +91,7 @@ export const updateVersionContentAction = authActionClient
8291
organizationId: true,
8392
currentVersionId: true,
8493
pendingVersionId: true,
94+
status: true,
8595
},
8696
},
8797
},
@@ -95,8 +105,8 @@ export const updateVersionContentAction = authActionClient
95105
return { success: false, error: 'Version does not belong to this policy' };
96106
}
97107

98-
// Cannot edit published version
99-
if (version.id === version.policy.currentVersionId) {
108+
// Cannot edit published version (only if the policy is actually published)
109+
if (version.id === version.policy.currentVersionId && version.policy.status === PolicyStatus.published) {
100110
return {
101111
success: false,
102112
error: 'Cannot edit the published version. Create a new version to make changes.',
@@ -117,7 +127,11 @@ export const updateVersionContentAction = authActionClient
117127

118128
await db.policyVersion.update({
119129
where: { id: versionId },
120-
data: { content: processedContent },
130+
data: {
131+
content: processedContent,
132+
// Update publishedById to track who last updated this version
133+
...(member?.id ? { publishedById: member.id } : {}),
134+
},
121135
});
122136

123137
revalidatePath(`/${activeOrganizationId}/policies/${policyId}`);

apps/app/src/app/(app)/[orgId]/cloud-tests/page.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,10 @@ export default async function CloudTestsPage({ params }: { params: Promise<{ org
8181
},
8282
});
8383

84-
// Filter out legacy integrations that have been migrated to new platform
85-
const newConnectionSlugs = new Set(newConnections.map((c) => c.provider.slug));
84+
// Filter legacy integrations to cloud providers only
85+
// NOTE: We now allow BOTH legacy and new connections to coexist for providers
86+
// that support multiple connections (e.g., AWS with multiple accounts)
8687
const activeLegacyIntegrations = legacyIntegrations.filter((integration) => {
87-
if (newConnectionSlugs.has(integration.integrationId)) return false;
8888
const manifest = getManifest(integration.integrationId);
8989
return manifest?.category === CLOUD_PROVIDER_CATEGORY;
9090
});

apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPageTabs.tsx

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { Control, Member, Policy, PolicyVersion, User } from '@db';
44
import type { JSONContent } from '@tiptap/react';
55
import { Stack, Tabs, TabsContent, TabsList, TabsTrigger } from '@trycompai/design-system';
66
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
7-
import { useMemo } from 'react';
7+
import { useEffect, useMemo, useState } from 'react';
88
import { Comments } from '../../../../../../components/comments/Comments';
99
import type { AuditLogWithRelations } from '../data';
1010
import { PolicyContentManager } from '../editor/components/PolicyDetails';
@@ -15,6 +15,7 @@ import { PolicyControlMappings } from './PolicyControlMappings';
1515
import { PolicyDeleteDialog } from './PolicyDeleteDialog';
1616
import { PolicyOverviewSheet } from './PolicyOverviewSheet';
1717
import { PolicySettingsCard } from './PolicySettingsCard';
18+
import { PolicyVersionsTab } from './PolicyVersionsTab';
1819
import { RecentAuditLogs } from './RecentAuditLogs';
1920

2021
type PolicyVersionWithPublisher = PolicyVersion & {
@@ -68,6 +69,31 @@ export function PolicyPageTabs({
6869
const isPendingApproval = policy ? !!policy.approverId : initialIsPendingApproval;
6970

7071
const isDeleteDialogOpen = searchParams.get('delete-policy') === 'true';
72+
const tabFromUrl = searchParams.get('tab') || 'overview';
73+
const versionIdFromUrl = searchParams.get('versionId');
74+
const [activeTab, setActiveTab] = useState(tabFromUrl);
75+
76+
// Sync activeTab with URL param
77+
useEffect(() => {
78+
setActiveTab(tabFromUrl);
79+
}, [tabFromUrl]);
80+
81+
const handleTabChange = (value: string) => {
82+
setActiveTab(value);
83+
const params = new URLSearchParams(searchParams.toString());
84+
if (value === 'overview') {
85+
params.delete('tab');
86+
params.delete('versionId');
87+
} else {
88+
params.set('tab', value);
89+
// Keep versionId if switching to content tab
90+
if (value !== 'content') {
91+
params.delete('versionId');
92+
}
93+
}
94+
const query = params.toString();
95+
router.push(query ? `${pathname}?${query}` : pathname);
96+
};
7197

7298
const handleCloseDeleteDialog = () => {
7399
const params = new URLSearchParams(searchParams.toString());
@@ -81,11 +107,12 @@ export function PolicyPageTabs({
81107
{/* Alerts always visible above tabs */}
82108
<PolicyAlerts policy={policy} isPendingApproval={isPendingApproval} onMutate={mutate} />
83109

84-
<Tabs defaultValue="overview">
110+
<Tabs value={activeTab} onValueChange={handleTabChange}>
85111
<Stack gap="lg">
86112
<TabsList variant="underline">
87113
<TabsTrigger value="overview">Overview</TabsTrigger>
88114
<TabsTrigger value="content">Content</TabsTrigger>
115+
<TabsTrigger value="versions">Versions</TabsTrigger>
89116
<TabsTrigger value="activity">Activity</TabsTrigger>
90117
<TabsTrigger value="comments">Comments</TabsTrigger>
91118
</TabsList>
@@ -96,7 +123,6 @@ export function PolicyPageTabs({
96123
policy={policy}
97124
assignees={assignees}
98125
isPendingApproval={isPendingApproval}
99-
versions={versions}
100126
onMutate={mutate}
101127
/>
102128
<PolicyControlMappings
@@ -140,10 +166,26 @@ export function PolicyPageTabs({
140166
currentVersionId={policy?.currentVersionId ?? null}
141167
pendingVersionId={policy?.pendingVersionId ?? null}
142168
versions={versions}
169+
policyStatus={policy?.status}
170+
lastPublishedAt={policy?.lastPublishedAt}
171+
assignees={assignees}
172+
initialVersionId={versionIdFromUrl || undefined}
143173
onMutate={mutate}
144174
/>
145175
</TabsContent>
146176

177+
<TabsContent value="versions">
178+
{policy && (
179+
<PolicyVersionsTab
180+
policy={policy}
181+
versions={versions}
182+
assignees={assignees}
183+
isPendingApproval={isPendingApproval}
184+
onMutate={mutate}
185+
/>
186+
)}
187+
</TabsContent>
188+
147189
<TabsContent value="activity">
148190
<RecentAuditLogs logs={logs} />
149191
</TabsContent>

0 commit comments

Comments
 (0)