Skip to content
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
92 changes: 86 additions & 6 deletions apps/app/src/app/(app)/[orgId]/components/OnboardingTracker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,86 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) =>
return 'Initializing...';
}, [stepStatus.currentStep, stepStatus.policiesTotal, stepStatus.policiesCompleted, currentStep]);

// Normalize vendor name for deduplication - strips parenthetical suffixes
// e.g., "Fanta (cool)" and "Fanta" are treated as the same vendor
const normalizeVendorName = useCallback((name: string): string => {
return name
.toLowerCase()
.replace(/\s*\([^)]*\)\s*$/, '') // Remove trailing parenthetical suffixes
.trim();
}, []);

const uniqueVendorsInfo = useMemo(() => {
const statusRank = (status: 'pending' | 'processing' | 'assessing' | 'completed') => {
switch (status) {
case 'completed':
return 3;
case 'assessing':
case 'processing':
return 2;
case 'pending':
default:
return 1;
}
};

const map = new Map<
string,
{ vendor: { id: string; name: string }; rank: number; status: 'pending' | 'processing' | 'assessing' | 'completed' }
>();

stepStatus.vendorsInfo.forEach((vendor) => {
const status = stepStatus.vendorsStatus[vendor.id] || 'pending';
const nameKey = normalizeVendorName(vendor.name);
const rank = statusRank(status);
const existing = map.get(nameKey);

if (!existing || rank > existing.rank) {
map.set(nameKey, { vendor, rank, status });
}
});

return Array.from(map.values()).map(({ vendor }) => vendor);
}, [stepStatus.vendorsInfo, stepStatus.vendorsStatus, normalizeVendorName]);

// Calculate unique completed count for the counter (to match deduplicated list)
const uniqueVendorsCounts = useMemo(() => {
const statusRank = (status: 'pending' | 'processing' | 'assessing' | 'completed') => {
switch (status) {
case 'completed':
return 3;
case 'assessing':
case 'processing':
return 2;
case 'pending':
default:
return 1;
}
};

const map = new Map<
string,
{ status: 'pending' | 'processing' | 'assessing' | 'completed'; rank: number }
>();

stepStatus.vendorsInfo.forEach((vendor) => {
const status = stepStatus.vendorsStatus[vendor.id] || 'pending';
const nameKey = normalizeVendorName(vendor.name);
const rank = statusRank(status);
const existing = map.get(nameKey);

if (!existing || rank > existing.rank) {
map.set(nameKey, { status, rank });
}
});

const entries = Array.from(map.values());
return {
total: entries.length,
completed: entries.filter((e) => e.status === 'completed').length,
};
}, [stepStatus.vendorsInfo, stepStatus.vendorsStatus, normalizeVendorName]);

if (!triggerJobId || !mounted) {
return null;
}
Expand Down Expand Up @@ -361,10 +441,10 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) =>
const isRisksStep = step.key === 'risk';
const isPoliciesStep = step.key === 'policies';

// Determine completion based on actual counts, not boolean flags
// Determine completion based on unique counts, not raw metadata totals
const vendorsCompleted =
stepStatus.vendorsTotal > 0 &&
stepStatus.vendorsCompleted >= stepStatus.vendorsTotal;
uniqueVendorsCounts.total > 0 &&
uniqueVendorsCounts.completed >= uniqueVendorsCounts.total;
const risksCompleted =
stepStatus.risksTotal > 0 && stepStatus.risksCompleted >= stepStatus.risksTotal;
const policiesCompleted =
Expand Down Expand Up @@ -437,7 +517,7 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) =>
</span>
<div className="flex items-center gap-2 shrink-0">
<span className="text-muted-foreground text-sm">
{stepStatus.vendorsCompleted}/{stepStatus.vendorsTotal}
{uniqueVendorsCounts.completed}/{uniqueVendorsCounts.total}
</span>
{isVendorsExpanded ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
Expand All @@ -449,7 +529,7 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) =>
</button>

