Skip to content

Commit d3f424e

Browse files
[dev] [tofikwest] tofik/swr-vendor-risk-mitigations (#1978)
* feat(risk, vendor): implement RiskPageClient and VendorPageClient for real-time updates due using SWR * feat(vendor, risk): enhance real-time updates with SWR and normalize API responses --------- Co-authored-by: Tofik Hasanov <annexcies@gmail.com>
1 parent 412bb92 commit d3f424e

File tree

9 files changed

+856
-61
lines changed

9 files changed

+856
-61
lines changed

apps/api/src/vendors/vendors.service.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,20 @@ export class VendorsService {
8282
id,
8383
organizationId,
8484
},
85+
include: {
86+
assignee: {
87+
include: {
88+
user: {
89+
select: {
90+
id: true,
91+
name: true,
92+
email: true,
93+
image: true,
94+
},
95+
},
96+
},
97+
},
98+
},
8599
});
86100

87101
if (!vendor) {
@@ -90,8 +104,51 @@ export class VendorsService {
90104
);
91105
}
92106

107+
// Fetch risk assessment from GlobalVendors if vendor has a website
108+
const domain = extractDomain(vendor.website);
109+
let globalVendorData: {
110+
website: string;
111+
riskAssessmentData: Prisma.JsonValue;
112+
riskAssessmentVersion: string | null;
113+
riskAssessmentUpdatedAt: Date | null;
114+
} | null = null;
115+
116+
if (domain) {
117+
const duplicates = await db.globalVendors.findMany({
118+
where: {
119+
website: {
120+
contains: domain,
121+
},
122+
},
123+
select: {
124+
website: true,
125+
riskAssessmentData: true,
126+
riskAssessmentVersion: true,
127+
riskAssessmentUpdatedAt: true,
128+
},
129+
orderBy: [
130+
{ riskAssessmentUpdatedAt: 'desc' },
131+
{ createdAt: 'desc' },
132+
],
133+
});
134+
135+
// Prefer record WITH risk assessment data (most recent)
136+
globalVendorData =
137+
duplicates.find((gv) => gv.riskAssessmentData !== null) ??
138+
duplicates[0] ??
139+
null;
140+
}
141+
142+
// Merge GlobalVendors risk assessment data into response
143+
const vendorWithRiskAssessment = {
144+
...vendor,
145+
riskAssessmentData: globalVendorData?.riskAssessmentData ?? null,
146+
riskAssessmentVersion: globalVendorData?.riskAssessmentVersion ?? null,
147+
riskAssessmentUpdatedAt: globalVendorData?.riskAssessmentUpdatedAt ?? null,
148+
};
149+
93150
this.logger.log(`Retrieved vendor: ${vendor.name} (${id})`);
94-
return vendor;
151+
return vendorWithRiskAssessment;
95152
} catch (error) {
96153
if (error instanceof NotFoundException) {
97154
throw error;

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client';
22

33
import { regenerateRiskMitigationAction } from '@/app/(app)/[orgId]/risk/[riskId]/actions/regenerate-risk-mitigation';
4+
import { useRisk } from '@/hooks/use-risks';
45
import { Button } from '@comp/ui/button';
56
import {
67
Dialog,
@@ -23,8 +24,16 @@ import { toast } from 'sonner';
2324

2425
export function RiskActions({ riskId }: { riskId: string }) {
2526
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
27+
28+
// Get SWR mutate function to refresh risk data after mutations
29+
const { mutate: refreshRisk } = useRisk(riskId);
30+
2631
const regenerate = useAction(regenerateRiskMitigationAction, {
27-
onSuccess: () => toast.success('Regeneration triggered. This may take a moment.'),
32+
onSuccess: () => {
33+
toast.success('Regeneration triggered. This may take a moment.');
34+
// Trigger SWR revalidation to refresh risk data
35+
refreshRisk();
36+
},
2837
onError: () => toast.error('Failed to trigger mitigation regeneration'),
2938
});
3039

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
'use client';
2+
3+
import { Comments } from '@/components/comments/Comments';
4+
import { InherentRiskChart } from '@/components/risks/charts/InherentRiskChart';
5+
import { ResidualRiskChart } from '@/components/risks/charts/ResidualRiskChart';
6+
import { RiskOverview } from '@/components/risks/risk-overview';
7+
import { TaskItems } from '@/components/task-items/TaskItems';
8+
import { useRisk, type RiskResponse } from '@/hooks/use-risks';
9+
import { CommentEntityType } from '@db';
10+
import type { Member, Risk, User } from '@db';
11+
import { useMemo } from 'react';
12+
13+
type RiskWithAssignee = Risk & {
14+
assignee: { user: User } | null;
15+
};
16+
17+
/**
18+
* Normalize API response to match Prisma types
19+
* API returns dates as strings, Prisma returns Date objects
20+
*/
21+
function normalizeRisk(apiRisk: RiskResponse): RiskWithAssignee {
22+
return {
23+
...apiRisk,
24+
createdAt: new Date(apiRisk.createdAt),
25+
updatedAt: new Date(apiRisk.updatedAt),
26+
assignee: apiRisk.assignee
27+
? {
28+
...apiRisk.assignee,
29+
user: apiRisk.assignee.user as User,
30+
}
31+
: null,
32+
} as unknown as RiskWithAssignee;
33+
}
34+
35+
interface RiskPageClientProps {
36+
riskId: string;
37+
orgId: string;
38+
initialRisk: RiskWithAssignee;
39+
assignees: (Member & { user: User })[];
40+
isViewingTask: boolean;
41+
}
42+
43+
/**
44+
* Client component for risk detail page content
45+
* Uses SWR for real-time updates and caching
46+
*
47+
* Benefits:
48+
* - Instant initial render (uses server-fetched data)
49+
* - Real-time updates via polling (5s interval)
50+
* - Mutations trigger automatic refresh via mutate()
51+
*/
52+
export function RiskPageClient({
53+
riskId,
54+
orgId,
55+
initialRisk,
56+
assignees,
57+
isViewingTask,
58+
}: RiskPageClientProps) {
59+
// Use SWR for real-time updates with polling
60+
const { risk: swrRisk, isLoading } = useRisk(riskId, {
61+
organizationId: orgId,
62+
});
63+
64+
// Normalize and memoize the risk data
65+
// Use SWR data when available, fall back to initial data
66+
const risk = useMemo(() => {
67+
if (swrRisk) {
68+
return normalizeRisk(swrRisk);
69+
}
70+
return initialRisk;
71+
}, [swrRisk, initialRisk]);
72+
73+
return (
74+
<div className="flex flex-col gap-4">
75+
{!isViewingTask && (
76+
<>
77+
<RiskOverview risk={risk} assignees={assignees} />
78+
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
79+
<InherentRiskChart risk={risk} />
80+
<ResidualRiskChart risk={risk} />
81+
</div>
82+
</>
83+
)}
84+
<TaskItems entityId={riskId} entityType="risk" organizationId={orgId} />
85+
{!isViewingTask && (
86+
<Comments entityId={riskId} entityType={CommentEntityType.risk} />
87+
)}
88+
</div>
89+
);
90+
}
91+
92+
/**
93+
* Export the risk mutate function for use by mutation components
94+
* Call this after updating risk data to trigger SWR revalidation
95+
*/
96+
export { useRisk } from '@/hooks/use-risks';
97+

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

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,12 @@
11
import PageWithBreadcrumb from '@/components/pages/PageWithBreadcrumb';
2-
import { InherentRiskChart } from '@/components/risks/charts/InherentRiskChart';
3-
import { ResidualRiskChart } from '@/components/risks/charts/ResidualRiskChart';
4-
import { RiskOverview } from '@/components/risks/risk-overview';
52
import { auth } from '@/utils/auth';
6-
import { CommentEntityType, db } from '@db';
3+
import { db } from '@db';
74
import type { Metadata } from 'next';
85
import { headers } from 'next/headers';
96
import { redirect } from 'next/navigation';
107
import { cache } from 'react';
11-
import { Comments } from '../../../../../components/comments/Comments';
12-
import { TaskItems } from '../../../../../components/task-items/TaskItems';
138
import { RiskActions } from './components/RiskActions';
9+
import { RiskPageClient } from './components/RiskPageClient';
1410

1511
interface PageProps {
1612
searchParams: Promise<{
@@ -24,16 +20,23 @@ interface PageProps {
2420
params: Promise<{ riskId: string; orgId: string }>;
2521
}
2622

23+
/**
24+
* Risk detail page - server component
25+
* Fetches initial data server-side for fast first render
26+
* Passes data to RiskPageClient which uses SWR for real-time updates
27+
*/
2728
export default async function RiskPage({ searchParams, params }: PageProps) {
2829
const { riskId, orgId } = await params;
2930
const { taskItemId } = await searchParams;
3031
const risk = await getRisk(riskId);
3132
const assignees = await getAssignees();
33+
3234
if (!risk) {
3335
redirect('/');
3436
}
3537

3638
const shortTaskId = (id: string) => id.slice(-6).toUpperCase();
39+
const isViewingTask = Boolean(taskItemId);
3740

3841
return (
3942
<PageWithBreadcrumb
@@ -48,21 +51,13 @@ export default async function RiskPage({ searchParams, params }: PageProps) {
4851
]}
4952
headerRight={<RiskActions riskId={riskId} />}
5053
>
51-
<div className="flex flex-col gap-4">
52-
{!taskItemId && (
53-
<>
54-
<RiskOverview risk={risk} assignees={assignees} />
55-
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
56-
<InherentRiskChart risk={risk} />
57-
<ResidualRiskChart risk={risk} />
58-
</div>
59-
</>
60-
)}
61-
<TaskItems entityId={riskId} entityType="risk" organizationId={orgId} />
62-
{!taskItemId && (
63-
<Comments entityId={riskId} entityType={CommentEntityType.risk} />
64-
)}
65-
</div>
54+
<RiskPageClient
55+
riskId={riskId}
56+
orgId={orgId}
57+
initialRisk={risk}
58+
assignees={assignees}
59+
isViewingTask={isViewingTask}
60+
/>
6661
</PageWithBreadcrumb>
6762
);
6863
}

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client';
22

33
import { regenerateVendorMitigationAction } from '@/app/(app)/[orgId]/vendors/[vendorId]/actions/regenerate-vendor-mitigation';
4+
import { useVendor } from '@/hooks/use-vendors';
45
import { Button } from '@comp/ui/button';
56
import {
67
Dialog,
@@ -25,8 +26,16 @@ import { toast } from 'sonner';
2526
export function VendorActions({ vendorId }: { vendorId: string }) {
2627
const [_, setOpen] = useQueryState('vendor-overview-sheet');
2728
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
29+
30+
// Get SWR mutate function to refresh vendor data after mutations
31+
const { mutate: refreshVendor } = useVendor(vendorId);
32+
2833
const regenerate = useAction(regenerateVendorMitigationAction, {
29-
onSuccess: () => toast.success('Regeneration triggered. This may take a moment.'),
34+
onSuccess: () => {
35+
toast.success('Regeneration triggered. This may take a moment.');
36+
// Trigger SWR revalidation to refresh vendor data
37+
refreshVendor();
38+
},
3039
onError: () => toast.error('Failed to trigger mitigation regeneration'),
3140
});
3241

0 commit comments

Comments
 (0)