diff --git a/apps/app/src/actions/policies/delete-policy.ts b/apps/app/src/actions/policies/delete-policy.ts index 436c310fe..b836aa26f 100644 --- a/apps/app/src/actions/policies/delete-policy.ts +++ b/apps/app/src/actions/policies/delete-policy.ts @@ -53,12 +53,7 @@ export const deletePolicyAction = authActionClient // Revalidate paths to update UI revalidatePath(`/${activeOrganizationId}/policies/all`); - revalidatePath(`/${activeOrganizationId}/policies`); revalidateTag('policies'); - - return { - success: true, - }; } catch (error) { console.error(error); return { diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/regenerate-policy.ts b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/regenerate-policy.ts new file mode 100644 index 000000000..9f98bcd28 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/regenerate-policy.ts @@ -0,0 +1,66 @@ +'use server'; + +import { authActionClient } from '@/actions/safe-action'; +import { updatePolicy } from '@/jobs/tasks/onboarding/update-policy'; +import { db } from '@db'; +import { tasks } from '@trigger.dev/sdk'; +import { z } from 'zod'; + +export const regeneratePolicyAction = authActionClient + .inputSchema( + z.object({ + policyId: z.string().min(1), + }), + ) + .metadata({ + name: 'regenerate-policy', + track: { + event: 'regenerate-policy', + channel: 'server', + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { policyId } = parsedInput; + const { session } = ctx; + + if (!session?.activeOrganizationId) { + throw new Error('No active organization'); + } + + // Load frameworks associated to this organization via instances + const instances = await db.frameworkInstance.findMany({ + where: { organizationId: session.activeOrganizationId }, + include: { + framework: true, + }, + }); + + const uniqueFrameworks = Array.from( + new Map(instances.map((fi) => [fi.framework.id, fi.framework])).values(), + ).map((f) => ({ + id: f.id, + name: f.name, + version: f.version, + description: f.description, + visible: f.visible, + createdAt: f.createdAt, + updatedAt: f.updatedAt, + })); + + // Build contextHub string from context table Q&A + const contextEntries = await db.context.findMany({ + where: { organizationId: session.activeOrganizationId }, + orderBy: { createdAt: 'asc' }, + }); + const contextHub = contextEntries.map((c) => `${c.question}\n${c.answer}`).join('\n'); + + await tasks.trigger('update-policy', { + organizationId: session.activeOrganizationId, + policyId, + contextHub, + frameworks: uniqueFrameworks, + }); + + // Revalidation handled by safe-action middleware using x-pathname header + return { success: true }; + }); diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyDeleteDialog.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyDeleteDialog.tsx index df8c72871..aa3612c0d 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyDeleteDialog.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyDeleteDialog.tsx @@ -46,9 +46,7 @@ export function PolicyDeleteDialog({ isOpen, onClose, policy }: PolicyDeleteDial const deletePolicy = useAction(deletePolicyAction, { onSuccess: () => { - toast.info('Policy deleted! Redirecting to policies list...'); onClose(); - router.push(`/${policy.organizationId}/policies/all`); }, onError: () => { toast.error('Failed to delete policy.'); @@ -61,6 +59,11 @@ export function PolicyDeleteDialog({ isOpen, onClose, policy }: PolicyDeleteDial id: policy.id, entityId: policy.id, }); + + setTimeout(() => { + router.replace(`/${policy.organizationId}/policies/all`); + }, 1000); + toast.info('Policy deleted! Redirecting to policies list...'); }; return ( diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyHeaderActions.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyHeaderActions.tsx new file mode 100644 index 000000000..cfe99467b --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyHeaderActions.tsx @@ -0,0 +1,114 @@ +'use client'; + +import { regeneratePolicyAction } from '@/app/(app)/[orgId]/policies/[policyId]/actions/regenerate-policy'; +import { Button } from '@comp/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@comp/ui/dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@comp/ui/dropdown-menu'; +import { Icons } from '@comp/ui/icons'; +import { useAction } from 'next-safe-action/hooks'; +import { useState } from 'react'; +import { toast } from 'sonner'; + +export function PolicyHeaderActions({ policyId }: { policyId: string }) { + const [isConfirmOpen, setIsConfirmOpen] = useState(false); + // Delete flows through query param to existing dialog in PolicyOverview + const regenerate = useAction(regeneratePolicyAction, { + onSuccess: () => toast.success('Regeneration triggered. This may take a moment.'), + onError: () => toast.error('Failed to trigger policy regeneration'), + }); + + return ( + <> + + + + + + setIsConfirmOpen(true)}> + Regenerate policy + + { + const url = new URL(window.location.href); + url.searchParams.set('policy-overview-sheet', 'true'); + window.history.pushState({}, '', url.toString()); + }} + > + Edit policy + + { + const url = new URL(window.location.href); + url.searchParams.set('archive-policy-sheet', 'true'); + window.history.pushState({}, '', url.toString()); + }} + > + Archive / Restore + + { + const url = new URL(window.location.href); + url.searchParams.set('delete-policy', 'true'); + window.history.pushState({}, '', url.toString()); + }} + className="text-destructive" + > + Delete + + + + + !open && setIsConfirmOpen(false)}> + + + Regenerate Policy + + This will generate new policy content using your org context and frameworks and mark + it for review. Continue? + + + + + + + + + + {/* Delete confirmation handled by PolicyDeleteDialog via query param */} + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyOverview.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyOverview.tsx index a62789053..693ccbc89 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyOverview.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyOverview.tsx @@ -6,29 +6,16 @@ import { authClient } from '@/utils/auth-client'; import { Alert, AlertDescription, AlertTitle } from '@comp/ui/alert'; import { Button } from '@comp/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@comp/ui/card'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@comp/ui/dropdown-menu'; import { Icons } from '@comp/ui/icons'; import type { Member, Policy, User } from '@db'; import { Control } from '@db'; import { format } from 'date-fns'; -import { - ArchiveIcon, - ArchiveRestoreIcon, - MoreVertical, - PencilIcon, - ShieldCheck, - ShieldX, - Trash2, -} from 'lucide-react'; +import { ArchiveIcon, ArchiveRestoreIcon, ShieldCheck, ShieldX } from 'lucide-react'; import { useAction } from 'next-safe-action/hooks'; import { useQueryState } from 'nuqs'; import { useState } from 'react'; import { toast } from 'sonner'; +import { regeneratePolicyAction } from '../actions/regenerate-policy'; import { PolicyActionDialog } from './PolicyActionDialog'; import { PolicyArchiveSheet } from './PolicyArchiveSheet'; import { PolicyControlMappings } from './PolicyControlMappings'; @@ -79,10 +66,8 @@ export function PolicyOverview({ // Dialog state for approval/denial actions const [approveDialogOpen, setApproveDialogOpen] = useState(false); const [denyDialogOpen, setDenyDialogOpen] = useState(false); - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - - // Dropdown menu state - const [dropdownOpen, setDropdownOpen] = useState(false); + const [deleteOpenParam, setDeleteOpenParam] = useQueryState('delete-policy'); + const [regenerateOpen, setRegenerateOpen] = useState(false); // Handle approve with optional comment const handleApprove = (comment?: string) => { @@ -149,22 +134,26 @@ export function PolicyOverview({ )} {policy?.isArchived && ( - -
- -
{'This policy is archived'}
+ +
+ +
+
This policy is archived
+ + Archived on {format(new Date(policy?.updatedAt ?? new Date()), 'PPP')} + +
+
+
+
- - {policy?.isArchived && ( - <> - {'Archived on'} {format(new Date(policy?.updatedAt ?? new Date()), 'PPP')} - - )} - -
)} @@ -176,55 +165,8 @@ export function PolicyOverview({ {policy?.name}
- - - - - - { - setDropdownOpen(false); - setOpen('true'); - }} - disabled={isPendingApproval} - > - - {'Edit policy'} - - { - setDropdownOpen(false); - setArchiveOpen('true'); - }} - disabled={isPendingApproval} - > - {policy?.isArchived ? ( - - ) : ( - - )} - {policy?.isArchived ? 'Restore policy' : 'Archive policy'} - - { - setDropdownOpen(false); - setDeleteDialogOpen(true); - }} - disabled={isPendingApproval} - className="text-destructive focus:text-destructive" - > - - Delete - - - + {/* Redundant gear removed; actions moved to breadcrumb header */} +
{policy?.description} @@ -276,10 +218,24 @@ export function PolicyOverview({ {/* Delete Dialog */} setDeleteDialogOpen(false)} + isOpen={Boolean(deleteOpenParam)} + onClose={() => setDeleteOpenParam(null)} policy={policy} /> + {/* Regenerate Dialog */} + setRegenerateOpen(false)} + onConfirm={async () => { + if (!policy?.id) return; + await regeneratePolicyAction({ policyId: policy.id }); + toast.info('Regeneration started'); + }} + title="Regenerate Policy" + description="This will regenerate the policy content and mark it for review. Continue?" + confirmText="Regenerate" + confirmIcon={} + /> )} diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/page.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/page.tsx index ca94a8805..26533ef5c 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/page.tsx @@ -1,5 +1,6 @@ import PageWithBreadcrumb from '@/components/pages/PageWithBreadcrumb'; import type { Metadata } from 'next'; +import { PolicyHeaderActions } from './components/PolicyHeaderActions'; import PolicyPage from './components/PolicyPage'; import { getAssignees, getLogsForPolicy, getPolicy, getPolicyControlMappingInfo } from './data'; @@ -23,6 +24,7 @@ export default async function PolicyDetails({ { label: 'Policies', href: `/${orgId}/policies/all` }, { label: policy?.name ?? 'Policy', current: true }, ]} + headerRight={} > { - logger.info(`Generating prompt for policy ${policy.name}`); + logger.info(`Generating prompt for policy ${policyTemplate.name}`); logger.info(`Company Name: ${companyName}`); logger.info(`Company Website: ${companyWebsite}`); logger.info(`Context: ${contextHub}`); - logger.info(`Existing Policy Content: ${JSON.stringify(existingPolicyContent)}`); + logger.info(`Existing Policy Content: ${JSON.stringify(policyTemplate.content)}`); logger.info( `Frameworks: ${JSON.stringify( frameworks.map((f) => ({ id: f.id, name: f.name, version: f.version })), @@ -81,6 +78,6 @@ Required rules (keep this simple): Output: Return ONLY the final TipTap JSON document. Template (TipTap JSON) to edit: -${JSON.stringify(existingPolicyContent)} +${JSON.stringify(policyTemplate.content)} `; }; diff --git a/apps/app/src/jobs/tasks/onboarding/onboard-organization-helpers.ts b/apps/app/src/jobs/tasks/onboarding/onboard-organization-helpers.ts index cdc9c0ffa..cd93d05c2 100644 --- a/apps/app/src/jobs/tasks/onboarding/onboard-organization-helpers.ts +++ b/apps/app/src/jobs/tasks/onboarding/onboard-organization-helpers.ts @@ -18,7 +18,7 @@ import z from 'zod'; import type { researchVendor } from '../scrape/research'; import { RISK_MITIGATION_PROMPT } from './prompts/risk-mitigation'; import { VENDOR_RISK_ASSESSMENT_PROMPT } from './prompts/vendor-risk-assessment'; -import { updatePolicies } from './update-policies'; +import { updatePolicy } from './update-policy'; // Types export type ContextItem = { @@ -431,7 +431,7 @@ export async function triggerPolicyUpdates( const policies = await getOrganizationPolicies(organizationId); if (policies.length > 0) { - await updatePolicies.batchTriggerAndWait( + await updatePolicy.batchTriggerAndWait( policies.map((policy) => ({ payload: { organizationId, diff --git a/apps/app/src/jobs/tasks/onboarding/update-policies-helpers.ts b/apps/app/src/jobs/tasks/onboarding/update-policies-helpers.ts index 89f01f3b9..b89896a87 100644 --- a/apps/app/src/jobs/tasks/onboarding/update-policies-helpers.ts +++ b/apps/app/src/jobs/tasks/onboarding/update-policies-helpers.ts @@ -1,5 +1,5 @@ import { openai } from '@ai-sdk/openai'; -import { db, FrameworkEditorFramework, type Policy } from '@db'; +import { db, FrameworkEditorFramework, FrameworkEditorPolicyTemplate, type Policy } from '@db'; import type { JSONContent } from '@tiptap/react'; import { logger } from '@trigger.dev/sdk'; import { generateObject, NoObjectGeneratedError } from 'ai'; @@ -379,7 +379,7 @@ export type UpdatePolicyParams = { export type PolicyUpdateResult = { policyId: string; contextHub: string; - policy: Policy; + policyName: string; updatedContent: { type: 'document'; content: Record[]; @@ -392,7 +392,11 @@ export type PolicyUpdateResult = { export async function fetchOrganizationAndPolicy( organizationId: string, policyId: string, -): Promise<{ organization: OrganizationData; policy: Policy }> { +): Promise<{ + organization: OrganizationData; + policy: Policy; + policyTemplate: FrameworkEditorPolicyTemplate; +}> { const [organization, policy] = await Promise.all([ db.organization.findUnique({ where: { id: organizationId }, @@ -411,22 +415,33 @@ export async function fetchOrganizationAndPolicy( throw new Error(`Policy not found for ${policyId}`); } - return { organization, policy }; + if (!policy.policyTemplateId) { + throw new Error(`Policy template not found for ${policyId}`); + } + + const policyTemplate = await db.frameworkEditorPolicyTemplate.findUnique({ + where: { id: policy.policyTemplateId }, + }); + + if (!policyTemplate) { + throw new Error(`Policy template not found for ${policy.policyTemplateId}`); + } + + return { organization, policy, policyTemplate }; } /** * Generates the prompt for policy content generation */ export async function generatePolicyPrompt( - policy: Policy, + policyTemplate: FrameworkEditorPolicyTemplate, contextHub: string, organization: OrganizationData, frameworks: FrameworkEditorFramework[], ): Promise { return generatePrompt({ - existingPolicyContent: (policy.content as unknown as JSONContent | JSONContent[]) ?? [], contextHub, - policy, + policyTemplate, companyName: organization.name ?? 'Company', companyWebsite: organization.website ?? 'https://company.com', frameworks, @@ -512,35 +527,24 @@ export async function processPolicyUpdate(params: UpdatePolicyParams): Promise

[], - // ); - - // QA AI temporarily disabled: use deterministic alignment result directly - // const originalNodes = originalTipTap as unknown as Record[]; - // const current = aligned; - // Update policy in database await updatePolicyInDatabase(policyId, updatedContent.content); return { policyId, contextHub, - policy, updatedContent, + policyName: policyTemplate.name, }; } diff --git a/apps/app/src/jobs/tasks/onboarding/update-policies.ts b/apps/app/src/jobs/tasks/onboarding/update-policy.ts similarity index 86% rename from apps/app/src/jobs/tasks/onboarding/update-policies.ts rename to apps/app/src/jobs/tasks/onboarding/update-policy.ts index cb1fdd42c..96d13c9b3 100644 --- a/apps/app/src/jobs/tasks/onboarding/update-policies.ts +++ b/apps/app/src/jobs/tasks/onboarding/update-policy.ts @@ -7,12 +7,12 @@ if (!process.env.OPENAI_API_KEY) { } // v4: define queue ahead of time -export const updatePoliciesQueue = queue({ name: 'update-policies', concurrencyLimit: 5 }); +export const updatePolicyQueue = queue({ name: 'update-policy', concurrencyLimit: 5 }); -export const updatePolicies = schemaTask({ - id: 'update-policies', +export const updatePolicy = schemaTask({ + id: 'update-policy', maxDuration: 600, // 10 minutes. - queue: updatePoliciesQueue, + queue: updatePolicyQueue, schema: z.object({ organizationId: z.string(), policyId: z.string(),