From fee8f14b5c343d67d7b8db3dbd96bc4cf6ee69f2 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Wed, 24 Dec 2025 00:54:33 -0500 Subject: [PATCH 1/2] feat(api): update firecrawl integration and enhance risk assessment task --- apps/api/package.json | 1 + .../vendor/vendor-risk-assessment-task.ts | 7 +- .../vendor-risk-assessment/agent-schema.ts | 52 +++ .../vendor-risk-assessment/agent-types.ts | 43 +++ .../vendor-risk-assessment/description.ts | 181 ++-------- .../vendor-risk-assessment/firecrawl-agent.ts | 203 ++++++++++++ .../comments/CommentRichTextField.tsx | 2 +- .../task-items/TaskItemCreateDialog.tsx | 2 +- .../TaskItemEditableDescription.tsx | 4 +- .../task-items/TaskItemEditableTitle.tsx | 7 +- .../task-items/TaskItemFocusSidebar.tsx | 188 ++++++++++- .../task-items/TaskItemFocusView.tsx | 18 +- .../TaskItemScrollableDescription.tsx | 6 +- .../task-items/TaskRichDescriptionField.tsx | 33 +- .../custom-task/CustomTaskItemMainContent.tsx | 77 +++++ .../GeneratedTaskItemMainContent.tsx | 31 ++ ...VendorRiskAssessmentCertificationsCard.tsx | 154 +++++++++ .../VendorRiskAssessmentTaskItemView.tsx | 313 ++++++++++++++++++ .../VendorRiskAssessmentTimelineCard.tsx | 126 +++++++ .../is-vendor-risk-assessment-task-item.ts | 51 +++ ...arse-vendor-risk-assessment-description.ts | 134 ++++++++ .../vendor-risk-assessment-types.tsx | 43 +++ bun.lock | 7 +- 23 files changed, 1507 insertions(+), 176 deletions(-) create mode 100644 apps/api/src/trigger/vendor/vendor-risk-assessment/agent-schema.ts create mode 100644 apps/api/src/trigger/vendor/vendor-risk-assessment/agent-types.ts create mode 100644 apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent.ts create mode 100644 apps/app/src/components/task-items/custom-task/CustomTaskItemMainContent.tsx create mode 100644 apps/app/src/components/task-items/generated-task/GeneratedTaskItemMainContent.tsx create mode 100644 apps/app/src/components/task-items/generated-task/vendor-risk-assessment/VendorRiskAssessmentCertificationsCard.tsx create mode 100644 apps/app/src/components/task-items/generated-task/vendor-risk-assessment/VendorRiskAssessmentTaskItemView.tsx create mode 100644 apps/app/src/components/task-items/generated-task/vendor-risk-assessment/VendorRiskAssessmentTimelineCard.tsx create mode 100644 apps/app/src/components/task-items/generated-task/vendor-risk-assessment/is-vendor-risk-assessment-task-item.ts create mode 100644 apps/app/src/components/task-items/generated-task/vendor-risk-assessment/parse-vendor-risk-assessment-description.ts create mode 100644 apps/app/src/components/task-items/generated-task/vendor-risk-assessment/vendor-risk-assessment-types.tsx diff --git a/apps/api/package.json b/apps/api/package.json index a64a743b6..157152039 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -14,6 +14,7 @@ "@browserbasehq/sdk": "^2.6.0", "@browserbasehq/stagehand": "^3.0.5", "@comp/integration-platform": "workspace:*", + "@mendable/firecrawl-js": "^4.9.3", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.0.1", diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment-task.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment-task.ts index 07c55971e..8798aff4c 100644 --- a/apps/api/src/trigger/vendor/vendor-risk-assessment-task.ts +++ b/apps/api/src/trigger/vendor/vendor-risk-assessment-task.ts @@ -7,7 +7,7 @@ import { VENDOR_RISK_ASSESSMENT_TASK_TITLE, } from './vendor-risk-assessment/constants'; import { buildRiskAssessmentDescription } from './vendor-risk-assessment/description'; -import { firecrawlExtractVendorData } from './vendor-risk-assessment/firecrawl'; +import { firecrawlAgentVendorRiskAssessment } from './vendor-risk-assessment/firecrawl-agent'; import { buildFrameworkChecklist, getDefaultFrameworks, @@ -107,7 +107,10 @@ export const vendorRiskAssessmentTask = schemaTask({ const research = payload.withResearch && payload.vendorWebsite - ? await firecrawlExtractVendorData(payload.vendorWebsite) + ? await firecrawlAgentVendorRiskAssessment({ + vendorName: payload.vendorName, + vendorWebsite: payload.vendorWebsite, + }) : null; const organizationFrameworks = getDefaultFrameworks(); diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment/agent-schema.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment/agent-schema.ts new file mode 100644 index 000000000..0532376c3 --- /dev/null +++ b/apps/api/src/trigger/vendor/vendor-risk-assessment/agent-schema.ts @@ -0,0 +1,52 @@ +import { z } from 'zod'; + +const urlOrEmptySchema = z.union([z.string().url(), z.literal('')]).optional().nullable(); +// Firecrawl may return various date formats (ISO, "YYYY-MM-DD", etc). We normalize later. +const dateStringOrEmptySchema = z.union([z.string(), z.literal('')]).optional().nullable(); + +export const vendorRiskAssessmentAgentSchema = z.object({ + risk_level: z.string().optional().nullable(), + security_assessment: z.string().optional().nullable(), + last_researched_at: dateStringOrEmptySchema, + certifications: z + .array( + z.object({ + type: z.string(), + status: z.enum(['verified', 'expired', 'not_certified', 'unknown']).optional().nullable(), + issued_at: dateStringOrEmptySchema, + expires_at: dateStringOrEmptySchema, + url: urlOrEmptySchema, + }), + ) + .optional() + .nullable(), + links: z + .object({ + privacy_policy_url: urlOrEmptySchema, + terms_of_service_url: urlOrEmptySchema, + trust_center_url: urlOrEmptySchema, + security_page_url: urlOrEmptySchema, + soc2_report_url: urlOrEmptySchema, + }) + .optional() + .nullable(), + news: z + .array( + z.object({ + date: z.string(), + title: z.string(), + summary: z.string().optional().nullable(), + source: z.string().optional().nullable(), + url: urlOrEmptySchema, + sentiment: z.enum(['positive', 'negative', 'neutral']).optional().nullable(), + }), + ) + .optional() + .nullable(), +}); + +export type VendorRiskAssessmentAgentResult = z.infer< + typeof vendorRiskAssessmentAgentSchema +>; + + diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment/agent-types.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment/agent-types.ts new file mode 100644 index 000000000..2fdbb2dba --- /dev/null +++ b/apps/api/src/trigger/vendor/vendor-risk-assessment/agent-types.ts @@ -0,0 +1,43 @@ +export type VendorRiskAssessmentCertificationStatus = + | 'verified' + | 'expired' + | 'not_certified' + | 'unknown'; + +export type VendorRiskAssessmentCertification = { + type: string; + status: VendorRiskAssessmentCertificationStatus; + issuedAt?: string | null; + expiresAt?: string | null; + url?: string | null; +}; + +export type VendorRiskAssessmentLink = { + label: string; + url: string; +}; + +export type VendorRiskAssessmentNewsSentiment = 'positive' | 'negative' | 'neutral'; + +export type VendorRiskAssessmentNewsItem = { + date: string; + title: string; + summary?: string | null; + source?: string | null; + url?: string | null; + sentiment?: VendorRiskAssessmentNewsSentiment | null; +}; + +export type VendorRiskAssessmentDataV1 = { + kind: 'vendorRiskAssessmentV1'; + vendorName?: string | null; + vendorWebsite?: string | null; + lastResearchedAt?: string | null; + riskLevel?: string | null; + securityAssessment?: string | null; + certifications?: VendorRiskAssessmentCertification[] | null; + links?: VendorRiskAssessmentLink[] | null; + news?: VendorRiskAssessmentNewsItem[] | null; +}; + + diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment/description.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment/description.ts index 79eae8de1..32c135c0e 100644 --- a/apps/api/src/trigger/vendor/vendor-risk-assessment/description.ts +++ b/apps/api/src/trigger/vendor/vendor-risk-assessment/description.ts @@ -1,164 +1,39 @@ import type { OrgFramework } from './frameworks'; -import type { FirecrawlVendorData } from './schema'; +import type { VendorRiskAssessmentDataV1 } from './agent-types'; export function buildRiskAssessmentDescription(params: { vendorName: string; vendorWebsite: string | null; - research: FirecrawlVendorData | null; + research: VendorRiskAssessmentDataV1 | null; frameworkChecklist: string[]; organizationFrameworks: OrgFramework[]; }): string { - const { vendorName, vendorWebsite, research, frameworkChecklist, organizationFrameworks } = - params; - - const instruction = - 'Conduct a risk assessment for this vendor. Review their controls and documentation against SOC 2 and ISO 27001 expectations and add a comment describing how your team will use the vendor securely.'; - - const links: Array<{ label: string; url: string }> = []; - if (research?.trust_portal_url) - links.push({ label: 'Trust Center', url: research.trust_portal_url }); - if (research?.security_overview_url) - links.push({ label: 'Security Overview', url: research.security_overview_url }); - if (research?.soc2_report_url) - links.push({ label: 'SOC 2 Report', url: research.soc2_report_url }); - if (research?.privacy_policy_url) - links.push({ label: 'Privacy Policy', url: research.privacy_policy_url }); - if (research?.terms_of_service_url) - links.push({ label: 'Terms of Service', url: research.terms_of_service_url }); - - const content: Array> = [ - { - type: 'paragraph', - content: [{ type: 'text', text: instruction }], - }, - { - type: 'paragraph', - content: [ - { type: 'text', marks: [{ type: 'bold' }], text: 'Vendor:' }, - { type: 'text', text: ` ${vendorName}` }, - ], - }, - ]; - - if (vendorWebsite) { - content.push({ - type: 'paragraph', - content: [ - { type: 'text', marks: [{ type: 'bold' }], text: 'Website:' }, - { type: 'text', text: ` ${vendorWebsite}` }, - ], - }); - } - - // Intentionally omit "Framework Focus" line to keep the description concise. - - if (frameworkChecklist.length > 0) { - content.push({ - type: 'paragraph', - content: [ - { type: 'text', marks: [{ type: 'bold' }], text: 'Framework-specific checks:' }, - ], - }); - content.push({ - type: 'bulletList', - content: frameworkChecklist.map((item) => ({ - type: 'listItem', - content: [{ type: 'paragraph', content: [{ type: 'text', text: item }] }], - })), - }); - } - - if (research?.company_description) { - content.push({ - type: 'paragraph', - content: [ - { type: 'text', marks: [{ type: 'bold' }], text: 'Company Overview:' }, - ], - }); - content.push({ - type: 'paragraph', - content: [{ type: 'text', text: research.company_description }], - }); - } - - const certs = research?.certified_security_frameworks?.filter(Boolean) ?? []; - if (certs.length > 0) { - content.push({ - type: 'paragraph', - content: [ - { type: 'text', marks: [{ type: 'bold' }], text: 'Security Certifications:' }, - ], - }); - content.push({ - type: 'bulletList', - content: certs.map((framework) => ({ - type: 'listItem', - content: [ - { type: 'paragraph', content: [{ type: 'text', text: framework }] }, - ], - })), - }); - } - - if (links.length > 0) { - content.push({ - type: 'paragraph', - content: [ - { type: 'text', marks: [{ type: 'bold' }], text: 'Relevant Links:' }, - ], - }); - content.push({ - type: 'bulletList', - content: links.map((link) => ({ - type: 'listItem', - content: [ - { - type: 'paragraph', - content: [ - { - type: 'text', - marks: [ - { - type: 'link', - attrs: { - href: link.url, - target: '_blank', - rel: 'noopener noreferrer', - }, - }, - ], - text: link.label, - }, - ], - }, - ], - })), - }); - } else if (vendorWebsite) { - content.push({ - type: 'paragraph', - content: [ - { - type: 'text', - marks: [{ type: 'italic' }], - text: 'Note: Automated research did not return links. Please collect documentation manually.', - }, - ], - }); - } else { - content.push({ - type: 'paragraph', - content: [ - { - type: 'text', - marks: [{ type: 'italic' }], - text: 'Note: No website provided for automated research.', - }, - ], - }); - } - - return JSON.stringify({ type: 'doc', content }); + const { vendorName, vendorWebsite, research, frameworkChecklist } = params; + + const base: VendorRiskAssessmentDataV1 = research ?? { + kind: 'vendorRiskAssessmentV1', + vendorName, + vendorWebsite, + lastResearchedAt: null, + riskLevel: null, + securityAssessment: null, + certifications: null, + links: null, + news: null, + }; + + // Keep the existing “framework checklist” value for humans (rendered inside the Security Assessment card). + const checklistSuffix = + frameworkChecklist.length > 0 + ? `\n\nFramework-specific checks:\n${frameworkChecklist.map((c) => `- ${c}`).join('\n')}` + : ''; + + return JSON.stringify({ + ...base, + vendorName: base.vendorName ?? vendorName, + vendorWebsite: base.vendorWebsite ?? vendorWebsite, + securityAssessment: (base.securityAssessment ?? '') + checklistSuffix || null, + } satisfies VendorRiskAssessmentDataV1); } diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent.ts new file mode 100644 index 000000000..e8693ad04 --- /dev/null +++ b/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent.ts @@ -0,0 +1,203 @@ +import Firecrawl from '@mendable/firecrawl-js'; +import { logger } from '@trigger.dev/sdk'; +import { vendorRiskAssessmentAgentSchema } from './agent-schema'; +import type { VendorRiskAssessmentDataV1 } from './agent-types'; + +function normalizeUrl(url: string | null | undefined): string | null { + if (!url) return null; + const trimmed = url.trim(); + if (!trimmed) return null; + if (trimmed === '') return null; + + const looksLikeDomain = + !/^https?:\/\//i.test(trimmed) && /^[a-z0-9.-]+\.[a-z]{2,}([/].*)?$/i.test(trimmed); + const candidate = looksLikeDomain ? `https://${trimmed}` : trimmed; + + try { + const u = new URL(candidate); + if (!['http:', 'https:'].includes(u.protocol)) return null; + return u.toString(); + } catch { + return null; + } +} + +function normalizeIso(date: string | null | undefined): string | null { + if (!date) return null; + const trimmed = date.trim(); + if (!trimmed) return null; + const d = new Date(trimmed); + if (Number.isNaN(d.getTime())) return null; + return d.toISOString(); +} + +export async function firecrawlAgentVendorRiskAssessment(params: { + vendorName: string; + vendorWebsite: string; +}): Promise { + const apiKey = process.env.FIRECRAWL_API_KEY; + if (!apiKey) { + logger.warn('FIRECRAWL_API_KEY is not configured; skipping vendor research'); + return null; + } + + const { vendorName, vendorWebsite } = params; + + let origin: string; + try { + origin = new URL(vendorWebsite).origin; + } catch { + logger.warn('Invalid website URL provided to Firecrawl Agent', { vendorWebsite }); + return null; + } + + const firecrawlClient = new Firecrawl({ apiKey }); + + const prompt = `Complete cyber security research on the vendor "${vendorName}" with website ${vendorWebsite}. + +Extract the following information: +1. **Certifications**: Find any security certifications they have (SOC 2 Type I, SOC 2 Type II, ISO 27001 etc). For each certification found, determine: + - The type of certification + - Whether it's verified/current, expired, or not certified + - Any issue or expiry dates mentioned + - Link to the compliance/trust page or report if available + +2. **Legal & Security Documents**: Find the direct URLs to: + - Privacy Policy page (usually at /privacy, /privacy-policy, or linked in the footer) + - Terms of Service page (usually at /terms, /tos, /terms-of-service, or linked in the footer) + - Trust Center or Security page (typically could be at /trust, /security or trust.website.com or security.website.com) + +3. **Recent News**: Find recent news articles (last 12 months) about the company, especially: + - Security incidents or data breaches + - Funding rounds or acquisitions + - Lawsuits or regulatory actions + - Major partnerships or product updates + - Leadership changes + +4. **Summary**: Provide an overall assessment of the vendor's security posture. + +Focus on their official website (especially trust/security/compliance pages), press releases, and reputable news sources.`; + + // Using SDK (no maxCredits override, no explicit polling here) + // Important: avoid crawling huge sites with a wildcard (e.g. workspace.google.com). + const agentResponse = await firecrawlClient.agent({ + prompt, + urls: [origin], + schema: { + type: 'object', + properties: { + risk_level: { type: 'string' }, + security_assessment: { type: 'string' }, + last_researched_at: { type: 'string' }, + certifications: { + type: 'array', + items: { + type: 'object', + properties: { + type: { type: 'string' }, + status: { + type: 'string', + enum: ['verified', 'expired', 'not_certified', 'unknown'], + }, + issued_at: { type: 'string' }, + expires_at: { type: 'string' }, + url: { type: 'string' }, + }, + required: ['type'], + }, + }, + links: { + type: 'object', + properties: { + privacy_policy_url: { type: 'string' }, + terms_of_service_url: { type: 'string' }, + trust_center_url: { type: 'string' }, + security_page_url: { type: 'string' }, + soc2_report_url: { type: 'string' }, + }, + }, + news: { + type: 'array', + items: { + type: 'object', + properties: { + date: { type: 'string' }, + title: { type: 'string' }, + summary: { type: 'string' }, + source: { type: 'string' }, + url: { type: 'string' }, + sentiment: { type: 'string', enum: ['positive', 'negative', 'neutral'] }, + }, + required: ['date', 'title'], + }, + }, + }, + required: ['security_assessment'], + }, + }); + + const parsed = vendorRiskAssessmentAgentSchema.safeParse(agentResponse.data); + if (!parsed.success) { + logger.warn('Firecrawl Agent SDK returned invalid data shape', { + vendorWebsite, + issues: parsed.error.issues, + }); + return null; + } + + const links = parsed.data.links ?? null; + const linkPairs: Array<{ label: string; url: string }> = []; + if (links?.trust_center_url) linkPairs.push({ label: 'Trust & Security', url: links.trust_center_url }); + if (links?.security_page_url) linkPairs.push({ label: 'Security Overview', url: links.security_page_url }); + if (links?.soc2_report_url) linkPairs.push({ label: 'SOC 2 Report', url: links.soc2_report_url }); + if (links?.privacy_policy_url) linkPairs.push({ label: 'Privacy Policy', url: links.privacy_policy_url }); + if (links?.terms_of_service_url) linkPairs.push({ label: 'Terms of Service', url: links.terms_of_service_url }); + + const normalizedLinks = linkPairs + .map((l) => ({ ...l, url: normalizeUrl(l.url) })) + .filter((l): l is { label: string; url: string } => Boolean(l.url)); + + const certifications = + parsed.data.certifications?.map((c) => ({ + type: c.type, + status: c.status ?? 'unknown', + issuedAt: normalizeIso(c.issued_at ?? null), + expiresAt: normalizeIso(c.expires_at ?? null), + url: normalizeUrl(c.url ?? null), + })) ?? []; + + const news = + parsed.data.news?.map((n) => ({ + date: normalizeIso(n.date) ?? n.date, + title: n.title, + summary: n.summary ?? null, + source: n.source ?? null, + url: normalizeUrl(n.url ?? null), + sentiment: n.sentiment ?? null, + })) ?? []; + + const result: VendorRiskAssessmentDataV1 = { + kind: 'vendorRiskAssessmentV1', + vendorName, + vendorWebsite, + lastResearchedAt: normalizeIso(parsed.data.last_researched_at ?? null) ?? new Date().toISOString(), + riskLevel: parsed.data.risk_level ?? null, + securityAssessment: parsed.data.security_assessment ?? null, + certifications: certifications.length > 0 ? certifications : null, + links: normalizedLinks.length > 0 ? normalizedLinks : null, + news: news.length > 0 ? news : null, + }; + + logger.info('Firecrawl Agent SDK completed vendor research', { + vendorWebsite, + found: { + links: normalizedLinks.length, + certifications: certifications.length, + news: news.length, + }, + }); + + return result; +} + + diff --git a/apps/app/src/components/comments/CommentRichTextField.tsx b/apps/app/src/components/comments/CommentRichTextField.tsx index 834c14d9b..99a8048dd 100644 --- a/apps/app/src/components/comments/CommentRichTextField.tsx +++ b/apps/app/src/components/comments/CommentRichTextField.tsx @@ -131,7 +131,7 @@ export function CommentRichTextField({ }, [value, editor]); return ( -
+
); diff --git a/apps/app/src/components/task-items/TaskItemCreateDialog.tsx b/apps/app/src/components/task-items/TaskItemCreateDialog.tsx index 340c65b28..e91bc3002 100644 --- a/apps/app/src/components/task-items/TaskItemCreateDialog.tsx +++ b/apps/app/src/components/task-items/TaskItemCreateDialog.tsx @@ -40,7 +40,7 @@ export function TaskItemCreateDialog({ return ( - + Create task Add a new task for this record. diff --git a/apps/app/src/components/task-items/TaskItemEditableDescription.tsx b/apps/app/src/components/task-items/TaskItemEditableDescription.tsx index 05fff9ef6..aee43a1d4 100644 --- a/apps/app/src/components/task-items/TaskItemEditableDescription.tsx +++ b/apps/app/src/components/task-items/TaskItemEditableDescription.tsx @@ -303,9 +303,11 @@ export function TaskItemEditableDescription({ isSelectingFileRef.current = true; }} onFileSelectEnd={() => { + // Small delay to ensure React state update has propagated + // before allowing blur/save handlers to run setTimeout(() => { isSelectingFileRef.current = false; - }, 500); + }, 50); }} entityId={entityId} entityType={entityType} diff --git a/apps/app/src/components/task-items/TaskItemEditableTitle.tsx b/apps/app/src/components/task-items/TaskItemEditableTitle.tsx index dfd9304d1..03df7258f 100644 --- a/apps/app/src/components/task-items/TaskItemEditableTitle.tsx +++ b/apps/app/src/components/task-items/TaskItemEditableTitle.tsx @@ -1,11 +1,13 @@ import { useEffect, useRef, useState } from 'react'; import { toast } from 'sonner'; +import { cn } from '@/lib/utils'; interface TaskItemEditableTitleProps { title: string; isUpdating: boolean; onUpdate: (updates: { title?: string }) => Promise; onAfterUpdate?: () => void; + className?: string; } export function TaskItemEditableTitle({ @@ -13,6 +15,7 @@ export function TaskItemEditableTitle({ isUpdating, onUpdate, onAfterUpdate, + className, }: TaskItemEditableTitleProps) { const [isEditingTitle, setIsEditingTitle] = useState(false); const [editedTitle, setEditedTitle] = useState(title); @@ -73,14 +76,14 @@ export function TaskItemEditableTitle({ } }} disabled={isUpdating} - className="w-full text-2xl font-semibold bg-transparent border-none outline-none resize-none rounded px-2 py-1 -mx-2 -my-1 overflow-hidden" + className={cn('w-full text-2xl font-semibold bg-transparent border-none outline-none resize-none rounded px-2 py-1 -mx-2 -my-1 overflow-hidden', className)} rows={1} />
) : (

setIsEditingTitle(true)} - className="text-2xl font-semibold cursor-text hover:bg-accent/50 rounded px-2 py-1 -mx-2 -my-1 transition-colors" + className={cn('text-2xl font-semibold cursor-text hover:bg-accent/50 rounded px-2 py-1 -mx-2 -my-1 transition-colors', className)} > {title}

diff --git a/apps/app/src/components/task-items/TaskItemFocusSidebar.tsx b/apps/app/src/components/task-items/TaskItemFocusSidebar.tsx index 5b9fc95cb..0e665baf3 100644 --- a/apps/app/src/components/task-items/TaskItemFocusSidebar.tsx +++ b/apps/app/src/components/task-items/TaskItemFocusSidebar.tsx @@ -7,7 +7,8 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@comp/ui/dropdown-menu'; -import { Check, Link2, Tags, Trash2 } from 'lucide-react'; +import { Check, ArrowLeftFromLine, Link2, Tags, Trash2, User as UserIcon } from 'lucide-react'; +import { cn } from '@/lib/utils'; import { SelectAssignee } from '@/components/SelectAssignee'; import { toast } from 'sonner'; import type { TaskItem, TaskItemPriority, TaskItemStatus } from '@/hooks/use-task-items'; @@ -32,9 +33,11 @@ interface TaskItemFocusSidebarProps { isUpdating: boolean; copiedLink: boolean; copiedTaskId: boolean; + isCollapsed: boolean; onCopyLink: () => void; onCopyTaskId: () => void; onDelete: () => void; + onCollapse: () => void; onStatusChange: (status: TaskItemStatus) => Promise; onPriorityChange: (priority: TaskItemPriority) => Promise; onAssigneeChange: (assigneeId: string | null) => Promise; @@ -47,9 +50,11 @@ export function TaskItemFocusSidebar({ isUpdating, copiedLink, copiedTaskId, + isCollapsed, onCopyLink, onCopyTaskId, onDelete, + onCollapse, onStatusChange, onPriorityChange, onAssigneeChange, @@ -58,22 +63,191 @@ export function TaskItemFocusSidebar({ const StatusIcon = getStatusIcon(taskItem.status); const PriorityIcon = getPriorityIcon(taskItem.priority); + if (isCollapsed) { + return ( +
+ {/* Left border line */} +
+ + {/* All controls in one centered column */} +
+ {/* Collapse button */} + + + {/* Status */} + + + + + + {STATUS_OPTIONS.map((option) => { + const isSelected = taskItem.status === option.value; + const OptionIcon = getStatusIcon(option.value); + return ( + onStatusChange(option.value)} + className={`cursor-pointer ${isSelected ? 'bg-accent font-medium' : ''}`} + > +
+ + {option.label} + {isSelected && } +
+
+ ); + })} +
+
+ + + {/* Priority */} + + + + + + {PRIORITY_OPTIONS.map((option) => { + const isSelected = taskItem.priority === option.value; + const OptionIcon = getPriorityIcon(option.value); + return ( + onPriorityChange(option.value)} + className={`cursor-pointer ${isSelected ? 'bg-accent font-medium' : ''}`} + > +
+ + {option.label} + {isSelected && } +
+
+ ); + })} +
+
+ + + {/* Assignee */} + {assignableMembers && assignableMembers.length > 0 && ( + + + + + + onAssigneeChange(null)} + className={`cursor-pointer ${!taskItem.assignee ? 'bg-accent font-medium' : ''}`} + > +
+ + Unassigned + {!taskItem.assignee && } +
+
+ {assignableMembers.map((member) => { + const isSelected = taskItem.assignee?.id === member.id; + return ( + onAssigneeChange(member.id)} + className={`cursor-pointer ${isSelected ? 'bg-accent font-medium' : ''}`} + > +
+ {member.user.image ? ( + {member.user.name + ) : ( + + )} + {member.user.name || member.user.email} + {isSelected && } +
+
+ ); + })} +
+
+ )} +
+ + {/* Spacer */} +
+
+ ); + } + return ( -
+
{/* Left border line - extends full height */}
{/* Properties */}
-
+
+
+ -
+
+
+ ) : null} +
+
+
+ ); +} + +export function VendorRiskAssessmentCertificationsCard({ + certifications, + verifiedCount, + previewCount = 4, +}: { + certifications: VendorRiskAssessmentCertification[]; + verifiedCount: number; + previewCount?: number; +}) { + const [open, setOpen] = useState(false); + + const preview = useMemo( + () => certifications.slice(0, previewCount), + [certifications, previewCount], + ); + const rest = useMemo( + () => certifications.slice(previewCount), + [certifications, previewCount], + ); + + return ( + + + +
+ + Certifications +
+ {certifications.length > 0 ? ( + + {verifiedCount} verified + + ) : null} +
+
+ + {certifications.length === 0 ? ( +

No certifications found.

+ ) : ( + +
+ {preview.map((cert) => ( + + ))} + + {rest.length > 0 ? ( + + {rest.map((cert) => ( + + ))} + + ) : null} +
+ + {rest.length > 0 ? ( +
+ + + +
+ ) : null} +
+ )} +
+
+ ); +} + + diff --git a/apps/app/src/components/task-items/generated-task/vendor-risk-assessment/VendorRiskAssessmentTaskItemView.tsx b/apps/app/src/components/task-items/generated-task/vendor-risk-assessment/VendorRiskAssessmentTaskItemView.tsx new file mode 100644 index 000000000..9c3a139f9 --- /dev/null +++ b/apps/app/src/components/task-items/generated-task/vendor-risk-assessment/VendorRiskAssessmentTaskItemView.tsx @@ -0,0 +1,313 @@ +'use client'; + +import type { TaskItem } from '@/hooks/use-task-items'; +import { Card, CardContent, CardHeader, CardTitle } from '@comp/ui/card'; +import { Badge } from '@comp/ui/badge'; +import { Button } from '@comp/ui/button'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@comp/ui/collapsible'; +import { ExternalLink, FileText, ShieldCheck, Clock, TrendingDown, TrendingUp, Link2, ChevronDown, ChevronUp, Shield, Lock, FileCheck } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { format, isValid } from 'date-fns'; +import { parseVendorRiskAssessmentDescription } from './parse-vendor-risk-assessment-description'; +import type { VendorRiskAssessmentDataV1, VendorRiskAssessmentNewsItem } from './vendor-risk-assessment-types'; +import { VendorRiskAssessmentTimelineCard } from './VendorRiskAssessmentTimelineCard'; +import { VendorRiskAssessmentCertificationsCard } from './VendorRiskAssessmentCertificationsCard'; + +function formatLongDate(value: string | Date | null | undefined): string { + if (!value) return '—'; + const d = typeof value === 'string' ? new Date(value) : value; + if (!isValid(d)) return '—'; + return format(d, 'MMM d, yyyy'); +} + +function SecurityAssessmentContent({ text }: { text: string }) { + const [isExpanded, setIsExpanded] = useState(false); + const maxLength = 500; // Characters to show before "Show more" + const isLong = text.length > maxLength; + const preview = isLong ? text.slice(0, maxLength) : text; + const rest = isLong ? text.slice(maxLength) : ''; + + if (!isLong) { + return

