Skip to content

Commit 02cda79

Browse files
authored
Merge pull request #1340 from trycompai/mariano/risk-mitigation
[dev] [Marfuen] mariano/risk-mitigation
2 parents 769b70d + a94ecd7 commit 02cda79

File tree

15 files changed

+821
-398
lines changed

15 files changed

+821
-398
lines changed
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
'use server';
2+
3+
import { authActionClient } from '@/actions/safe-action';
4+
import { generateRiskMitigation } from '@/jobs/tasks/onboarding/generate-risk-mitigation';
5+
import { tasks } from '@trigger.dev/sdk';
6+
import { z } from 'zod';
7+
8+
export const regenerateRiskMitigationAction = authActionClient
9+
.inputSchema(
10+
z.object({
11+
riskId: z.string().min(1),
12+
}),
13+
)
14+
.metadata({
15+
name: 'regenerate-risk-mitigation',
16+
track: {
17+
event: 'regenerate-risk-mitigation',
18+
channel: 'server',
19+
},
20+
})
21+
.action(async ({ parsedInput, ctx }) => {
22+
const { riskId } = parsedInput;
23+
const { session } = ctx;
24+
25+
if (!session?.activeOrganizationId) {
26+
throw new Error('No active organization');
27+
}
28+
29+
await tasks.trigger<typeof generateRiskMitigation>('generate-risk-mitigation', {
30+
organizationId: session.activeOrganizationId,
31+
riskId,
32+
});
33+
34+
return { success: true };
35+
});
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
'use client';
2+
3+
import { regenerateRiskMitigationAction } from '@/app/(app)/[orgId]/risk/[riskId]/actions/regenerate-risk-mitigation';
4+
import { Button } from '@comp/ui/button';
5+
import {
6+
Dialog,
7+
DialogContent,
8+
DialogDescription,
9+
DialogFooter,
10+
DialogHeader,
11+
DialogTitle,
12+
} from '@comp/ui/dialog';
13+
import {
14+
DropdownMenu,
15+
DropdownMenuContent,
16+
DropdownMenuItem,
17+
DropdownMenuTrigger,
18+
} from '@comp/ui/dropdown-menu';
19+
import { Cog } from 'lucide-react';
20+
import { useAction } from 'next-safe-action/hooks';
21+
import { useState } from 'react';
22+
import { toast } from 'sonner';
23+
24+
export function RiskActions({ riskId }: { riskId: string }) {
25+
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
26+
const regenerate = useAction(regenerateRiskMitigationAction, {
27+
onSuccess: () => toast.success('Regeneration triggered. This may take a moment.'),
28+
onError: () => toast.error('Failed to trigger mitigation regeneration'),
29+
});
30+
31+
const handleConfirm = () => {
32+
setIsConfirmOpen(false);
33+
toast.info('Regenerating risk mitigation...');
34+
regenerate.execute({ riskId });
35+
};
36+
37+
return (
38+
<>
39+
<DropdownMenu>
40+
<DropdownMenuTrigger asChild>
41+
<Button variant="ghost" size="icon" aria-label="Risk actions">
42+
<Cog className="h-4 w-4" />
43+
</Button>
44+
</DropdownMenuTrigger>
45+
<DropdownMenuContent align="end">
46+
<DropdownMenuItem onClick={() => setIsConfirmOpen(true)}>
47+
Regenerate Risk Mitigation
48+
</DropdownMenuItem>
49+
</DropdownMenuContent>
50+
</DropdownMenu>
51+
52+
<Dialog open={isConfirmOpen} onOpenChange={(open) => !open && setIsConfirmOpen(false)}>
53+
<DialogContent className="sm:max-w-[420px]">
54+
<DialogHeader>
55+
<DialogTitle>Regenerate Mitigation</DialogTitle>
56+
<DialogDescription>
57+
This will generate a fresh mitigation comment for this risk and mark it closed.
58+
Continue?
59+
</DialogDescription>
60+
</DialogHeader>
61+
<DialogFooter className="gap-2">
62+
<Button
63+
variant="outline"
64+
onClick={() => setIsConfirmOpen(false)}
65+
disabled={regenerate.status === 'executing'}
66+
>
67+
Cancel
68+
</Button>
69+
<Button onClick={handleConfirm} disabled={regenerate.status === 'executing'}>
70+
{regenerate.status === 'executing' ? 'Working…' : 'Confirm'}
71+
</Button>
72+
</DialogFooter>
73+
</DialogContent>
74+
</Dialog>
75+
</>
76+
);
77+
}

apps/app/src/app/(app)/[orgId]/risk/[riskId]/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { headers } from 'next/headers';
99
import { redirect } from 'next/navigation';
1010
import { cache } from 'react';
1111
import { Comments } from '../../../../../components/comments/Comments';
12+
import { RiskActions } from './components/RiskActions';
1213