{/* Expanded vendor list */}
{isVendorsExpanded && stepStatus.vendorsInfo.length > 0 && (
{isVendorsExpanded && uniqueVendorsInfo.length > 0 && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
Expand All @@ -458,7 +538,7 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) =>
className="overflow-hidden"
>
<div className="flex flex-col gap-1.5 pl-7">
{stepStatus.vendorsInfo.map((vendor) => {
{uniqueVendorsInfo.map((vendor) => {
const vendorStatus = stepStatus.vendorsStatus[vendor.id] || 'pending';
const isVendorCompleted = vendorStatus === 'completed';
const isVendorProcessing = vendorStatus === 'processing';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -849,6 +849,12 @@ function CheckRunItem({
<span className="text-destructive">{run.failedCount} failed</span>
</>
)}
{run.passedCount > 0 && (
<>
<span className="text-muted-foreground">•</span>
<span className="text-primary">{run.passedCount} passed</span>
</>
)}
</div>
</div>

Expand Down Expand Up @@ -909,38 +915,45 @@ function CheckRunItem({
</div>
)}

{/* Passing Results */}
{passing.length > 0 && findings.length === 0 && (
<div className="space-y-2">
{passing.slice(0, 2).map((result) => (
<div key={result.id} className="space-y-2">
<div>
<p className="text-sm font-medium text-foreground">{result.title}</p>
{result.description && (
<p className="text-sm text-muted-foreground mt-1">{result.description}</p>
{/* Passing Results - always show when there are passing results */}
{passing.length > 0 && (
<details className="text-xs" open={findings.length === 0}>
<summary className="text-sm font-medium text-primary cursor-pointer flex items-center gap-2">
<span>✓ {passing.length} passed</span>
</summary>
<div className="mt-2 space-y-2 pl-4 border-l-2 border-primary/20">
{passing.slice(0, 3).map((result) => (
<div key={result.id} className="space-y-2">
<div>
<p className="text-sm font-medium text-foreground">{result.title}</p>
{result.description && (
<p className="text-sm text-muted-foreground mt-1">{result.description}</p>
)}
<Badge variant="secondary" className="mt-2 font-mono text-xs">
{result.resourceId}
</Badge>
</div>
{result.evidence && Object.keys(result.evidence).length > 0 && (
<details className="text-xs">
<summary className="text-muted-foreground cursor-pointer">
View Evidence
</summary>
<EvidenceJsonView
evidence={result.evidence}
organizationName={organizationName}
automationName={run.checkName}
/>
</details>
)}
<Badge variant="secondary" className="mt-2 font-mono text-xs">
{result.resourceId}
</Badge>
</div>
{result.evidence && Object.keys(result.evidence).length > 0 && (
<details className="text-xs">
<summary className="text-muted-foreground cursor-pointer">
View Evidence
</summary>
<EvidenceJsonView
evidence={result.evidence}
organizationName={organizationName}
automationName={run.checkName}
/>
</details>
)}
</div>
))}
{passing.length > 2 && (
<p className="text-sm text-muted-foreground">+{passing.length - 2} more passed</p>
)}
</div>
))}
{passing.length > 3 && (
<p className="text-sm text-muted-foreground">
+{passing.length - 3} more passed
</p>
)}
</div>
</details>
)}

{/* Logs */}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -244,10 +244,53 @@ export function VendorsTable({
return [...vendorsWithStatus, ...pendingVendors, ...tempVendors];
}, [vendors, itemsInfo, itemStatuses, orgId, isActive, onboardingRunId]);

const dedupedVendors = useMemo<VendorRow[]>(() => {
// SAFE deduplication strategy:
// 1. Show ALL real DB vendors (no deduplication) - user data must not be hidden
// 2. Hide placeholders if a real vendor with same normalized name exists
// 3. Deduplicate placeholders against each other (show only one per name)

// Normalize vendor name for deduplication - strips parenthetical suffixes
const normalizeVendorName = (name: string): string => {
return name
.toLowerCase()
.replace(/\s*\([^)]*\)\s*$/, '') // Remove trailing parenthetical suffixes
.trim();
};

// Separate real DB vendors from placeholders
const realVendors = mergedVendors.filter((v) => !v.isPending);
const placeholders = mergedVendors.filter((v) => v.isPending);

// Build a set of normalized names from real vendors
const realVendorNames = new Set(realVendors.map((v) => normalizeVendorName(v.name)));

// Deduplicate placeholders: keep only one per name, and only if no real vendor exists
const placeholderMap = new Map<string, VendorRow>();
placeholders.forEach((placeholder) => {
const nameKey = normalizeVendorName(placeholder.name);

// Skip if a real vendor with this name already exists
if (realVendorNames.has(nameKey)) {
return;
}

// Keep the first placeholder for each name (or replace if needed)
const existing = placeholderMap.get(nameKey);
if (!existing) {
placeholderMap.set(nameKey, placeholder);
}
// If multiple placeholders with same name, keep the first one (no ranking needed)
});

// Return all real vendors + deduplicated placeholders
return [...realVendors, ...Array.from(placeholderMap.values())];
}, [mergedVendors]);

const columns = useMemo<ColumnDef<VendorRow>[]>(() => getColumns(orgId), [orgId]);

const { table } = useDataTable({
data: mergedVendors,
data: dedupedVendors,
columns,
pageCount,
getRowId: (row) => row.id,
Expand Down Expand Up @@ -285,33 +328,55 @@ export function VendorsTable({
[itemStatuses],
);

// Calculate actual assessment progress
// Calculate actual assessment progress (using deduplicated counts to match the table)
const assessmentProgress = useMemo(() => {
if (!progress || !itemsInfo.length) {
return null;
}

// Count vendors that are completed (either 'completed' in metadata or 'assessed' in DB)
const completedCount = vendors.filter((vendor) => {
const metadataStatus = itemStatuses[vendor.id];
return metadataStatus === 'completed' || vendor.status === 'assessed';
}).length;
// Normalize vendor name for deduplication (same as dedupedVendors)
const normalizeVendorName = (name: string): string => {
return name
.toLowerCase()
.replace(/\s*\([^)]*\)\s*$/, '')
.trim();
};

// Also count vendors in metadata that are completed but not yet in DB
const completedInMetadata = Object.values(itemStatuses).filter(
(status) => status === 'completed',
).length;
// Build a map of unique vendor names with their best status
// This mirrors the deduplication logic used for the table
const uniqueVendorStatuses = new Map<string, { isCompleted: boolean }>();

// First, add all vendors from metadata
itemsInfo.forEach((item) => {
const nameKey = normalizeVendorName(item.name);
const metadataStatus = itemStatuses[item.id];
const isCompleted = metadataStatus === 'completed';

const existing = uniqueVendorStatuses.get(nameKey);
if (!existing || (isCompleted && !existing.isCompleted)) {
uniqueVendorStatuses.set(nameKey, { isCompleted });
}
});

// Total is the max of progress.total, itemsInfo.length, or actual vendors created
const total = Math.max(progress.total, itemsInfo.length, vendors.length);
// Then, update with DB vendor statuses (which may be more accurate)
vendors.forEach((vendor) => {
const nameKey = normalizeVendorName(vendor.name);
const metadataStatus = itemStatuses[vendor.id];
const isCompleted = metadataStatus === 'completed' || vendor.status === 'assessed';

const existing = uniqueVendorStatuses.get(nameKey);
if (!existing || (isCompleted && !existing.isCompleted)) {
uniqueVendorStatuses.set(nameKey, { isCompleted });
}
});

// Completed is the max of DB assessed vendors or metadata completed
const completed = Math.max(completedCount, completedInMetadata);
const total = uniqueVendorStatuses.size;
const completed = Array.from(uniqueVendorStatuses.values()).filter((v) => v.isCompleted).length;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Progress counter inconsistent with table vendor deduplication

Medium Severity

The assessmentProgress calculation deduplicates all vendors (including real DB vendors) by normalized name, while dedupedVendors explicitly preserves all real DB vendors without deduplication. This mismatch means if two real vendors exist with names that normalize to the same value (e.g., "Slack" and "Slack Inc"), the table shows both rows but the progress counter treats them as one. If only one is completed, progress shows "1/1" (complete) while a pending vendor is still visible in the table. The comment claims the progress calculation "matches the table" but the logic does not.

Additional Locations (1)

Fix in Cursor Fix in Web


return { total, completed };
}, [progress, itemsInfo, vendors, itemStatuses]);

const isEmpty = mergedVendors.length === 0;
const isEmpty = dedupedVendors.length === 0;
// Show empty state if onboarding is active (even if progress metadata isn't set yet)
const showEmptyState = isEmpty && onboardingRunId && isActive;

Expand Down
Loading
Loading