{text}

; + } + + return ( + +
+
+

+ {preview} + {!isExpanded && rest && '...'} +

+
+
+ {isExpanded && ( + +

{rest}

+
+ )} +
+
+ + + +
+ + ); +} + +function getSentimentCounts(news: VendorRiskAssessmentNewsItem[] | null | undefined): { + positive: number; + negative: number; + neutral: number; +} { + const items = news ?? []; + return items.reduce( + (acc, item) => { + const s = item.sentiment ?? 'neutral'; + if (s === 'positive') acc.positive += 1; + else if (s === 'negative') acc.negative += 1; + else acc.neutral += 1; + return acc; + }, + { positive: 0, negative: 0, neutral: 0 }, + ); +} + +function getVerifiedCounts(data: VendorRiskAssessmentDataV1 | null) { + const certs = data?.certifications ?? []; + const total = certs?.length ?? 0; + const verified = certs?.filter((c) => c.status === 'verified').length ?? 0; + return { verified, total }; +} + +function getLinkIcon(label: string) { + const normalizedLabel = label.toLowerCase(); + if (normalizedLabel.includes('trust') || normalizedLabel.includes('security')) { + return Shield; + } + if (normalizedLabel.includes('soc') || normalizedLabel.includes('report')) { + return FileCheck; + } + if (normalizedLabel.includes('privacy')) { + return Lock; + } + if (normalizedLabel.includes('terms') || normalizedLabel.includes('service')) { + return FileText; + } + return Link2; +} + +export function VendorRiskAssessmentTaskItemView({ taskItem }: { taskItem: TaskItem }) { + const data = useMemo(() => { + return parseVendorRiskAssessmentDescription(taskItem.description); + }, [taskItem.description]); + + const { verified, total } = useMemo(() => getVerifiedCounts(data), [data]); + const sentiment = useMemo(() => getSentimentCounts(data?.news), [data?.news]); + + const links = data?.links ?? []; + const certs = data?.certifications ?? []; + const news = data?.news ?? []; + + const vendorName = + data?.vendorName ?? (taskItem.entityType === 'vendor' ? 'Vendor' : '—'); + + const addedBy = + taskItem.createdBy?.user?.name || + taskItem.createdBy?.user?.email || + 'Unknown'; + + const lastResearched = data?.lastResearchedAt ?? taskItem.createdAt; + + return ( +
+
+

{taskItem.title}

+

+ Automated vendor research summary for {vendorName} +

+
+ + {/* Top metrics */} +
+ + + Risk Level + + +
+
{data?.riskLevel ?? '—'}
+
+
+
+ + + + Certifications + + +
+
+ {total > 0 ? `${verified}/${total}` : '—'} +
+
+ +
+
+
+
+ + + + News Sentiment + + +
+ {news.length > 0 ? ( +
+
+ + {sentiment.positive} +
+
+ + {sentiment.negative} +
+
+ ) : ( +
+ )} +
+ +
+
+
+
+ + + + Last Researched + + +
+
{formatLongDate(lastResearched)}
+
+ +
+
+
+
+
+ + {/* Main content */} +
+
+ + + + + Security Assessment + + + + {data?.securityAssessment ? ( + + ) : ( +

+ No automated security assessment found. +

+ )} +
+
+ + +
+ +
+ + + + + Useful Links + + + + {links.length === 0 ? ( +

No links found.

+ ) : ( + links.map((link) => { + const LinkIcon = getLinkIcon(link.label); + return ( + + ); + }) + )} +
+
+ + + + + + Vendor Details + + +
+ Added by + {addedBy} +
+
+ Added on + + {formatLongDate(taskItem.createdAt)} + +
+
+
+
+
+
+ ); +} + + diff --git a/apps/app/src/components/task-items/generated-task/vendor-risk-assessment/VendorRiskAssessmentTimelineCard.tsx b/apps/app/src/components/task-items/generated-task/vendor-risk-assessment/VendorRiskAssessmentTimelineCard.tsx new file mode 100644 index 000000000..844baeddd --- /dev/null +++ b/apps/app/src/components/task-items/generated-task/vendor-risk-assessment/VendorRiskAssessmentTimelineCard.tsx @@ -0,0 +1,126 @@ +'use client'; + +import { Badge } from '@comp/ui/badge'; +import { Button } from '@comp/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@comp/ui/card'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@comp/ui/collapsible'; +import { Separator } from '@comp/ui/separator'; +import { ExternalLink, ChevronDown, ChevronUp, Clock } from 'lucide-react'; +import { format, isValid } from 'date-fns'; +import { useMemo, useState } from 'react'; +import type { VendorRiskAssessmentNewsItem } from './vendor-risk-assessment-types'; + +function formatLongDate(value: string | Date | null | undefined): string { + if (!value) return '—'; + const d = typeof value === 'string' ? new Date(value) : value; + if (!isValid(d)) return '—'; + return format(d, 'MMM d, yyyy'); +} + +function NewsRow({ item }: { item: VendorRiskAssessmentNewsItem }) { + return ( +
+
+ {formatLongDate(item.date)} + {item.source ? {item.source} : null} +
+ +
+
+

