Skip to content

Commit e16542d

Browse files
committed
feat(api): enhance vendor risk assessment task with status handling and skeleton UI
1 parent 154ca39 commit e16542d

File tree

8 files changed

+185
-39
lines changed

8 files changed

+185
-39
lines changed

apps/api/src/trigger/vendor/vendor-risk-assessment-task.ts

Lines changed: 54 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,11 @@ export const vendorRiskAssessmentTask = schemaTask({
8989
entityId: payload.vendorId,
9090
title: VENDOR_RISK_ASSESSMENT_TASK_TITLE,
9191
},
92-
select: { id: true },
92+
select: { id: true, status: true, createdById: true, assigneeId: true },
9393
});
9494

95-
if (existing) {
95+
// If an existing task is already complete (i.e. not "generating"), don't create another one.
96+
if (existing && existing.status !== TaskItemStatus.in_progress) {
9697
logger.info('Risk assessment task already exists for vendor, skipping', {
9798
vendorId: payload.vendorId,
9899
taskItemId: existing.id,
@@ -104,6 +105,49 @@ export const vendorRiskAssessmentTask = schemaTask({
104105
organizationId: payload.organizationId,
105106
createdByUserId: payload.createdByUserId ?? null,
106107
});
108+
// focused frameworks
109+
const organizationFrameworks = getDefaultFrameworks();
110+
const frameworkChecklist = buildFrameworkChecklist(organizationFrameworks);
111+
112+
// Create a placeholder task immediately so UI can show a skeleton while research runs.
113+
// If an in-progress placeholder already exists, reuse it.
114+
const taskItemId =
115+
existing?.id ??
116+
(
117+
await db.taskItem.create({
118+
data: {
119+
title: VENDOR_RISK_ASSESSMENT_TASK_TITLE,
120+
// Keep a structured marker so frontend can reliably detect this task type,
121+
// but keep status=in_progress so it renders as "generating".
122+
description: buildRiskAssessmentDescription({
123+
vendorName: payload.vendorName,
124+
vendorWebsite: payload.vendorWebsite ?? null,
125+
research: null,
126+
frameworkChecklist,
127+
organizationFrameworks,
128+
}),
129+
status: TaskItemStatus.in_progress,
130+
priority: TaskItemPriority.high,
131+
entityId: payload.vendorId,
132+
entityType: 'vendor',
133+
organizationId: payload.organizationId,
134+
assigneeId: assigneeMemberId,
135+
createdById: creatorMemberId,
136+
},
137+
select: { id: true },
138+
})
139+
).id;
140+
141+
if (!existing) {
142+
await logAutomatedTaskCreation({
143+
organizationId: payload.organizationId,
144+
taskItemId,
145+
taskTitle: VENDOR_RISK_ASSESSMENT_TASK_TITLE,
146+
memberId: creatorMemberId,
147+
entityType: 'vendor',
148+
entityId: payload.vendorId,
149+
});
150+
}
107151

108152
const research =
109153
payload.withResearch && payload.vendorWebsite
@@ -113,9 +157,6 @@ export const vendorRiskAssessmentTask = schemaTask({
113157
})
114158
: null;
115159

116-
const organizationFrameworks = getDefaultFrameworks();
117-
const frameworkChecklist = buildFrameworkChecklist(organizationFrameworks);
118-
119160
const description = buildRiskAssessmentDescription({
120161
vendorName: payload.vendorName,
121162
vendorWebsite: payload.vendorWebsite ?? null,
@@ -124,37 +165,26 @@ export const vendorRiskAssessmentTask = schemaTask({
124165
organizationFrameworks,
125166
});
126167

127-
const taskItem = await db.taskItem.create({
168+
// Mark as ready for normal UX: clickable + full renderer
169+
await db.taskItem.update({
170+
where: { id: taskItemId },
128171
data: {
129-
title: VENDOR_RISK_ASSESSMENT_TASK_TITLE,
130172
description,
131173
status: TaskItemStatus.todo,
132-
priority: TaskItemPriority.high,
133-
entityId: payload.vendorId,
134-
entityType: 'vendor',
135-
organizationId: payload.organizationId,
136-
assigneeId: assigneeMemberId,
137-
createdById: creatorMemberId,
174+
// Keep stable creator/assignee for reused placeholders
175+
assigneeId: existing?.assigneeId ?? assigneeMemberId,
176+
updatedById: existing?.createdById ?? creatorMemberId,
138177
},
139178
select: { id: true },
140179
});
141180

142-
await logAutomatedTaskCreation({
143-
organizationId: payload.organizationId,
144-
taskItemId: taskItem.id,
145-
taskTitle: VENDOR_RISK_ASSESSMENT_TASK_TITLE,
146-
memberId: creatorMemberId,
147-
entityType: 'vendor',
148-
entityId: payload.vendorId,
149-
});
150-
151181
logger.info('Created vendor risk assessment task item', {
152182
vendorId: payload.vendorId,
153-
taskItemId: taskItem.id,
183+
taskItemId,
154184
researched: Boolean(research),
155185
});
156186

157-
return { success: true, taskItemId: taskItem.id, deduped: false, researched: Boolean(research) };
187+
return { success: true, taskItemId, deduped: Boolean(existing), researched: Boolean(research) };
158188
},
159189
});
160190

apps/app/src/components/task-items/TaskItemItem.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ import { useMemo, useState } from 'react';
6161
import { toast } from 'sonner';
6262
import { SelectAssignee } from '@/components/SelectAssignee';
6363
import { format } from 'date-fns';
64+
import { isVendorRiskAssessmentTaskItem } from './generated-task/vendor-risk-assessment/is-vendor-risk-assessment-task-item';
65+
import { VendorRiskAssessmentTaskItemSkeletonRow } from './generated-task/vendor-risk-assessment/VendorRiskAssessmentTaskItemSkeletonRow';
6466

6567
const formatShortDate = (date: string | Date): string => {
6668
try {
@@ -122,6 +124,12 @@ export function TaskItemItem({
122124
onToggleExpanded,
123125
onStatusOrPriorityChange,
124126
}: TaskItemItemProps) {
127+
const isGeneratingVendorRiskAssessment =
128+
taskItem.status === 'in_progress' && isVendorRiskAssessmentTaskItem(taskItem);
129+
if (isGeneratingVendorRiskAssessment) {
130+
return <VendorRiskAssessmentTaskItemSkeletonRow />;
131+
}
132+
125133
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
126134
const [editedTitle, setEditedTitle] = useState(taskItem.title);
127135
const [editedDescription, setEditedDescription] = useState(taskItem.description || '');

apps/app/src/components/task-items/TaskItems.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { TaskItemsBody } from './TaskItemsBody';
1313
import { useOrganizationMembers } from '@/hooks/use-organization-members';
1414
import { filterMembersByOwnerOrAdmin } from '@/utils/filter-members-by-role';
1515
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
16+
import { isVendorRiskAssessmentTaskItem } from './generated-task/vendor-risk-assessment/is-vendor-risk-assessment-task-item';
1617

1718
interface TaskItemsProps {
1819
entityId: string;
@@ -66,6 +67,26 @@ export const TaskItems = ({
6667
organizationId,
6768
});
6869

70+
// Check if any tasks are currently generating (in_progress vendor risk assessments)
71+
// If yes, enable polling so skeleton updates automatically when generation completes
72+
const hasGeneratingTasks = useMemo(() => {
73+
const taskItems = taskItemsResponse?.data?.data ?? [];
74+
return taskItems.some(
75+
(taskItem) =>
76+
taskItem.status === 'in_progress' && isVendorRiskAssessmentTaskItem(taskItem),
77+
);
78+
}, [taskItemsResponse?.data?.data]);
79+
80+
// Re-fetch with polling enabled if there are generating tasks
81+
useEffect(() => {
82+
if (hasGeneratingTasks) {
83+
const interval = setInterval(() => {
84+
refreshTaskItems();
85+
}, 3000); // Poll every 3 seconds
86+
return () => clearInterval(interval);
87+
}
88+
}, [hasGeneratingTasks, refreshTaskItems]);
89+
6990
const {
7091
data: statsResponse,
7192
isLoading: statsLoading,

apps/app/src/components/task-items/generated-task/vendor-risk-assessment/VendorRiskAssessmentCertificationsCard.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,14 +109,14 @@ export function VendorRiskAssessmentCertificationsCard({
109109
) : (
110110
<Collapsible open={open} onOpenChange={setOpen}>
111111
<div className="space-y-3">
112-
{preview.map((cert) => (
113-
<CertificationRow key={`${cert.type}-${cert.url ?? ''}`} cert={cert} />
112+
{preview.map((cert, index) => (
113+
<CertificationRow key={`${cert.type}-${cert.status}-${index}`} cert={cert} />
114114
))}
115115

116116
{rest.length > 0 ? (
117117
<CollapsibleContent className="space-y-3">
118-
{rest.map((cert) => (
119-
<CertificationRow key={`${cert.type}-${cert.url ?? ''}`} cert={cert} />
118+
{rest.map((cert, index) => (
119+
<CertificationRow key={`${cert.type}-${cert.status}-${previewCount + index}`} cert={cert} />
120120
))}
121121
</CollapsibleContent>
122122
) : null}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
'use client';
2+
3+
import { Skeleton } from '@comp/ui/skeleton';
4+
import { Loader2, TrendingUp, Circle } from 'lucide-react';
5+
6+
/**
7+
* Skeleton row shown while the system is generating the vendor Risk Assessment task.
8+
*
9+
* Intentionally non-interactive: users shouldn't open or edit the task until the
10+
* background job finishes and populates the structured description.
11+
*
12+
* Layout mirrors the actual TaskItemItem row for visual consistency.
13+
*/
14+
export function VendorRiskAssessmentTaskItemSkeletonRow() {
15+
return (
16+
<div className="flex items-center gap-2 p-4 rounded-lg border border-border bg-card opacity-60">
17+
<div className="flex-1 flex items-center gap-3 text-sm w-full">
18+
<div className="flex items-center gap-3 flex-1 min-w-0">
19+
{/* Priority Icon - Fixed width (matches real row) */}
20+
<div className="w-8 shrink-0 flex items-center justify-center">
21+
<div className="h-6 px-1.5 rounded-md flex items-center justify-center bg-transparent text-pink-600 dark:text-pink-400">
22+
<TrendingUp className="h-4 w-4 stroke-[2]" />
23+
</div>
24+
</div>
25+
26+
{/* Task ID - Fixed width (matches real row) */}
27+
<div className="w-14 shrink-0">
28+
<Skeleton className="h-3 w-12" />
29+
</div>
30+
31+
{/* Status Icon - Fixed width (matches real row) */}
32+
<div className="w-8 shrink-0 flex items-center justify-center">
33+
<Loader2 className="h-4 w-4 animate-spin text-blue-600 dark:text-blue-400" />
34+
</div>
35+
36+
{/* Title - Flexible with max width (matches real row) */}
37+
<div className="flex items-center gap-2 flex-1 min-w-0 max-w-[300px]">
38+
<h4 className="text-sm font-medium truncate">Risk Assessment</h4>
39+
</div>
40+
41+
{/* Spacer */}
42+
<div className="flex-1" />
43+
44+
{/* Assignee - Fixed width (matches real row) */}
45+
<div className="shrink-0 w-[180px]">
46+
<Skeleton className="h-6 w-full" />
47+
</div>
48+
49+
{/* Date - Fixed width (matches real row) */}
50+
<div className="w-16 shrink-0 text-right">
51+
<Skeleton className="h-4 w-14 ml-auto" />
52+
</div>
53+
54+
{/* Options Menu Placeholder - matches real row */}
55+
<div className="h-6 w-6 shrink-0" />
56+
</div>
57+
</div>
58+
</div>
59+
);
60+
}
61+
62+

apps/app/src/components/task-items/generated-task/vendor-risk-assessment/VendorRiskAssessmentTaskItemView.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -261,11 +261,11 @@ export function VendorRiskAssessmentTaskItemView({ taskItem }: { taskItem: TaskI
261261
{links.length === 0 ? (
262262
<p className="text-sm text-muted-foreground italic">No links found.</p>
263263
) : (
264-
links.map((link) => {
264+
links.map((link, index) => {
265265
const LinkIcon = getLinkIcon(link.label);
266266
return (
267267
<Button
268-
key={link.url}
268+
key={`${link.url}-${link.label}-${index}`}
269269
variant="outline"
270270
className="w-full justify-between"
271271
onClick={() => window.open(link.url, '_blank', 'noopener,noreferrer')}

apps/app/src/components/task-items/generated-task/vendor-risk-assessment/VendorRiskAssessmentTimelineCard.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,17 +75,17 @@ export function VendorRiskAssessmentTimelineCard({
7575
) : (
7676
<Collapsible open={open} onOpenChange={setOpen}>
7777
<div className="space-y-5">
78-
{preview.map((item) => (
79-
<div key={`${item.date}-${item.title}`} className="space-y-2">
78+
{preview.map((item, index) => (
79+
<div key={`${item.date}-${item.title}-${index}`} className="space-y-2">
8080
<NewsRow item={item} />
8181
<Separator />
8282
</div>
8383
))}
8484

8585
{rest.length > 0 ? (
8686
<CollapsibleContent className="space-y-5">
87-
{rest.map((item) => (
88-
<div key={`${item.date}-${item.title}`} className="space-y-2">
87+
{rest.map((item, index) => (
88+
<div key={`${item.date}-${item.title}-${previewCount + index}`} className="space-y-2">
8989
<NewsRow item={item} />
9090
<Separator />
9191
</div>

apps/app/src/trigger/tasks/onboarding/onboard-organization-helpers.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,27 @@ async function triggerVendorRiskAssessmentsViaApi(params: {
386386
process.env.NEXT_PUBLIC_API_URL || process.env.API_BASE_URL || 'http://localhost:3333';
387387
const token = process.env.INTERNAL_API_TOKEN;
388388

389+
// Sanitize vendor websites - only send valid URLs or null
390+
const sanitizeWebsite = (
391+
website: string | null | undefined,
392+
vendorName: string,
393+
): string | null => {
394+
if (!website || website.trim() === '') return null;
395+
396+
const trimmed = website.trim();
397+
// If it doesn't have a protocol, try adding https://
398+
const withProtocol = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
399+
400+
try {
401+
const url = new URL(withProtocol);
402+
return url.toString();
403+
} catch {
404+
// Invalid URL, return null
405+
logger.warn('Invalid vendor website, will skip research', { website, vendorName });
406+
return null;
407+
}
408+
};
409+
389410
logger.info('Calling vendor risk assessment API endpoint', {
390411
organizationId,
391412
vendorCount: vendors.length,
@@ -400,11 +421,15 @@ async function triggerVendorRiskAssessmentsViaApi(params: {
400421
{
401422
organizationId,
402423
withResearch,
403-
vendors: vendors.map((v) => ({
404-
vendorId: v.id,
405-
vendorName: v.name,
406-
vendorWebsite: v.website,
407-
})),
424+
vendors: vendors.map((v) => {
425+
const sanitized = sanitizeWebsite(v.website, v.name);
426+
return {
427+
vendorId: v.id,
428+
vendorName: v.name,
429+
// Only include vendorWebsite if it's a valid URL (undefined triggers @IsOptional)
430+
...(sanitized && { vendorWebsite: sanitized }),
431+
};
432+
}),
408433
},
409434
{
410435
headers: token ? { 'X-Internal-Token': token } : undefined,

0 commit comments

Comments
 (0)