1314
interface PageProps {
1415
searchParams: Promise<{
@@ -35,6 +36,7 @@ export default async function RiskPage({ searchParams, params }: PageProps) {
3536
{ label: 'Risks', href: `/${orgId}/risk` },
3637
{ label: risk.title, current: true },
3738
]}
39+
headerRight={<RiskActions riskId={riskId} />}
3840
>
3941
<div className="flex flex-col gap-4">
4042
<RiskOverview risk={risk} assignees={assignees} />
@@ -74,8 +76,6 @@ const getRisk = cache(async (riskId: string) => {
7476
return risk;
7577
});
7678

77-
78-
7979
const getAssignees = cache(async () => {
8080
const session = await auth.api.getSession({
8181
headers: await headers(),
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
'use server';
2+
3+
import { authActionClient } from '@/actions/safe-action';
4+
import { generateVendorMitigation } from '@/jobs/tasks/onboarding/generate-vendor-mitigation';
5+
import { tasks } from '@trigger.dev/sdk';
6+
import { z } from 'zod';
7+
8+
export const regenerateVendorMitigationAction = authActionClient
9+
.inputSchema(
10+
z.object({
11+
vendorId: z.string().min(1),
12+
}),
13+
)
14+
.metadata({
15+
name: 'regenerate-vendor-mitigation',
16+
track: {
17+
event: 'regenerate-vendor-mitigation',
18+
channel: 'server',
19+
},
20+
})
21+
.action(async ({ parsedInput, ctx }) => {
22+
const { vendorId } = parsedInput;
23+
const { session } = ctx;
24+
25+
if (!session?.activeOrganizationId) {
26+
throw new Error('No active organization');
27+
}
28+
29+
await tasks.trigger<typeof generateVendorMitigation>('generate-vendor-mitigation', {
30+
organizationId: session.activeOrganizationId,
31+
vendorId,
32+
});
33+
34+
return { success: true };
35+
});
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
'use client';
2+
3+
import { regenerateVendorMitigationAction } from '@/app/(app)/[orgId]/vendors/[vendorId]/actions/regenerate-vendor-mitigation';
4+
import { Button } from '@comp/ui/button';
5+
import {
6+
Dialog,
7+
DialogContent,
8+
DialogDescription,
9+
DialogFooter,
10+
DialogHeader,
11+
DialogTitle,
12+
} from '@comp/ui/dialog';
13+
import {
14+
DropdownMenu,
15+
DropdownMenuContent,
16+
DropdownMenuItem,
17+
DropdownMenuTrigger,
18+
} from '@comp/ui/dropdown-menu';
19+
import { Cog } from 'lucide-react';
20+
import { useAction } from 'next-safe-action/hooks';
21+
import { useState } from 'react';
22+
import { toast } from 'sonner';
23+
24+
export function VendorActions({ vendorId }: { vendorId: string }) {
25+
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
26+
const regenerate = useAction(regenerateVendorMitigationAction, {
27+
onSuccess: () => toast.success('Regeneration triggered. This may take a moment.'),
28+
onError: () => toast.error('Failed to trigger mitigation regeneration'),
29+
});
30+
31+
const handleConfirm = () => {
32+
setIsConfirmOpen(false);
33+
toast.info('Regenerating vendor risk mitigation...');
34+
regenerate.execute({ vendorId });
35+
};
36+
37+
return (
38+
<>
39+
<DropdownMenu>
40+
<DropdownMenuTrigger asChild>
41+
<Button variant="ghost" size="icon" aria-label="Vendor actions">
42+
<Cog className="h-4 w-4" />
43+
</Button>
44+
</DropdownMenuTrigger>
45+
<DropdownMenuContent align="end">
46+
<DropdownMenuItem onClick={() => setIsConfirmOpen(true)}>
47+
Regenerate Risk Mitigation
48+
</DropdownMenuItem>
49+
</DropdownMenuContent>
50+
</DropdownMenu>
51+
52+
<Dialog open={isConfirmOpen} onOpenChange={(open) => !open && setIsConfirmOpen(false)}>
53+
<DialogContent className="sm:max-w-[420px]">
54+
<DialogHeader>
55+
<DialogTitle>Regenerate Mitigation</DialogTitle>
56+
<DialogDescription>
57+
This will generate a fresh risk mitigation comment for this vendor and mark it
58+
assessed. Continue?
59+
</DialogDescription>
60+
</DialogHeader>
61+
<DialogFooter className="gap-2">
62+
<Button
63+
variant="outline"
64+
onClick={() => setIsConfirmOpen(false)}
65+
disabled={regenerate.status === 'executing'}
66+
>
67+
Cancel
68+
</Button>
69+
<Button onClick={handleConfirm} disabled={regenerate.status === 'executing'}>
70+
{regenerate.status === 'executing' ? 'Working…' : 'Confirm'}
71+
</Button>
72+
</DialogFooter>
73+
</DialogContent>
74+
</Dialog>
75+
</>
76+
);
77+
}

apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { headers } from 'next/headers';
88
import { redirect } from 'next/navigation';
99
import { cache } from 'react';
1010
import { Comments } from '../../../../../components/comments/Comments';
11+
import { VendorActions } from './components/VendorActions';
1112
import { VendorInherentRiskChart } from './components/VendorInherentRiskChart';
1213
import { VendorResidualRiskChart } from './components/VendorResidualRiskChart';
1314
import { SecondaryFields } from './components/secondary-fields/secondary-fields';
@@ -31,6 +32,7 @@ export default async function VendorPage({ params }: PageProps) {
3132
{ label: 'Vendors', href: `/${orgId}/vendors` },
3233
{ label: vendor.vendor?.name ?? '', current: true },
3334
]}
35+
headerRight={<VendorActions vendorId={vendorId} />}
3436
>
3537
<div className="flex flex-col gap-4">
3638
<SecondaryFields

apps/app/src/components/comments/CommentItem.tsx

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ import { useCommentActions } from '@/hooks/use-comments-api';
55
import { Avatar, AvatarFallback, AvatarImage } from '@comp/ui/avatar';
66
import { Button } from '@comp/ui/button';
77
import { Card, CardContent } from '@comp/ui/card';
8+
import {
9+
Dialog,
10+
DialogContent,
11+
DialogDescription,
12+
DialogFooter,
13+
DialogHeader,
14+
DialogTitle,
15+
} from '@comp/ui/dialog';
816
import {
917
DropdownMenu,
1018
DropdownMenuContent,
@@ -54,6 +62,8 @@ interface CommentItemProps {
5462
export function CommentItem({ comment, refreshComments }: CommentItemProps) {
5563
const [isEditing, setIsEditing] = useState(false);
5664
const [editedContent, setEditedContent] = useState(comment.content);
65+
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
66+
const [isDeleting, setIsDeleting] = useState(false);
5767

5868
// Use API hooks instead of server actions
5969
const { updateComment, deleteComment } = useCommentActions();
@@ -93,17 +103,17 @@ export function CommentItem({ comment, refreshComments }: CommentItemProps) {
93103
};
94104

95105
const handleDeleteComment = async () => {
96-
if (window.confirm('Are you sure you want to delete this comment?')) {
97-
try {
98-
// Use API hook directly instead of server action
99-
await deleteComment(comment.id);
100-
101-
toast.success('Comment deleted successfully.');
102-
refreshComments();
103-
} catch (error) {
104-
toast.error('Failed to delete comment.');
105-
console.error('Delete comment error:', error);
106-
}
106+
setIsDeleting(true);
107+
try {
108+
await deleteComment(comment.id);
109+
toast.success('Comment deleted successfully.');
110+
refreshComments();
111+
setIsDeleteOpen(false);
112+
} catch (error) {
113+
toast.error('Failed to delete comment.');
114+
console.error('Delete comment error:', error);
115+
} finally {
116+
setIsDeleting(false);
107117
}
108118
};
109119

@@ -171,7 +181,7 @@ export function CommentItem({ comment, refreshComments }: CommentItemProps) {
171181
</DropdownMenuItem>
172182
<DropdownMenuItem
173183
className="text-destructive focus:text-destructive focus:bg-destructive/10"
174-
onSelect={handleDeleteComment}
184+
onSelect={() => setIsDeleteOpen(true)}
175185
>
176186
<Trash2 className="mr-2 h-3.5 w-3.5" />
177187
Delete
@@ -229,6 +239,25 @@ export function CommentItem({ comment, refreshComments }: CommentItemProps) {
229239
</div>
230240
</div>
231241
</CardContent>
242+
{/* Delete confirmation dialog */}
243+
<Dialog open={isDeleteOpen} onOpenChange={(open) => !open && setIsDeleteOpen(false)}>
244+
<DialogContent className="sm:max-w-[420px]">
245+
<DialogHeader>
246+
<DialogTitle>Delete Comment</DialogTitle>
247+
<DialogDescription>
248+
Are you sure you want to delete this comment? This cannot be undone.
249+
</DialogDescription>
250+
</DialogHeader>
251+
<DialogFooter className="gap-2">
252+
<Button variant="outline" onClick={() => setIsDeleteOpen(false)} disabled={isDeleting}>
253+
Cancel
254+
</Button>
255+
<Button variant="destructive" onClick={handleDeleteComment} disabled={isDeleting}>
256+
{isDeleting ? 'Deleting…' : 'Delete'}
257+
</Button>
258+
</DialogFooter>
259+
</DialogContent>
260+
</Dialog>
232261
</Card>
233262
);
234263
}

0 commit comments

Comments
 (0)