Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions apps/api/src/trigger/vendor/vendor-risk-assessment-task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,15 @@ export const vendorRiskAssessmentTask: Task<
);
}

// Mark vendor as in-progress immediately so UI can show "generating" state
// This happens at the start before any processing, so the UI updates right away
if (vendor.status !== VendorStatus.in_progress) {
await db.vendor.update({
where: { id: vendor.id },
data: { status: VendorStatus.in_progress },
});
}

if (!vendor.website) {
logger.info('⏭️ SKIP (no website)', { vendor: payload.vendorName });
// Mark vendor as assessed even without website (no risk assessment possible)
Expand Down Expand Up @@ -424,13 +433,7 @@ export const vendorRiskAssessmentTask: Task<
};
}

// Mark vendor as in-progress immediately so UI can show "generating"
await db.vendor.update({
where: { id: vendor.id },
data: {
status: VendorStatus.in_progress,
},
});
// Note: status is already set to in_progress at the start of the task

const { creatorMemberId, assigneeMemberId } =
await resolveTaskCreatorAndAssignee({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
'use client';

import { VendorRiskAssessmentView } from '@/components/vendor-risk-assessment/VendorRiskAssessmentView';
import { useTaskItems } from '@/hooks/use-task-items';
import { useVendor, type VendorResponse } from '@/hooks/use-vendors';
import { useEffect, useMemo } from 'react';

interface VendorReviewClientProps {
vendorId: string;
orgId: string;
initialVendor: VendorResponse;
}

/**
* Client component for vendor risk assessment review
* Uses SWR with polling to auto-refresh when risk assessment completes
*/
export function VendorReviewClient({
vendorId,
orgId,
initialVendor,
}: VendorReviewClientProps) {
// Use SWR for real-time updates with polling (5s default)
const { vendor: swrVendor } = useVendor(vendorId, {
organizationId: orgId,
initialData: initialVendor,
});

const {
data: taskItemsResponse,
mutate: refreshTaskItems,
} = useTaskItems(
vendorId,
'vendor',
1,
50,
'createdAt',
'desc',
{},
{
organizationId: orgId,
// Avoid always-on polling; we only poll aggressively while generating
refreshInterval: 0,
revalidateOnFocus: true,
},
);

// Use SWR data when available, fall back to initial data
const vendor = useMemo(() => {
return swrVendor ?? initialVendor;
}, [swrVendor, initialVendor]);

const riskAssessmentData = vendor.riskAssessmentData;
const riskAssessmentUpdatedAt = vendor.riskAssessmentUpdatedAt ?? null;

// Mirror the Tasks section behavior:
// If the "Verify risk assessment" task is in progress, the assessment is still generating.
const hasGeneratingVerifyRiskAssessmentTask = useMemo(() => {
const allTaskItems = taskItemsResponse?.data?.data ?? [];
return allTaskItems.some(
(t) => t.title === 'Verify risk assessment' && t.status === 'in_progress',
);
}, [taskItemsResponse]);

useEffect(() => {
if (!hasGeneratingVerifyRiskAssessmentTask) return;

const interval = setInterval(() => {
void refreshTaskItems();
}, 3000);

return () => clearInterval(interval);
}, [hasGeneratingVerifyRiskAssessmentTask, refreshTaskItems]);

// Show risk assessment data if available
if (riskAssessmentData) {
return (
<VendorRiskAssessmentView
source={{
title: 'Risk Assessment',
description: JSON.stringify(riskAssessmentData),
createdAt: riskAssessmentUpdatedAt ?? vendor.updatedAt,
entityType: 'vendor',
createdByName: null,
createdByEmail: null,
}}
/>
);
}

// Show loading state if still processing
if (vendor.status === 'in_progress' || hasGeneratingVerifyRiskAssessmentTask) {
return (
<div className="rounded-lg border border-border bg-card p-8">
<div className="flex flex-col items-center gap-4">
<div className="relative">
<div className="h-10 w-10 animate-spin rounded-full border-4 border-muted border-t-primary" />
</div>
<div className="flex flex-col items-center gap-1.5 text-center">
<p className="text-sm font-medium text-foreground">
Analyzing vendor risk profile
</p>
<p className="text-sm text-muted-foreground max-w-md">
We're researching this vendor and generating a comprehensive risk
assessment. This typically takes 3-8 minutes.
</p>
</div>
</div>
</div>
);
}

// Show "not available" for assessed vendors without data
return (
<div className="rounded-lg border border-border bg-card p-8">
<div className="flex flex-col items-center gap-3 text-center">
<p className="text-sm text-muted-foreground">
No risk assessment available for this vendor.
</p>
</div>
</div>
);
}
65 changes: 30 additions & 35 deletions apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/review/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
'use server';

import PageWithBreadcrumb from '@/components/pages/PageWithBreadcrumb';
import { VendorRiskAssessmentView } from '@/components/vendor-risk-assessment/VendorRiskAssessmentView';
import type { VendorResponse } from '@/hooks/use-vendors';
import { auth } from '@/utils/auth';
import { extractDomain } from '@/utils/normalize-website';
import { db } from '@db';
Expand All @@ -12,6 +10,7 @@ import { cache } from 'react';
import { VendorActions } from '../components/VendorActions';
import { VendorHeader } from '../components/VendorHeader';
import { VendorTabs } from '../components/VendorTabs';
import { VendorReviewClient } from './components/VendorReviewClient';

interface ReviewPageProps {
params: Promise<{ vendorId: string; locale: string; orgId: string }>;
Expand All @@ -26,16 +25,13 @@ export default async function ReviewPage({ params, searchParams }: ReviewPagePro

const vendorResult = await getVendor({ vendorId, organizationId: orgId });

if (!vendorResult || !vendorResult.vendor) {
if (!vendorResult || !vendorResult.vendor || !vendorResult.vendorForClient) {
redirect('/');
}

// Hide tabs when viewing a task in focus mode
const isViewingTask = Boolean(taskItemId);
const vendor = vendorResult.vendor;

const riskAssessmentData = vendor.riskAssessmentData;
const riskAssessmentUpdatedAt = vendor.riskAssessmentUpdatedAt ?? null;
const { vendor, vendorForClient } = vendorResult;

return (
<PageWithBreadcrumb
Expand All @@ -54,26 +50,11 @@ export default async function ReviewPage({ params, searchParams }: ReviewPagePro
{!isViewingTask && <VendorHeader vendor={vendor} />}
{!isViewingTask && <VendorTabs vendorId={vendorId} orgId={orgId} />}
<div className="flex flex-col gap-4">
{riskAssessmentData ? (
<VendorRiskAssessmentView
source={{
title: 'Risk Assessment',
description: JSON.stringify(riskAssessmentData),
createdAt: (riskAssessmentUpdatedAt ?? vendor.updatedAt).toISOString(),
entityType: 'vendor',
createdByName: null,
createdByEmail: null,
}}
/>
) : (
<div className="rounded-lg border border-border bg-card p-8 text-center">
<p className="text-sm text-muted-foreground">
{vendor.status === 'in_progress'
? 'Risk assessment is being generated. Please check back soon.'
: 'No risk assessment found yet.'}
</p>
</div>
)}
<VendorReviewClient
vendorId={vendorId}
orgId={orgId}
initialVendor={vendorForClient}
/>
</div>
</PageWithBreadcrumb>
);
Expand Down Expand Up @@ -143,14 +124,28 @@ const getVendor = cache(async (params: { vendorId: string; organizationId: strin
globalVendor = duplicates.find((gv) => gv.riskAssessmentData !== null) ?? duplicates[0] ?? null;
}

// Return vendor with Date objects for VendorHeader (server component compatible)
const vendorWithRiskAssessment = {
...vendor,
riskAssessmentData: globalVendor?.riskAssessmentData ?? null,
riskAssessmentVersion: globalVendor?.riskAssessmentVersion ?? null,
riskAssessmentUpdatedAt: globalVendor?.riskAssessmentUpdatedAt ?? null,
};

// Serialize dates to strings for VendorReviewClient (client component)
const vendorForClient: VendorResponse = {
...vendor,
description: vendor.description ?? '',
createdAt: vendor.createdAt.toISOString(),
updatedAt: vendor.updatedAt.toISOString(),
riskAssessmentData: globalVendor?.riskAssessmentData ?? null,
riskAssessmentVersion: globalVendor?.riskAssessmentVersion ?? null,
riskAssessmentUpdatedAt: globalVendor?.riskAssessmentUpdatedAt?.toISOString() ?? null,
};

return {
vendor: {
...vendor,
// Use GlobalVendors risk assessment data if available, fallback to Vendor (for migration)
riskAssessmentData: globalVendor?.riskAssessmentData ?? null,
riskAssessmentVersion: globalVendor?.riskAssessmentVersion ?? null,
riskAssessmentUpdatedAt: globalVendor?.riskAssessmentUpdatedAt ?? null,
},
vendor: vendorWithRiskAssessment,
vendorForClient,
};
});

Expand Down
52 changes: 52 additions & 0 deletions apps/app/src/app/(app)/onboarding/actions/complete-onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ const onboardingCompletionSchema = z.object({
devices: z.string().min(1),
authentication: z.string().min(1),
software: z.string().optional(),
customVendors: z
.array(
z.object({
name: z.string(),
website: z.string().optional(),
}),
)
.optional(),
workLocation: z.string().min(1),
infrastructure: z.string().min(1),
dataTypes: z.string().min(1),
Expand Down Expand Up @@ -97,6 +105,50 @@ export const completeOnboarding = authActionClientWithoutOrg
tags: ['onboarding'],
organizationId: parsedInput.organizationId,
}));

// Add customVendors to context if present (for vendor risk assessment with URLs)
if (parsedInput.customVendors && parsedInput.customVendors.length > 0) {
contextData.push({
question: 'What are your custom vendors and their websites?',
answer: JSON.stringify(parsedInput.customVendors),
tags: ['onboarding'],
organizationId: parsedInput.organizationId,
});

// Add custom vendors to GlobalVendors immediately (if they have URLs and don't exist)
// This allows other organizations to benefit from user-contributed vendor data
for (const vendor of parsedInput.customVendors) {
if (vendor.website && vendor.website.trim()) {
try {
// Check if vendor with same name already exists in GlobalVendors
const existingGlobalVendor = await db.globalVendors.findFirst({
where: {
company_name: {
equals: vendor.name,
mode: 'insensitive',
},
},
});

if (!existingGlobalVendor) {
// Create new GlobalVendor entry (approved: false for review)
await db.globalVendors.create({
data: {
website: vendor.website,
company_name: vendor.name,
approved: false,
},
});
console.log(`Added custom vendor to GlobalVendors: ${vendor.name}`);
}
} catch (error) {
// Log but don't fail - GlobalVendors is a nice-to-have
console.warn(`Failed to add vendor ${vendor.name} to GlobalVendors:`, error);
}
}
}
}

await db.context.createMany({ data: contextData });

// Update organization to mark onboarding as complete
Expand Down
Loading