From 7c4723ea6370087db21f8e735e7fc1bd54b9c6fa Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Thu, 13 Nov 2025 16:20:42 -0500 Subject: [PATCH] feat(onboarding): add individual tracking for vendors and risks with auto-expand - Track vendors and risks individually with dropdowns similar to policies - Extract vendors upfront to show them immediately before creation - Auto-expand current step and collapse previous steps - Update metadata tracking for vendors and risks with status indicators --- .../[orgId]/components/OnboardingTracker.tsx | 234 ++++++++++++++++++ .../onboard-organization-helpers.ts | 7 +- .../tasks/onboarding/onboard-organization.ts | 59 ++++- 3 files changed, 294 insertions(+), 6 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/components/OnboardingTracker.tsx b/apps/app/src/app/(app)/[orgId]/components/OnboardingTracker.tsx index c9c61f01e..f69d86960 100644 --- a/apps/app/src/app/(app)/[orgId]/components/OnboardingTracker.tsx +++ b/apps/app/src/app/(app)/[orgId]/components/OnboardingTracker.tsx @@ -37,6 +37,8 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) => const [isMinimized, setIsMinimized] = useState(false); const [isDismissed, setIsDismissed] = useState(false); const [isPoliciesExpanded, setIsPoliciesExpanded] = useState(false); + const [isVendorsExpanded, setIsVendorsExpanded] = useState(false); + const [isRisksExpanded, setIsRisksExpanded] = useState(false); // useRealtimeRun will automatically get the token from TriggerProvider context // This gives us real-time updates including metadata changes @@ -63,6 +65,16 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) => risk: false, policies: false, currentStep: null, + vendorsTotal: 0, + vendorsCompleted: 0, + vendorsRemaining: 0, + vendorsInfo: [], + vendorsStatus: {}, + risksTotal: 0, + risksCompleted: 0, + risksRemaining: 0, + risksInfo: [], + risksStatus: {}, policiesTotal: 0, policiesCompleted: 0, policiesRemaining: 0, @@ -73,6 +85,24 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) => const meta = run.metadata as Record; + // Build vendorsStatus object from individual vendor status keys + const vendorsStatus: Record = {}; + const vendorsInfo = (meta.vendorsInfo as Array<{ id: string; name: string }>) || []; + + vendorsInfo.forEach((vendor) => { + const statusKey = `vendor_${vendor.id}_status`; + vendorsStatus[vendor.id] = (meta[statusKey] as 'pending' | 'processing' | 'completed') || 'pending'; + }); + + // Build risksStatus object from individual risk status keys + const risksStatus: Record = {}; + const risksInfo = (meta.risksInfo as Array<{ id: string; name: string }>) || []; + + risksInfo.forEach((risk) => { + const statusKey = `risk_${risk.id}_status`; + risksStatus[risk.id] = (meta[statusKey] as 'pending' | 'processing' | 'completed') || 'pending'; + }); + // Build policiesStatus object from individual policy status keys const policiesStatus: Record = {}; const policiesInfo = (meta.policiesInfo as Array<{ id: string; name: string }>) || []; @@ -88,6 +118,16 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) => risk: meta.risk === true, policies: meta.policies === true, currentStep: (meta.currentStep as string) || null, + vendorsTotal: (meta.vendorsTotal as number) || 0, + vendorsCompleted: (meta.vendorsCompleted as number) || 0, + vendorsRemaining: (meta.vendorsRemaining as number) || 0, + vendorsInfo, + vendorsStatus, + risksTotal: (meta.risksTotal as number) || 0, + risksCompleted: (meta.risksCompleted as number) || 0, + risksRemaining: (meta.risksRemaining as number) || 0, + risksInfo, + risksStatus, policiesTotal: (meta.policiesTotal as number) || 0, policiesCompleted: (meta.policiesCompleted as number) || 0, policiesRemaining: (meta.policiesRemaining as number) || 0, @@ -107,6 +147,28 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) => return ONBOARDING_STEPS.find((step) => !stepStatus[step.key as keyof typeof stepStatus]); }, [stepStatus]); + // Auto-expand current step and collapse others + useEffect(() => { + if (!currentStep) return; + + const stepKey = currentStep.key; + + // Expand current step if it has items to show + if (stepKey === 'vendors' && stepStatus.vendorsTotal > 0) { + setIsVendorsExpanded(true); + setIsRisksExpanded(false); + setIsPoliciesExpanded(false); + } else if (stepKey === 'risk' && stepStatus.risksTotal > 0) { + setIsVendorsExpanded(false); + setIsRisksExpanded(true); + setIsPoliciesExpanded(false); + } else if (stepKey === 'policies' && stepStatus.policiesTotal > 0) { + setIsVendorsExpanded(false); + setIsRisksExpanded(false); + setIsPoliciesExpanded(true); + } + }, [currentStep?.key, stepStatus.vendorsTotal, stepStatus.risksTotal, stepStatus.policiesTotal]); + // Build dynamic current step message with progress const currentStepMessage = useMemo(() => { if (stepStatus.currentStep) { @@ -267,8 +329,180 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) => {ONBOARDING_STEPS.map((step) => { const isCompleted = stepStatus[step.key as keyof typeof stepStatus]; const isCurrent = currentStep?.key === step.key; + const isVendorsStep = step.key === 'vendors'; + const isRisksStep = step.key === 'risk'; const isPoliciesStep = step.key === 'policies'; + // Vendors step with expandable dropdown + if (isVendorsStep && stepStatus.vendorsTotal > 0) { + return ( +
+ + + {/* Expanded vendor list */} + {isVendorsExpanded && stepStatus.vendorsInfo.length > 0 && ( + +
+ {stepStatus.vendorsInfo.map((vendor) => { + const vendorStatus = stepStatus.vendorsStatus[vendor.id] || 'pending'; + const isVendorCompleted = vendorStatus === 'completed'; + const isVendorProcessing = vendorStatus === 'processing'; + + return ( +
+ {isVendorCompleted ? ( + + ) : isVendorProcessing ? ( + + ) : ( +
+ )} + + {vendor.name} + +
+ ); + })} +
+ + )} +
+ ); + } + + // Risks step with expandable dropdown + if (isRisksStep && stepStatus.risksTotal > 0) { + return ( +
+ + + {/* Expanded risk list */} + {isRisksExpanded && stepStatus.risksInfo.length > 0 && ( + +
+ {stepStatus.risksInfo.map((risk) => { + const riskStatus = stepStatus.risksStatus[risk.id] || 'pending'; + const isRiskCompleted = riskStatus === 'completed'; + const isRiskProcessing = riskStatus === 'processing'; + + return ( +
+ {isRiskCompleted ? ( + + ) : isRiskProcessing ? ( + + ) : ( +
+ )} + + {risk.name} + +
+ ); + })} +
+ + )} +
+ ); + } + if (isPoliciesStep && stepStatus.policiesTotal > 0) { // Policies step with expandable dropdown return ( diff --git a/apps/app/src/jobs/tasks/onboarding/onboard-organization-helpers.ts b/apps/app/src/jobs/tasks/onboarding/onboard-organization-helpers.ts index 66d5905d8..28dd8e52a 100644 --- a/apps/app/src/jobs/tasks/onboarding/onboard-organization-helpers.ts +++ b/apps/app/src/jobs/tasks/onboarding/onboard-organization-helpers.ts @@ -554,12 +554,13 @@ export async function triggerPolicyUpdates( export async function createVendors( questionsAndAnswers: ContextItem[], organizationId: string, + vendorData?: VendorData[], ): Promise { - // Extract vendors using AI - const vendorData = await extractVendorsFromContext(questionsAndAnswers); + // Extract vendors using AI if not provided + const vendorsToCreate = vendorData || await extractVendorsFromContext(questionsAndAnswers); // Create vendor records in database - const createdVendors = await createVendorsFromData(vendorData, organizationId); + const createdVendors = await createVendorsFromData(vendorsToCreate, organizationId); // Trigger background research for each vendor await triggerVendorResearch(createdVendors); diff --git a/apps/app/src/jobs/tasks/onboarding/onboard-organization.ts b/apps/app/src/jobs/tasks/onboarding/onboard-organization.ts index 9dad8ba41..f7c8935bd 100644 --- a/apps/app/src/jobs/tasks/onboarding/onboard-organization.ts +++ b/apps/app/src/jobs/tasks/onboarding/onboard-organization.ts @@ -6,6 +6,7 @@ import { generateVendorMitigationsForOrg } from './generate-vendor-mitigation'; import { createRisks, createVendors, + extractVendorsFromContext, getOrganizationContext, updateOrganizationPolicies, } from './onboard-organization-helpers'; @@ -93,8 +94,41 @@ export const onboardOrganization = task({ }, }); - // Create vendors - const vendors = await createVendors(questionsAndAnswers, payload.organizationId); + // Extract vendors first so we can show them immediately + const vendorData = await extractVendorsFromContext(questionsAndAnswers); + + // Track vendors immediately as "pending" before creation + if (vendorData.length > 0) { + metadata.set('vendorsTotal', vendorData.length); + metadata.set('vendorsCompleted', 0); + metadata.set('vendorsRemaining', vendorData.length); + // Use temporary IDs based on index until we have real IDs + metadata.set( + 'vendorsInfo', + vendorData.map((v, index) => ({ id: `temp_${index}`, name: v.vendor_name })), + ); + // Mark all as pending initially + vendorData.forEach((_, index) => { + metadata.set(`vendor_temp_${index}_status`, 'pending'); + }); + } + + // Create vendors (pass extracted data to avoid re-extraction) + const vendors = await createVendors(questionsAndAnswers, payload.organizationId, vendorData); + + // Update tracking with real vendor IDs and mark as completed + if (vendors.length > 0) { + metadata.set('vendorsCompleted', vendors.length); + metadata.set('vendorsRemaining', 0); + metadata.set( + 'vendorsInfo', + vendors.map((v) => ({ id: v.id, name: v.name })), + ); + // Mark all as completed + vendors.forEach((vendor) => { + metadata.set(`vendor_${vendor.id}_status`, 'completed'); + }); + } // Mark vendors step as complete in metadata (real-time) metadata.set('vendors', true); @@ -109,7 +143,26 @@ export const onboardOrganization = task({ ); // Create risks - await createRisks(questionsAndAnswers, payload.organizationId, organization.name); + const risks = await createRisks( + questionsAndAnswers, + payload.organizationId, + organization.name, + ); + + // Track risks with metadata for real-time tracking + if (risks.length > 0) { + metadata.set('risksTotal', risks.length); + metadata.set('risksCompleted', risks.length); + metadata.set('risksRemaining', 0); + metadata.set( + 'risksInfo', + risks.map((r) => ({ id: r.id, name: r.title })), + ); + // All risks are created immediately, so mark them all as completed + risks.forEach((risk) => { + metadata.set(`risk_${risk.id}_status`, 'completed'); + }); + } // Mark risks step as complete in metadata (real-time) metadata.set('risk', true);