{item.title}

+ {item.summary ? ( +

{item.summary}

+ ) : null} +
+ + {item.url ? ( + + ) : null} +
+
+ ); +} + +export function VendorRiskAssessmentTimelineCard({ + news, + previewCount = 3, +}: { + news: VendorRiskAssessmentNewsItem[]; + previewCount?: number; +}) { + const [open, setOpen] = useState(false); + + const preview = useMemo(() => news.slice(0, previewCount), [news, previewCount]); + const rest = useMemo(() => news.slice(previewCount), [news, previewCount]); + + return ( + + + + + Timeline + + + + {news.length === 0 ? ( +

No recent news items were captured yet.

+ ) : ( + +
+ {preview.map((item) => ( +
+ + +
+ ))} + + {rest.length > 0 ? ( + + {rest.map((item) => ( +
+ + +
+ ))} +
+ ) : null} +
+ + {rest.length > 0 ? ( +
+ + + +
+ ) : null} +
+ )} +
+
+ ); +} + + diff --git a/apps/app/src/components/task-items/generated-task/vendor-risk-assessment/is-vendor-risk-assessment-task-item.ts b/apps/app/src/components/task-items/generated-task/vendor-risk-assessment/is-vendor-risk-assessment-task-item.ts new file mode 100644 index 000000000..3cfe41ed6 --- /dev/null +++ b/apps/app/src/components/task-items/generated-task/vendor-risk-assessment/is-vendor-risk-assessment-task-item.ts @@ -0,0 +1,51 @@ +import type { TaskItem } from '@/hooks/use-task-items'; + +const VENDOR_RISK_ASSESSMENT_TASK_TITLE = 'Risk Assessment'; +const VENDOR_RISK_ASSESSMENT_INSTRUCTION_SNIPPET = + 'Conduct a risk assessment for this vendor.'; + +function tryParseJson(value: string): unknown | null { + try { + return JSON.parse(value); + } catch { + return null; + } +} + +function hasVendorRiskAssessmentMarker(description: string | null | undefined): boolean { + if (!description) return false; + + const parsed = tryParseJson(description); + if (!parsed || typeof parsed !== 'object') return false; + + // Structured format (new) + if ('kind' in parsed && (parsed as { kind?: unknown }).kind === 'vendorRiskAssessmentV1') { + return true; + } + + // Legacy TipTap JSON description (old): check first paragraph contains the instruction sentence. + const type = (parsed as { type?: unknown }).type; + if (type !== 'doc') return false; + + const content = (parsed as { content?: unknown }).content; + if (!Array.isArray(content)) return false; + + const firstParagraph = content.find( + (node) => node && typeof node === 'object' && (node as { type?: unknown }).type === 'paragraph', + ) as { content?: Array<{ text?: string }> } | undefined; + + const firstText = + firstParagraph?.content?.find((c) => typeof c?.text === 'string')?.text ?? ''; + + return firstText.includes(VENDOR_RISK_ASSESSMENT_INSTRUCTION_SNIPPET); +} + +export function isVendorRiskAssessmentTaskItem(taskItem: TaskItem): boolean { + return ( + taskItem.entityType === 'vendor' && + taskItem.title === VENDOR_RISK_ASSESSMENT_TASK_TITLE && + hasVendorRiskAssessmentMarker(taskItem.description) + ); +} + + diff --git a/apps/app/src/components/task-items/generated-task/vendor-risk-assessment/parse-vendor-risk-assessment-description.ts b/apps/app/src/components/task-items/generated-task/vendor-risk-assessment/parse-vendor-risk-assessment-description.ts new file mode 100644 index 000000000..e6ab8a8cd --- /dev/null +++ b/apps/app/src/components/task-items/generated-task/vendor-risk-assessment/parse-vendor-risk-assessment-description.ts @@ -0,0 +1,134 @@ +import type { VendorRiskAssessmentDataV1, VendorRiskAssessmentLink } from './vendor-risk-assessment-types'; + +type TipTapNode = { + type?: string; + content?: TipTapNode[]; + text?: string; + marks?: Array<{ type?: string; attrs?: Record }>; +}; + +function tryParseJson(value: string): unknown | null { + try { + return JSON.parse(value); + } catch { + return null; + } +} + +function getText(node: TipTapNode | undefined): string { + if (!node) return ''; + if (typeof node.text === 'string') return node.text; + if (!node.content) return ''; + return node.content.map(getText).join(''); +} + +function isBoldTextNode(node: TipTapNode | undefined): boolean { + const marks = node?.marks; + if (!Array.isArray(marks)) return false; + return marks.some((m) => m?.type === 'bold'); +} + +function extractLinksFromBulletList(node: TipTapNode): VendorRiskAssessmentLink[] { + const list: VendorRiskAssessmentLink[] = []; + if (!Array.isArray(node.content)) return list; + + for (const li of node.content) { + const paragraph = li?.content?.find((c) => c?.type === 'paragraph'); + const textNode = paragraph?.content?.find((c) => typeof c?.text === 'string'); + const label = (textNode?.text ?? '').trim(); + + const linkMark = textNode?.marks?.find((m) => m?.type === 'link'); + const href = + (linkMark?.attrs?.href && typeof linkMark.attrs.href === 'string' + ? linkMark.attrs.href + : null) ?? null; + + if (label && href) { + list.push({ label, url: href }); + } + } + + return list; +} + +export function parseVendorRiskAssessmentDescription( + description: string | null | undefined, +): VendorRiskAssessmentDataV1 | null { + if (!description) return null; + + const parsed = tryParseJson(description); + if (!parsed || typeof parsed !== 'object') return null; + + // New structured format + if ('kind' in parsed && (parsed as { kind?: unknown }).kind === 'vendorRiskAssessmentV1') { + return parsed as VendorRiskAssessmentDataV1; + } + + // Legacy TipTap doc JSON: extract company overview, links, and certifications + if ((parsed as { type?: unknown }).type !== 'doc') return null; + + const content = (parsed as { content?: unknown }).content; + if (!Array.isArray(content)) return null; + + let companyOverview: string | null = null; + let links: VendorRiskAssessmentLink[] = []; + const certifications: string[] = []; + + for (let i = 0; i < content.length; i++) { + const node = content[i] as TipTapNode; + if (node?.type !== 'paragraph') continue; + + const firstChild = node.content?.[0]; + const labelText = (firstChild?.text ?? '').trim(); + const isLabel = isBoldTextNode(firstChild); + + if (!isLabel) continue; + + if (labelText === 'Company Overview:') { + const next = content[i + 1] as TipTapNode | undefined; + if (next?.type === 'paragraph') { + companyOverview = getText(next).trim() || null; + } + } + + if (labelText === 'Security Certifications:') { + const next = content[i + 1] as TipTapNode | undefined; + if (next?.type === 'bulletList' && Array.isArray(next.content)) { + for (const li of next.content) { + const text = getText(li).trim(); + if (text) certifications.push(text); + } + } + } + + if (labelText === 'Relevant Links:') { + const next = content[i + 1] as TipTapNode | undefined; + if (next?.type === 'bulletList') { + links = extractLinksFromBulletList(next); + } + } + } + + const legacyLinks = links.length > 0 ? links : null; + const legacyCerts = + certifications.length > 0 + ? certifications.map((c) => ({ type: c, status: 'unknown' as const })) + : null; + + // If we couldn't extract anything meaningful, treat as non-vendor-risk content + if (!companyOverview && !legacyLinks && !legacyCerts) return null; + + return { + kind: 'vendorRiskAssessmentV1', + vendorName: null, + vendorWebsite: null, + lastResearchedAt: null, + riskLevel: null, + securityAssessment: companyOverview, + links: legacyLinks, + certifications: legacyCerts, + news: null, + }; +} + + diff --git a/apps/app/src/components/task-items/generated-task/vendor-risk-assessment/vendor-risk-assessment-types.tsx b/apps/app/src/components/task-items/generated-task/vendor-risk-assessment/vendor-risk-assessment-types.tsx new file mode 100644 index 000000000..2fdbb2dba --- /dev/null +++ b/apps/app/src/components/task-items/generated-task/vendor-risk-assessment/vendor-risk-assessment-types.tsx @@ -0,0 +1,43 @@ +export type VendorRiskAssessmentCertificationStatus = + | 'verified' + | 'expired' + | 'not_certified' + | 'unknown'; + +export type VendorRiskAssessmentCertification = { + type: string; + status: VendorRiskAssessmentCertificationStatus; + issuedAt?: string | null; + expiresAt?: string | null; + url?: string | null; +}; + +export type VendorRiskAssessmentLink = { + label: string; + url: string; +}; + +export type VendorRiskAssessmentNewsSentiment = 'positive' | 'negative' | 'neutral'; + +export type VendorRiskAssessmentNewsItem = { + date: string; + title: string; + summary?: string | null; + source?: string | null; + url?: string | null; + sentiment?: VendorRiskAssessmentNewsSentiment | null; +}; + +export type VendorRiskAssessmentDataV1 = { + kind: 'vendorRiskAssessmentV1'; + vendorName?: string | null; + vendorWebsite?: string | null; + lastResearchedAt?: string | null; + riskLevel?: string | null; + securityAssessment?: string | null; + certifications?: VendorRiskAssessmentCertification[] | null; + links?: VendorRiskAssessmentLink[] | null; + news?: VendorRiskAssessmentNewsItem[] | null; +}; + + diff --git a/bun.lock b/bun.lock index 78b9a4ec6..2ffdbf1ce 100644 --- a/bun.lock +++ b/bun.lock @@ -79,6 +79,7 @@ "@browserbasehq/sdk": "^2.6.0", "@browserbasehq/stagehand": "^3.0.5", "@comp/integration-platform": "workspace:*", + "@mendable/firecrawl-js": "^4.9.3", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.0.1", @@ -1254,7 +1255,7 @@ "@mediapipe/tasks-vision": ["@mediapipe/tasks-vision@0.10.17", "", {}, "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg=="], - "@mendable/firecrawl-js": ["@mendable/firecrawl-js@1.29.3", "", { "dependencies": { "axios": "^1.11.0", "typescript-event-target": "^1.1.1", "zod": "^3.23.8", "zod-to-json-schema": "^3.23.0" } }, "sha512-+uvDktesJmVtiwxMtimq+3f5bKlsan4T7TokxOI7DbxFkApwrRNss5GYEXbInveMTz8LpGth/9Ch5BTwCqrpfA=="], + "@mendable/firecrawl-js": ["@mendable/firecrawl-js@4.9.3", "", { "dependencies": { "axios": "^1.12.2", "typescript-event-target": "^1.1.1", "zod": "^3.23.8", "zod-to-json-schema": "^3.23.0" } }, "sha512-1k6qv0RiFHanx1XQE+DqEjdaQk0IXbsz/MF7FFrHCQX/oPHXm3TtA5gNNvUIogfX1mghgkVthKObmBNoUhVB1Q=="], "@mermaid-js/parser": ["@mermaid-js/parser@0.6.3", "", { "dependencies": { "langium": "3.3.1" } }, "sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA=="], @@ -5828,6 +5829,8 @@ "@commitlint/types/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "@comp/app/@mendable/firecrawl-js": ["@mendable/firecrawl-js@1.29.3", "", { "dependencies": { "axios": "^1.11.0", "typescript-event-target": "^1.1.1", "zod": "^3.23.8", "zod-to-json-schema": "^3.23.0" } }, "sha512-+uvDktesJmVtiwxMtimq+3f5bKlsan4T7TokxOI7DbxFkApwrRNss5GYEXbInveMTz8LpGth/9Ch5BTwCqrpfA=="], + "@comp/app/@prisma/instrumentation": ["@prisma/instrumentation@6.6.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.52.0 || ^0.53.0 || ^0.54.0 || ^0.55.0 || ^0.56.0 || ^0.57.0" }, "peerDependencies": { "@opentelemetry/api": "^1.8" } }, "sha512-M/a6njz3hbf2oucwdbjNKrSMLuyMCwgDrmTtkF1pm4Nm7CU45J/Hd6lauF2CDACTUYzu3ymcV7P0ZAhIoj6WRw=="], "@comp/app/resend": ["resend@4.8.0", "", { "dependencies": { "@react-email/render": "1.1.2" } }, "sha512-R8eBOFQDO6dzRTDmaMEdpqrkmgSjPpVXt4nGfWsZdYOet0kqra0xgbvTES6HmCriZEXbmGk3e0DiGIaLFTFSHA=="], @@ -7036,6 +7039,8 @@ "@commitlint/top-level/find-up/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="], + "@comp/app/@mendable/firecrawl-js/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@comp/app/@prisma/instrumentation/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.57.2", "", { "dependencies": { "@opentelemetry/api-logs": "0.57.2", "@types/shimmer": "^1.2.0", "import-in-the-middle": "^1.8.1", "require-in-the-middle": "^7.1.1", "semver": "^7.5.2", "shimmer": "^1.2.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg=="], "@comp/app/resend/@react-email/render": ["@react-email/render@1.1.2", "", { "dependencies": { "html-to-text": "^9.0.5", "prettier": "^3.5.3", "react-promise-suspense": "^0.3.4" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-RnRehYN3v9gVlNMehHPHhyp2RQo7+pSkHDtXPvg3s0GbzM9SQMW4Qrf8GRNvtpLC4gsI+Wt0VatNRUFqjvevbw=="], From 463cd2db2a30a087ce03aaab094da22a83bfe511 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Wed, 24 Dec 2025 01:13:49 -0500 Subject: [PATCH 2/2] feat(api): enhance news item processing in firecrawl agent --- .../vendor-risk-assessment/firecrawl-agent.ts | 35 ++++++++++++++----- .../task-items/TaskItemFocusView.tsx | 3 +- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent.ts index e8693ad04..3c5ad6108 100644 --- a/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent.ts +++ b/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent.ts @@ -167,14 +167,33 @@ Focus on their official website (especially trust/security/compliance pages), pr })) ?? []; const news = - parsed.data.news?.map((n) => ({ - date: normalizeIso(n.date) ?? n.date, - title: n.title, - summary: n.summary ?? null, - source: n.source ?? null, - url: normalizeUrl(n.url ?? null), - sentiment: n.sentiment ?? null, - })) ?? []; + parsed.data.news + ?.flatMap((n) => { + const isoDate = normalizeIso(n.date); + if (!isoDate) { + // Avoid storing invalid/non-ISO dates (frontend uses Date parsing + formatting). + logger.debug('Skipping news item due to invalid date', { + vendorWebsite, + title: n.title, + date: n.date, + source: n.source ?? null, + }); + return []; + } + + return [ + { + date: isoDate, + title: n.title, + summary: n.summary ?? null, + source: n.source ?? null, + url: normalizeUrl(n.url ?? null), + sentiment: n.sentiment ?? null, + }, + ]; + }) + // Extra guard, in case future changes return null-ish entries + .filter(Boolean) ?? []; const result: VendorRiskAssessmentDataV1 = { kind: 'vendorRiskAssessmentV1', diff --git a/apps/app/src/components/task-items/TaskItemFocusView.tsx b/apps/app/src/components/task-items/TaskItemFocusView.tsx index e0362a07f..862b3679e 100644 --- a/apps/app/src/components/task-items/TaskItemFocusView.tsx +++ b/apps/app/src/components/task-items/TaskItemFocusView.tsx @@ -32,6 +32,7 @@ import { Comments } from '../comments/Comments'; import { CommentEntityType } from '@db'; import { GeneratedTaskItemMainContent } from './generated-task/GeneratedTaskItemMainContent'; import { CustomTaskItemMainContent } from './custom-task/CustomTaskItemMainContent'; +import { isVendorRiskAssessmentTaskItem } from './generated-task/vendor-risk-assessment/is-vendor-risk-assessment-task-item'; interface TaskItemFocusViewProps { taskItem: TaskItem; @@ -67,7 +68,7 @@ export function TaskItemFocusView({ const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const pathname = usePathname(); - const isGeneratedTask = taskItem.title === 'Risk Assessment' && taskItem.entityType === 'vendor'; + const isGeneratedTask = isVendorRiskAssessmentTaskItem(taskItem); const { optimisticUpdate, optimisticDelete } = useOptimisticTaskItems( entityId,