diff --git a/apps/api/src/task-management/task-management.service.ts b/apps/api/src/task-management/task-management.service.ts index 559ef27f3..2ef9ac7b0 100644 --- a/apps/api/src/task-management/task-management.service.ts +++ b/apps/api/src/task-management/task-management.service.ts @@ -298,8 +298,8 @@ export class TaskManagementService { `Created task item: ${taskItem.id} for organization ${organizationId} by ${member.id}`, ); - // Log task creation in audit log - void this.auditService.logTaskItemCreated({ + // Log task creation in audit log first (await to ensure it's created before assignment log) + await this.auditService.logTaskItemCreated({ taskItemId: taskItem.id, organizationId, userId: authContext.userId, @@ -325,9 +325,9 @@ export class TaskManagementService { assignedByUserId: authContext.userId, }); - // Log initial assignment in audit log + // Log initial assignment in audit log (after creation log to ensure correct order) if (taskItem.assignee) { - void this.auditService.logTaskItemAssigned({ + await this.auditService.logTaskItemAssigned({ taskItemId: taskItem.id, organizationId, userId: authContext.userId, diff --git a/apps/api/src/trigger/vendor/backfill-vendor-risk-assessment-tasks.ts b/apps/api/src/trigger/vendor/backfill-vendor-risk-assessment-tasks.ts deleted file mode 100644 index 6781ec626..000000000 --- a/apps/api/src/trigger/vendor/backfill-vendor-risk-assessment-tasks.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { db } from '@db'; -import { logger, schemaTask } from '@trigger.dev/sdk'; -import { z } from 'zod'; -import { VENDOR_RISK_ASSESSMENT_TASK_ID, VENDOR_RISK_ASSESSMENT_TASK_TITLE } from './vendor-risk-assessment/constants'; -import { vendorRiskAssessmentTask } from './vendor-risk-assessment-task'; - -const schema = z.object({ - organizationId: z.string().optional(), - /** - * Backfill default is true so existing vendors get enriched with links/overview. - * If you want a cheaper/faster run, set this to false. - */ - withResearch: z.boolean().optional().default(true), - /** - * Safety limit per run (per org or total). - */ - limit: z.number().int().min(1).max(5000).optional().default(500), - /** - * If true, will not create tasks; only logs how many would be created. - */ - dryRun: z.boolean().optional().default(false), -}); - -export const backfillVendorRiskAssessmentTasks = schemaTask({ - id: 'backfill-vendor-risk-assessment-tasks', - schema, - maxDuration: 1000 * 60 * 15, - run: async (payload) => { - logger.info('Backfill vendor risk assessment tasks started', payload); - - const orgIds = payload.organizationId - ? [payload.organizationId] - : ( - await db.organization.findMany({ - select: { id: true }, - }) - ).map((o) => o.id); - - let totalMissing = 0; - let totalTriggered = 0; - - for (const organizationId of orgIds) { - // Fetch all vendors for org (lightweight) - const vendors = await db.vendor.findMany({ - where: { organizationId }, - select: { id: true, name: true, website: true }, - }); - - if (vendors.length === 0) continue; - - // Fetch existing Risk Assessment task items for vendors in this org - const existingTaskItems = await db.taskItem.findMany({ - where: { - organizationId, - entityType: 'vendor', - title: VENDOR_RISK_ASSESSMENT_TASK_TITLE, - }, - select: { entityId: true }, - }); - - const vendorIdsWithTasks = new Set(existingTaskItems.map((t) => t.entityId)); - const missingVendors = vendors.filter((v) => !vendorIdsWithTasks.has(v.id)); - - const limitedMissing = missingVendors.slice(0, payload.limit - totalMissing); - totalMissing += limitedMissing.length; - - logger.info('Backfill org scan', { - organizationId, - vendors: vendors.length, - existingTasks: vendorIdsWithTasks.size, - missing: limitedMissing.length, - }); - - if (payload.dryRun) { - if (totalMissing >= payload.limit) break; - continue; - } - - if (limitedMissing.length > 0) { - const batch = limitedMissing.map((v) => ({ - payload: { - vendorId: v.id, - vendorName: v.name, - vendorWebsite: v.website, - organizationId, - createdByUserId: null, - withResearch: payload.withResearch, - }, - })); - - await vendorRiskAssessmentTask.batchTrigger(batch); - totalTriggered += batch.length; - } - - if (totalMissing >= payload.limit) break; - } - - logger.info('Backfill vendor risk assessment tasks completed', { - totalMissing, - totalTriggered, - dryRun: payload.dryRun, - }); - - return { - success: true, - totalMissing, - totalTriggered, - dryRun: payload.dryRun, - }; - }, -}); - - diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment-monthly-schedule.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment-monthly-schedule.ts new file mode 100644 index 000000000..6ccce608c --- /dev/null +++ b/apps/api/src/trigger/vendor/vendor-risk-assessment-monthly-schedule.ts @@ -0,0 +1,92 @@ +import { db } from '@db'; +import { logger, schedules } from '@trigger.dev/sdk'; +import { vendorRiskAssessmentTask } from './vendor-risk-assessment-task'; + +/** + * Monthly scheduled task that refreshes risk assessments for all vendors. + * Runs on the 1st of each month at 2:00 AM UTC. + */ +export const vendorRiskAssessmentMonthlySchedule = schedules.task({ + id: 'vendor-risk-assessment-monthly-schedule', + cron: '0 2 1 * *', // 1st of each month at 2:00 AM UTC + maxDuration: 1000 * 60 * 60, // 1 hour (for batch processing) + run: async (payload) => { + logger.info('Monthly vendor risk assessment refresh started', { + scheduledAt: payload.timestamp, + lastRun: payload.lastTimestamp, + }); + + // Find all vendors across all organizations that have websites + const vendors = await db.vendor.findMany({ + where: { + website: { + not: null, + }, + }, + select: { + id: true, + name: true, + website: true, + organizationId: true, + }, + }); + + logger.info(`Found ${vendors.length} unique vendors with websites`); + + if (vendors.length === 0) { + return { + success: true, + totalVendors: 0, + triggered: 0, + message: 'No vendors with websites found', + }; + } + + // Process ALL vendors - monthly refresh for everyone + // This ensures all vendors get updated risk assessments monthly + logger.info(`Processing all ${vendors.length} vendors for monthly refresh`); + + // Batch trigger risk assessment tasks with research enabled for ALL vendors + // This will: + // - Create new assessments for vendors without data (v1) + // - Refresh existing assessments and increment version (v1 -> v2, v2 -> v3, etc.) + const batch = vendors.map((vendor) => ({ + payload: { + vendorId: vendor.id, + vendorName: vendor.name, + vendorWebsite: vendor.website!, + organizationId: vendor.organizationId, + createdByUserId: null, // System-initiated + withResearch: true, // Always do research for monthly refresh + }, + })); + + try { + await vendorRiskAssessmentTask.batchTrigger(batch); + logger.info(`Triggered ${batch.length} vendor risk assessment tasks`, { + totalVendors: vendors.length, + triggered: batch.length, + }); + + return { + success: true, + totalVendors: vendors.length, + triggered: batch.length, + message: `Triggered monthly refresh for ${batch.length} vendors`, + }; + } catch (error) { + logger.error('Failed to trigger batch risk assessment tasks', { + error: error instanceof Error ? error.message : String(error), + batchSize: batch.length, + }); + + return { + success: false, + totalVendors: vendors.length, + triggered: 0, + error: error instanceof Error ? error.message : String(error), + }; + } + }, +}); + 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 28a5bfd82..cfa7aee7e 100644 --- a/apps/api/src/trigger/vendor/vendor-risk-assessment-task.ts +++ b/apps/api/src/trigger/vendor/vendor-risk-assessment-task.ts @@ -1,11 +1,15 @@ -import { db, TaskItemPriority, TaskItemStatus, type TaskItemEntityType } from '@db'; +import { + db, + TaskItemPriority, + TaskItemStatus, + VendorStatus, + type TaskItemEntityType, +} from '@db'; +import type { Prisma } from '@prisma/client'; import { logger, queue, schemaTask } from '@trigger.dev/sdk'; import { resolveTaskCreatorAndAssignee } from './vendor-risk-assessment/assignee'; -import { - VENDOR_RISK_ASSESSMENT_TASK_ID, - VENDOR_RISK_ASSESSMENT_TASK_TITLE, -} from './vendor-risk-assessment/constants'; +import { VENDOR_RISK_ASSESSMENT_TASK_ID } from './vendor-risk-assessment/constants'; import { buildRiskAssessmentDescription } from './vendor-risk-assessment/description'; import { firecrawlAgentVendorRiskAssessment } from './vendor-risk-assessment/firecrawl-agent'; import { @@ -14,53 +18,170 @@ import { } from './vendor-risk-assessment/frameworks'; import { vendorRiskAssessmentPayloadSchema } from './vendor-risk-assessment/schema'; -async function logAutomatedTaskCreation(params: { - organizationId: string; - taskItemId: string; - taskTitle: string; - memberId: string; - entityType: string; - entityId: string; -}) { - try { - const member = await db.member.findUnique({ - where: { id: params.memberId }, - select: { - id: true, - userId: true, - }, - }); +const VERIFY_RISK_ASSESSMENT_TASK_TITLE = 'Verify risk assessment' as const; - if (!member?.userId) { - logger.warn('Unable to log task creation: member userId not found', { - memberId: params.memberId, - taskItemId: params.taskItemId, - }); - return; +function parseVersionNumber(version: string | null | undefined): number { + if (!version || !version.startsWith('v')) return 0; + const n = Number.parseInt(version.slice(1), 10); + return Number.isFinite(n) ? n : 0; +} + +function maxVersion( + vendors: Array<{ riskAssessmentVersion: string | null | undefined }>, +): string | null { + let best: string | null = null; + let bestN = 0; + for (const v of vendors) { + const n = parseVersionNumber(v.riskAssessmentVersion); + if (n > bestN) { + bestN = n; + best = v.riskAssessmentVersion ?? null; } + } + return best; +} - await db.auditLog.create({ - data: { - organizationId: params.organizationId, - userId: member.userId, - memberId: params.memberId, - entityType: 'task', - entityId: params.taskItemId, - description: 'created this task', - data: { - action: 'created', - taskItemId: params.taskItemId, - taskTitle: params.taskTitle, - parentEntityType: params.entityType, - parentEntityId: params.entityId, - }, - }, - }); +async function withAdvisoryLock({ + lockKey, + run, +}: { + lockKey: string; + run: () => Promise; +}): Promise { + // We use a Postgres advisory lock keyed by website/domain to serialize + // the final "version increment + write" step (short critical section). + // If the DB isn't Postgres or the lock fails, we fall back to running without a lock. + try { + await db.$executeRaw`SELECT pg_advisory_lock(hashtext(${lockKey}))`; + try { + return await run(); + } finally { + await db.$executeRaw`SELECT pg_advisory_unlock(hashtext(${lockKey}))`; + } } catch (error) { - logger.error('Failed to log automated task creation', { + logger.warn('Advisory lock unavailable; proceeding without lock', { + lockKey, error: error instanceof Error ? error.message : String(error), - taskItemId: params.taskItemId, }); + return await run(); + } +} + +/** + * Increments version number (v1 -> v2 -> v3, etc.) + */ +function incrementVersion(currentVersion: string | null | undefined): string { + if (!currentVersion || !currentVersion.startsWith('v')) { + return 'v1'; + } + const versionNumber = parseInt(currentVersion.slice(1), 10); + if (isNaN(versionNumber)) { + return 'v1'; + } + return `v${versionNumber + 1}`; +} + +/** + * Determines if research is needed. + * If withResearch is true, always do research (task was triggered because research is needed). + * Otherwise, check if data exists - if not, do research. + */ +function shouldDoResearch( + globalVendor: { riskAssessmentData: unknown; riskAssessmentVersion: string | null } | null, + withResearch: boolean, +): boolean { + // If withResearch is true, task was triggered because research is needed (we filter before triggering) + if (withResearch) { + return true; + } + + // Fallback: do research if vendor doesn't exist in GlobalVendors or has no data + // (This shouldn't happen if filtering works correctly, but kept as safety check) + if (!globalVendor || !globalVendor.riskAssessmentData) { + return true; + } + + // Otherwise, skip research (use existing data) + return false; +} + +function isJsonInputValue(value: unknown): value is Prisma.InputJsonValue { + if ( + value === null || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + return true; + } + + if (Array.isArray(value)) { + return value.every(isJsonInputValue); + } + + if (typeof value === 'object') { + return Object.values(value as Record).every(isJsonInputValue); + } + + return false; +} + +function parseRiskAssessmentJson(value: string): Prisma.InputJsonValue { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch (error) { + throw new Error( + `Failed to parse vendor risk assessment JSON: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + if (!isJsonInputValue(parsed)) { + throw new Error('Parsed vendor risk assessment is not valid JSON'); + } + + return parsed; +} + +/** + * Extract domain from website URL for GlobalVendors lookup. + * Removes www. prefix and returns just the domain (e.g., "example.com"). + */ +function extractDomain(website: string | null | undefined): string | null { + if (!website) return null; + + const trimmed = website.trim(); + if (!trimmed) return null; + + try { + // Add protocol if missing to make URL parsing work + const urlString = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; + const url = new URL(urlString); + // Remove www. prefix and return just the domain + return url.hostname.toLowerCase().replace(/^www\./, ''); + } catch { + return null; + } +} + +function normalizeWebsite(website: string): string | null { + const trimmed = website.trim(); + if (!trimmed) return null; + + // Require explicit protocol (do not silently force https) + if (!/^https?:\/\//i.test(trimmed)) { + return null; + } + + try { + const url = new URL(trimmed); + const protocol = url.protocol.toLowerCase(); + const hostname = url.hostname.toLowerCase().replace(/^www\./, ''); + const port = url.port ? `:${url.port}` : ''; + // Canonical key ignores path/query/hash + return `${protocol}//${hostname}${port}`; + } catch { + return null; } } @@ -76,81 +197,291 @@ export const vendorRiskAssessmentTask = schemaTask({ }, maxDuration: 1000 * 60 * 10, run: async (payload) => { - logger.info('Vendor risk assessment task started', { - vendorId: payload.vendorId, - organizationId: payload.organizationId, - }); - // Dedupe: don't create multiple identical tasks for the same vendor - const existing = await db.taskItem.findFirst({ + const vendor = await db.vendor.findFirst({ where: { + id: payload.vendorId, organizationId: payload.organizationId, - entityType: 'vendor' as TaskItemEntityType, - entityId: payload.vendorId, - title: VENDOR_RISK_ASSESSMENT_TASK_TITLE, }, - select: { id: true, status: true, createdById: true, assigneeId: true }, + select: { + id: true, + website: true, + status: true, + }, }); - // If an existing task is already complete (i.e. not "generating"), don't create another one. - if (existing && existing.status !== TaskItemStatus.in_progress) { - logger.info('Risk assessment task already exists for vendor, skipping', { - vendorId: payload.vendorId, - taskItemId: existing.id, + if (!vendor) { + throw new Error( + `Vendor ${payload.vendorId} not found in org ${payload.organizationId}`, + ); + } + + if (!vendor.website) { + logger.info('⏭️ SKIP (no website)', { vendor: payload.vendorName }); + // Mark vendor as assessed even without website (no risk assessment possible) + await db.vendor.update({ + where: { id: vendor.id }, + data: { status: VendorStatus.assessed }, + }); + return { + success: true, + vendorId: vendor.id, + deduped: false, + researched: false, + skipped: true, + reason: 'no_website', + riskAssessmentVersion: null, + }; + } + + const normalizedWebsite = normalizeWebsite(vendor.website); + if (!normalizedWebsite) { + logger.info('⏭️ SKIP (invalid website)', { vendor: payload.vendorName, website: vendor.website }); + await db.vendor.update({ + where: { id: vendor.id }, + data: { status: VendorStatus.assessed }, + }); + return { + success: true, + vendorId: vendor.id, + deduped: false, + researched: false, + skipped: true, + reason: 'invalid_website', + riskAssessmentVersion: null, + }; + } + + // Check GlobalVendors for existing risk assessment using domain-based lookup + // Find ALL duplicates to update them all (not just the most recent) + const domain = extractDomain(vendor.website); + const globalVendors = domain + ? await db.globalVendors.findMany({ + where: { + website: { + contains: domain, + }, + }, + select: { + website: true, + riskAssessmentVersion: true, + riskAssessmentUpdatedAt: true, + riskAssessmentData: true, + }, + orderBy: [ + { riskAssessmentUpdatedAt: 'desc' }, + { createdAt: 'desc' }, + ], + }) + : []; + + // Use the most recent one for reading/checking, but we'll update all duplicates + const globalVendor = globalVendors[0] ?? null; + + // Determine if research is needed + // If withResearch is true, task was triggered because research is needed (we filter before triggering) + const needsResearch = shouldDoResearch(globalVendor, payload.withResearch ?? false); + + if (needsResearch) { + logger.info('🔍 DOING RESEARCH', { + vendor: payload.vendorName, + website: normalizedWebsite, + }); + } else { + // This shouldn't happen if filtering works correctly, but kept as safety + logger.info('✅ SKIP RESEARCH (already has data)', { + vendor: payload.vendorName, + website: normalizedWebsite, + version: globalVendor ? globalVendor.riskAssessmentVersion : null, + }); + + // Still ensure a "Verify risk assessment" task exists so humans can confirm accuracy, + // even when we are reusing cached GlobalVendors data (no research performed). + const { creatorMemberId, assigneeMemberId } = await resolveTaskCreatorAndAssignee({ + organizationId: payload.organizationId, + createdByUserId: payload.createdByUserId ?? null, + }); + + const creatorMember = await db.member.findUnique({ + where: { id: creatorMemberId }, + select: { id: true, userId: true }, + }); + + const existingVerifyTask = await db.taskItem.findFirst({ + where: { + organizationId: payload.organizationId, + entityType: 'vendor' as TaskItemEntityType, + entityId: payload.vendorId, + title: VERIFY_RISK_ASSESSMENT_TASK_TITLE, + }, + select: { id: true, status: true }, + orderBy: { createdAt: 'desc' }, + }); + + const isNewTask = !existingVerifyTask; + const verifyTaskItemId = + existingVerifyTask?.id ?? + ( + await db.taskItem.create({ + data: { + title: VERIFY_RISK_ASSESSMENT_TASK_TITLE, + description: 'Review the latest Risk Assessment and confirm it is accurate.', + status: TaskItemStatus.todo, + priority: TaskItemPriority.high, + entityId: payload.vendorId, + entityType: 'vendor', + organizationId: payload.organizationId, + createdById: creatorMemberId, + assigneeId: assigneeMemberId, + }, + select: { id: true }, + }) + ).id; + + // If task already exists but is still blocked, flip it to todo (unless done/canceled). + await db.taskItem.updateMany({ + where: { + id: verifyTaskItemId, + status: { notIn: [TaskItemStatus.done, TaskItemStatus.canceled] }, + }, + data: { + status: TaskItemStatus.todo, + description: 'Review the latest Risk Assessment and confirm it is accurate.', + assigneeId: assigneeMemberId, + updatedById: creatorMemberId, + }, }); - return { success: true, taskItemId: existing.id, deduped: true }; + + // Audit log for automated task creation (best-effort) + if (isNewTask && creatorMember?.userId) { + try { + await db.auditLog.create({ + data: { + organizationId: payload.organizationId, + userId: creatorMember.userId, + memberId: creatorMember.id, + entityType: 'task', + entityId: verifyTaskItemId, + description: 'created this task', + data: { + action: 'created', + taskItemId: verifyTaskItemId, + taskTitle: VERIFY_RISK_ASSESSMENT_TASK_TITLE, + parentEntityType: 'vendor', + parentEntityId: payload.vendorId, + }, + }, + }); + } catch (error) { + logger.error('Failed to log task item creation:', error); + } + } + + // Still mark the org-specific vendor as assessed + await db.vendor.update({ + where: { id: vendor.id }, + data: { status: VendorStatus.assessed }, + }); + return { + success: true, + vendorId: vendor.id, + deduped: true, + researched: false, + riskAssessmentVersion: globalVendor?.riskAssessmentVersion ?? 'v1', + }; } + // Mark vendor as in-progress immediately so UI can show "generating" + await db.vendor.update({ + where: { id: vendor.id }, + data: { + status: VendorStatus.in_progress, + }, + }); + const { creatorMemberId, assigneeMemberId } = await resolveTaskCreatorAndAssignee({ organizationId: payload.organizationId, createdByUserId: payload.createdByUserId ?? null, }); - // focused frameworks - const organizationFrameworks = getDefaultFrameworks(); - const frameworkChecklist = buildFrameworkChecklist(organizationFrameworks); - // Create a placeholder task immediately so UI can show a skeleton while research runs. - // If an in-progress placeholder already exists, reuse it. - const taskItemId = - existing?.id ?? + // Get creator member with userId for activity log + const creatorMember = await db.member.findUnique({ + where: { id: creatorMemberId }, + select: { id: true, userId: true }, + }); + + if (!creatorMember?.userId) { + logger.warn('Creator member has no userId, skipping activity log creation', { + creatorMemberId, + organizationId: payload.organizationId, + }); + } + + // Ensure a "Verify risk assessment" task exists immediately, but keep it blocked while generation runs. + // We represent "blocked" as status=in_progress to prevent the team from treating it as ready. + const existingVerifyTask = await db.taskItem.findFirst({ + where: { + organizationId: payload.organizationId, + entityType: 'vendor' as TaskItemEntityType, + entityId: payload.vendorId, + title: VERIFY_RISK_ASSESSMENT_TASK_TITLE, + }, + select: { id: true, status: true }, + orderBy: { createdAt: 'desc' }, + }); + + const isNewTask = !existingVerifyTask; + const verifyTaskItemId = + existingVerifyTask?.id ?? ( await db.taskItem.create({ data: { - title: VENDOR_RISK_ASSESSMENT_TASK_TITLE, - // Keep a structured marker so frontend can reliably detect this task type, - // but keep status=in_progress so it renders as "generating". - description: buildRiskAssessmentDescription({ - vendorName: payload.vendorName, - vendorWebsite: payload.vendorWebsite ?? null, - research: null, - frameworkChecklist, - organizationFrameworks, - }), + title: VERIFY_RISK_ASSESSMENT_TASK_TITLE, + description: 'Waiting for risk assessment generation to complete.', status: TaskItemStatus.in_progress, priority: TaskItemPriority.high, entityId: payload.vendorId, entityType: 'vendor', organizationId: payload.organizationId, - assigneeId: assigneeMemberId, createdById: creatorMemberId, + assigneeId: assigneeMemberId, }, select: { id: true }, }) ).id; - if (!existing) { - await logAutomatedTaskCreation({ + // Create activity log for new task creation + if (isNewTask && creatorMember?.userId) { + try { + await db.auditLog.create({ + data: { organizationId: payload.organizationId, - taskItemId, - taskTitle: VENDOR_RISK_ASSESSMENT_TASK_TITLE, + userId: creatorMember.userId, memberId: creatorMemberId, - entityType: 'vendor', - entityId: payload.vendorId, + entityType: 'task', + entityId: verifyTaskItemId, + description: 'created this task', + data: { + action: 'created', + taskItemId: verifyTaskItemId, + taskTitle: VERIFY_RISK_ASSESSMENT_TASK_TITLE, + parentEntityType: 'vendor', + parentEntityId: payload.vendorId, + }, + }, }); + } catch (error) { + logger.error('Failed to log task item creation:', error); + // Don't throw - audit log failures should not block operations + } } - const research = - payload.withResearch && payload.vendorWebsite + // Focused frameworks + const organizationFrameworks = getDefaultFrameworks(); + const frameworkChecklist = buildFrameworkChecklist(organizationFrameworks); + + // Do research if needed (vendor doesn't exist, no data, or explicitly requested) + const research = needsResearch && payload.vendorWebsite ? await firecrawlAgentVendorRiskAssessment({ vendorName: payload.vendorName, vendorWebsite: payload.vendorWebsite, @@ -165,26 +496,115 @@ export const vendorRiskAssessmentTask = schemaTask({ organizationFrameworks, }); - // Mark as ready for normal UX: clickable + full renderer - await db.taskItem.update({ - where: { id: taskItemId }, + const data = parseRiskAssessmentJson(description); + + // Upsert GlobalVendors with risk assessment data (shared across all organizations) + // Version is auto-incremented (v1 -> v2 -> v3, etc.) + // Concurrency: serialize the final "read latest version + write + bump version" step. + const lockKey = domain ?? normalizedWebsite; + const { nextVersion, updatedWebsites } = await withAdvisoryLock({ + lockKey, + run: async () => { + const latestGlobalVendors = domain + ? await db.globalVendors.findMany({ + where: { website: { contains: domain } }, + select: { + website: true, + riskAssessmentVersion: true, + riskAssessmentUpdatedAt: true, + }, + orderBy: [{ riskAssessmentUpdatedAt: 'desc' }, { createdAt: 'desc' }], + }) + : []; + + const currentMax = maxVersion(latestGlobalVendors); + const computedNext = incrementVersion(currentMax); + const now = new Date(); + + if (latestGlobalVendors.length > 0) { + for (const gv of latestGlobalVendors) { + await db.globalVendors.update({ + where: { website: gv.website }, + data: { + company_name: payload.vendorName, + riskAssessmentData: data, + riskAssessmentVersion: computedNext, + riskAssessmentUpdatedAt: now, + }, + }); + } + return { + nextVersion: computedNext, + updatedWebsites: latestGlobalVendors.map((gv) => gv.website), + }; + } + + await db.globalVendors.upsert({ + where: { website: normalizedWebsite }, + create: { + website: normalizedWebsite, + company_name: payload.vendorName, + riskAssessmentData: data, + riskAssessmentVersion: computedNext, + riskAssessmentUpdatedAt: now, + }, + update: { + company_name: payload.vendorName, + riskAssessmentData: data, + riskAssessmentVersion: computedNext, + riskAssessmentUpdatedAt: now, + }, + }); + + return { nextVersion: computedNext, updatedWebsites: [normalizedWebsite] }; + }, + }); + + if (updatedWebsites.length > 1) { + logger.info('Updated multiple duplicates', { + vendor: payload.vendorName, + count: updatedWebsites.length, + websites: updatedWebsites, + }); + } + + // Mark org-specific vendor as assessed + await db.vendor.update({ + where: { id: vendor.id }, + data: { + status: VendorStatus.assessed, + }, + }); + + // Flip verify task to "todo" once the risk assessment is ready (only if it wasn't already completed/canceled). + await db.taskItem.updateMany({ + where: { + id: verifyTaskItemId, + status: { notIn: [TaskItemStatus.done, TaskItemStatus.canceled] }, + }, data: { - description, status: TaskItemStatus.todo, - // Keep stable creator/assignee for reused placeholders - assigneeId: existing?.assigneeId ?? assigneeMemberId, - updatedById: existing?.createdById ?? creatorMemberId, + description: 'Review the latest Risk Assessment and confirm it is accurate.', + // Keep stable assignee/creator + assigneeId: assigneeMemberId, + updatedById: creatorMemberId, }, - select: { id: true }, }); - logger.info('Created vendor risk assessment task item', { - vendorId: payload.vendorId, - taskItemId, + logger.info('✅ COMPLETED', { + vendor: payload.vendorName, researched: Boolean(research), + version: nextVersion, }); - return { success: true, taskItemId, deduped: Boolean(existing), researched: Boolean(research) }; + return { + success: true, + vendorId: vendor.id, + deduped: false, + researched: Boolean(research), + riskAssessmentVersion: nextVersion, + verifyTaskItemId, + }; }, }); diff --git a/apps/api/src/vendors/internal-vendor-automation.controller.ts b/apps/api/src/vendors/internal-vendor-automation.controller.ts index 3ecce3873..dcfb8b787 100644 --- a/apps/api/src/vendors/internal-vendor-automation.controller.ts +++ b/apps/api/src/vendors/internal-vendor-automation.controller.ts @@ -33,7 +33,8 @@ export class InternalVendorAutomationController { const result = await this.vendorsService.triggerVendorRiskAssessments({ organizationId: body.organizationId, - withResearch: body.withResearch ?? true, + // Default to "ensure" mode (cheap). Only scheduled refreshes should force research. + withResearch: body.withResearch ?? false, vendors: body.vendors, }); diff --git a/apps/api/src/vendors/vendors.service.ts b/apps/api/src/vendors/vendors.service.ts index 10053b1dc..e5237802f 100644 --- a/apps/api/src/vendors/vendors.service.ts +++ b/apps/api/src/vendors/vendors.service.ts @@ -1,9 +1,55 @@ import { Injectable, NotFoundException, Logger } from '@nestjs/common'; -import { db } from '@trycompai/db'; +import { db, TaskItemPriority, TaskItemStatus } from '@trycompai/db'; import { CreateVendorDto } from './dto/create-vendor.dto'; import { UpdateVendorDto } from './dto/update-vendor.dto'; import { tasks } from '@trigger.dev/sdk'; +import { Prisma } from '@prisma/client'; import type { TriggerVendorRiskAssessmentVendorDto } from './dto/trigger-vendor-risk-assessment.dto'; +import { resolveTaskCreatorAndAssignee } from '../trigger/vendor/vendor-risk-assessment/assignee'; + +const normalizeWebsite = (website: string | null | undefined): string | null => { + if (!website) return null; + const trimmed = website.trim(); + if (!trimmed) return null; + + // Require explicit protocol (do not silently force https) + if (!/^https?:\/\//i.test(trimmed)) { + return null; + } + + try { + const url = new URL(trimmed); + const protocol = url.protocol.toLowerCase(); + const hostname = url.hostname.toLowerCase().replace(/^www\./, ''); + const port = url.port ? `:${url.port}` : ''; + return `${protocol}//${hostname}${port}`; + } catch { + return null; + } +}; + +/** + * Extract domain from website URL for GlobalVendors lookup. + * Removes www. prefix and returns just the domain (e.g., "example.com"). + */ +const extractDomain = (website: string | null | undefined): string | null => { + if (!website) return null; + + const trimmed = website.trim(); + if (!trimmed) return null; + + try { + // Add protocol if missing to make URL parsing work + const urlString = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; + const url = new URL(urlString); + // Remove www. prefix and return just the domain + return url.hostname.toLowerCase().replace(/^www\./, ''); + } catch { + return null; + } +}; + +const VERIFY_RISK_ASSESSMENT_TASK_TITLE = 'Verify risk assessment' as const; @Injectable() export class VendorsService { @@ -115,44 +161,215 @@ export class VendorsService { return { triggered: 0, batchId: null }; } - this.logger.log('Preparing to batch trigger vendor risk assessment tasks', { - organizationId, - vendorCount: vendors.length, - withResearch, - vendorIds: vendors.map((v) => v.vendorId), - }); + // If we are NOT forcing research, avoid triggering runs for vendors that already have + // GlobalVendors riskAssessmentData. (This keeps onboarding + UI creates cheap and quiet.) + let vendorsToTrigger = vendors; + let skippedBecauseAlreadyHasData = 0; + let skippedVendors: TriggerVendorRiskAssessmentVendorDto[] = []; + let createdVerifyTasks = 0; + let updatedVerifyTasks = 0; + + if (!withResearch) { + // Extract domains for all vendors and check which ones already have risk assessment data + const vendorDomains = vendors + .map((v) => ({ + vendor: v, + domain: extractDomain(v.vendorWebsite ?? null), + })) + .filter((vd): vd is { vendor: TriggerVendorRiskAssessmentVendorDto; domain: string } => vd.domain !== null); + + // Check which domains already have risk assessment data using contains filter + const existingDomains = new Set(); + if (vendorDomains.length > 0) { + const uniqueDomains = Array.from(new Set(vendorDomains.map((vd) => vd.domain))); + const existing = await db.globalVendors.findMany({ + where: { + OR: uniqueDomains.map((domain) => ({ + website: { contains: domain }, + })), + // Json fields require Prisma null sentinels (DbNull/JsonNull), not literal null + riskAssessmentData: { not: Prisma.DbNull }, + }, + select: { website: true }, + }); + + // Extract domains from existing records to build the set + for (const gv of existing) { + const domain = extractDomain(gv.website); + if (domain) { + existingDomains.add(domain); + } + } + } + + vendorsToTrigger = vendors.filter((v) => { + const domain = extractDomain(v.vendorWebsite ?? null); + if (!domain) return true; // Let the task handle "no website" skip behavior. + return !existingDomains.has(domain); + }); + + skippedVendors = vendors.filter((v) => { + const domain = extractDomain(v.vendorWebsite ?? null); + if (!domain) return false; + return existingDomains.has(domain); + }); + + skippedBecauseAlreadyHasData = vendors.length - vendorsToTrigger.length; + + // For vendors we are skipping (because GlobalVendors already has data), still ensure the human + // "Verify risk assessment" task exists. This keeps the UI consistent without running any job. + if (skippedVendors.length > 0) { + const settled = await Promise.allSettled( + skippedVendors.map(async (v) => { + const { creatorMemberId, assigneeMemberId } = await resolveTaskCreatorAndAssignee({ + organizationId, + createdByUserId: null, + }); + + const creatorMember = await db.member.findUnique({ + where: { id: creatorMemberId }, + select: { id: true, userId: true }, + }); + + const existingVerifyTask = await db.taskItem.findFirst({ + where: { + organizationId, + entityType: 'vendor', + entityId: v.vendorId, + title: VERIFY_RISK_ASSESSMENT_TASK_TITLE, + }, + select: { id: true, status: true }, + orderBy: { createdAt: 'desc' }, + }); + + if (!existingVerifyTask) { + const created = await db.taskItem.create({ + data: { + title: VERIFY_RISK_ASSESSMENT_TASK_TITLE, + description: 'Review the latest Risk Assessment and confirm it is accurate.', + status: TaskItemStatus.todo, + priority: TaskItemPriority.high, + entityId: v.vendorId, + entityType: 'vendor', + organizationId, + createdById: creatorMemberId, + assigneeId: assigneeMemberId, + }, + select: { id: true }, + }); + + createdVerifyTasks += 1; + + // Audit log (best-effort) + if (creatorMember?.userId) { + try { + await db.auditLog.create({ + data: { + organizationId, + userId: creatorMember.userId, + memberId: creatorMember.id, + entityType: 'task', + entityId: created.id, + description: 'created this task', + data: { + action: 'created', + taskItemId: created.id, + taskTitle: VERIFY_RISK_ASSESSMENT_TASK_TITLE, + parentEntityType: 'vendor', + parentEntityId: v.vendorId, + }, + }, + }); + } catch { + // ignore + } + } + + return; + } + + // If it exists but is blocked, flip it to todo (unless already done/canceled) + if (existingVerifyTask.status === TaskItemStatus.in_progress) { + await db.taskItem.update({ + where: { id: existingVerifyTask.id }, + data: { + status: TaskItemStatus.todo, + description: 'Review the latest Risk Assessment and confirm it is accurate.', + assigneeId: assigneeMemberId, + updatedById: creatorMemberId, + }, + }); + updatedVerifyTasks += 1; + } + }), + ); + + const failures = settled.filter((r) => r.status === 'rejected'); + if (failures.length > 0) { + this.logger.warn('Some verify tasks could not be ensured for skipped vendors', { + organizationId, + failures: failures.length, + skippedCount: skippedVendors.length, + }); + } + } + } + + // Simplified logging: clear lists of what needs research vs what doesn't + if (!withResearch && skippedVendors.length > 0) { + this.logger.log('✅ Vendors that DO NOT need research (already have data)', { + count: skippedVendors.length, + vendors: skippedVendors.map((v) => `${v.vendorName} (${v.vendorWebsite ?? 'no website'})`), + }); + } + + if (vendorsToTrigger.length > 0) { + this.logger.log('🔍 Vendors that NEED research (missing data)', { + count: vendorsToTrigger.length, + withResearch, + vendors: vendorsToTrigger.map((v) => `${v.vendorName} (${v.vendorWebsite ?? 'no website'})`), + }); + } else { + this.logger.log('✅ All vendors already have risk assessment data - no research needed', { + totalVendors: vendors.length, + }); + } // Use batchTrigger for efficiency (less overhead than N individual triggers) - const batch = vendors.map((v) => ({ + // If we're triggering the task, it means research is needed (we've already filtered) + // So always pass withResearch: true when triggering + const batch = vendorsToTrigger.map((v) => ({ payload: { vendorId: v.vendorId, vendorName: v.vendorName, - vendorWebsite: v.vendorWebsite ?? null, + // Keep website canonical so downstream (Trigger task) uses the same GlobalVendors key. + vendorWebsite: normalizeWebsite(v.vendorWebsite ?? null), organizationId, createdByUserId: null, - withResearch, + withResearch: true, // Always true - if task is triggered, research is needed }, })); try { + if (vendorsToTrigger.length === 0) { + return { triggered: 0, batchId: null }; + } + const batchHandle = await tasks.batchTrigger('vendor-risk-assessment-task', batch); - this.logger.log( - `Successfully triggered ${vendors.length} vendor risk assessment tasks for organization ${organizationId}`, - { - batchId: batchHandle.batchId, - vendorCount: vendors.length, - }, - ); + this.logger.log('✅ Triggered risk assessment tasks', { + count: vendorsToTrigger.length, + batchId: batchHandle.batchId, + }); return { - triggered: vendors.length, + triggered: vendorsToTrigger.length, batchId: batchHandle.batchId, }; } catch (error) { this.logger.error('Failed to batch trigger vendor risk assessment tasks', { organizationId, - vendorCount: vendors.length, + vendorCount: vendorsToTrigger.length, error: error instanceof Error ? error.message : String(error), errorStack: error instanceof Error ? error.stack : undefined, }); diff --git a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorActions.tsx b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorActions.tsx index 287314252..c8c5f6097 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorActions.tsx +++ b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorActions.tsx @@ -18,10 +18,12 @@ import { } from '@comp/ui/dropdown-menu'; import { Cog } from 'lucide-react'; import { useAction } from 'next-safe-action/hooks'; +import { useQueryState } from 'nuqs'; import { useState } from 'react'; import { toast } from 'sonner'; export function VendorActions({ vendorId }: { vendorId: string }) { + const [_, setOpen] = useQueryState('vendor-overview-sheet'); const [isConfirmOpen, setIsConfirmOpen] = useState(false); const regenerate = useAction(regenerateVendorMitigationAction, { onSuccess: () => toast.success('Regeneration triggered. This may take a moment.'), @@ -43,6 +45,9 @@ export function VendorActions({ vendorId }: { vendorId: string }) { + setOpen('true')}> + Edit vendor name and description + setIsConfirmOpen(true)}> Regenerate Risk Mitigation diff --git a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorHeader.tsx b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorHeader.tsx new file mode 100644 index 000000000..bf5130c69 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorHeader.tsx @@ -0,0 +1,178 @@ +'use client'; + +import { Badge } from '@comp/ui/badge'; +import { Button } from '@comp/ui/button'; +import type { Vendor } from '@db'; +import type { Prisma } from '@prisma/client'; +import { ExternalLink } from 'lucide-react'; +import { useMemo } from 'react'; +import { cn } from '@/lib/utils'; +import { filterCertifications } from '@/components/vendor-risk-assessment/filter-certifications'; +import { parseVendorRiskAssessmentDescription } from '@/components/vendor-risk-assessment/parse-vendor-risk-assessment-description'; +import { UpdateTitleAndDescriptionSheet } from './title-and-description/update-title-and-description-sheet'; +import Link from 'next/link'; +import { + ISO27001, + ISO42001, + SOC2Type1, + SOC2Type2, + HIPAA, +} from '@/app/(app)/[orgId]/trust/portal-settings/components/logos'; +import type { VendorRiskAssessmentCertification } from '@/components/vendor-risk-assessment/vendor-risk-assessment-types'; + +// Vendor with risk assessment data merged from GlobalVendors +type VendorWithRiskAssessment = Vendor & { + riskAssessmentData?: Prisma.InputJsonValue | null; + riskAssessmentVersion?: string | null; + riskAssessmentUpdatedAt?: Date | null; +}; + +interface VendorHeaderProps { + vendor: VendorWithRiskAssessment; +} + +/** + * Get the compliance icon component for a certification type + */ +function getCertificationIcon(cert: VendorRiskAssessmentCertification) { + const typeLower = cert.type.toLowerCase().trim(); + + // ISO 27001 + if (typeLower.includes('iso') && typeLower.includes('27001')) { + return ISO27001; + } + + // ISO 42001 + if (typeLower.includes('iso') && typeLower.includes('42001')) { + return ISO42001; + } + + // SOC 2 Type 1 + if ( + typeLower.includes('soc') && + (typeLower.includes('type 1') || typeLower.includes('type i')) && + !typeLower.includes('type 2') && + !typeLower.includes('type ii') + ) { + return SOC2Type1; + } + + // SOC 2 Type 2 + if ( + typeLower.includes('soc') && + (typeLower.includes('type 2') || typeLower.includes('type ii')) + ) { + return SOC2Type2; + } + + // HIPAA + if (typeLower === 'hipaa' || typeLower === 'hipa') { + return HIPAA; + } + + return null; +} + +export function VendorHeader({ vendor }: VendorHeaderProps) { + + // Parse risk assessment data to get certifications and links + // Note: This should come from GlobalVendors, but we're reading from vendor for now + // TODO: Update to fetch from GlobalVendors via vendor.website lookup + const { certifications, links } = useMemo(() => { + if (!vendor.riskAssessmentData) return { certifications: [], links: [] }; + const data = parseVendorRiskAssessmentDescription( + typeof vendor.riskAssessmentData === 'string' + ? vendor.riskAssessmentData + : JSON.stringify(vendor.riskAssessmentData), + ); + return { + certifications: filterCertifications(data?.certifications), + links: data?.links ?? [], + }; + }, [vendor.riskAssessmentData]); + + return ( + <> +
+
+

{vendor.name}

+ {certifications.filter((cert) => cert.status === 'verified').length > 0 && ( +
+ {certifications + .filter((cert) => { + // Only show verified certifications + return cert.status === 'verified'; + }) + .map((cert, index) => { + const IconComponent = getCertificationIcon(cert); + + if (!IconComponent) return null; + + const iconContent = ( +
+ +
+ ); + + if (cert.url) { + return ( + + {iconContent} + + ); + } + + return ( +
+ {iconContent} +
+ ); + })} +
+ )} +
+ {vendor.description && ( +

{vendor.description}

+ )} + {links.length > 0 && ( +
+ {links.map((link, index) => { + return ( + + ); + })} +
+ )} +
+ + + ); +} + diff --git a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorTabs.tsx b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorTabs.tsx new file mode 100644 index 000000000..500aeb7c8 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/VendorTabs.tsx @@ -0,0 +1,22 @@ +import { SecondaryMenu } from '@comp/ui/secondary-menu'; + +interface VendorTabsProps { + vendorId: string; + orgId: string; +} + +export function VendorTabs({ vendorId, orgId }: VendorTabsProps) { + const items = [ + { + path: `/${orgId}/vendors/${vendorId}`, + label: 'Overview', + }, + { + path: `/${orgId}/vendors/${vendorId}/review`, + label: 'Risk Assessment', + }, + ]; + + return ; +} + diff --git a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/secondary-fields/secondary-fields.tsx b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/secondary-fields/secondary-fields.tsx index 6281f18fe..f098df2b9 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/secondary-fields/secondary-fields.tsx +++ b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/components/secondary-fields/secondary-fields.tsx @@ -1,48 +1,21 @@ 'use client'; -import { Button } from '@comp/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@comp/ui/card'; -import type { GlobalVendors, Member, User, Vendor } from '@db'; -import { PencilIcon } from 'lucide-react'; -import { useQueryState } from 'nuqs'; -import { UpdateTitleAndDescriptionSheet } from '../title-and-description/update-title-and-description-sheet'; +import { Card, CardContent } from '@comp/ui/card'; +import type { Member, User, Vendor } from '@db'; import { UpdateSecondaryFieldsForm } from './update-secondary-fields-form'; export function SecondaryFields({ vendor, assignees, - globalVendor, }: { vendor: Vendor & { assignee: { user: User | null } | null }; assignees: (Member & { user: User })[]; - globalVendor: GlobalVendors | null; }) { - const [_, setOpen] = useQueryState('vendor-overview-sheet'); - return ( -
- -
-
- {vendor.name} - -
- {vendor.description} -
-
- +
- -
); } diff --git a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/layout.tsx b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/layout.tsx new file mode 100644 index 000000000..49ea4bdf6 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/layout.tsx @@ -0,0 +1,8 @@ +interface VendorLayoutProps { + children: React.ReactNode; +} + +export default function VendorLayout({ children }: VendorLayoutProps) { + return <>{children}; +} + diff --git a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/loading.tsx b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/loading.tsx index 4f38f9a92..8071fa5db 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/loading.tsx +++ b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/loading.tsx @@ -1,9 +1,74 @@ -import Loader from '@/components/ui/loader'; +import { Skeleton } from '@comp/ui/skeleton'; export default function Loading() { return ( -
- +
+ {/* Vendor header skeleton */} +
+
+ + +
+ +
+ + {/* Tabs skeleton */} + + + {/* Content skeleton */} +
+ {/* Secondary Fields skeleton */} +
+
+
+ + +
+
+ + + +
+
+
+ + {/* Charts skeleton */} +
+
+ + +
+
+ + +
+
+ + {/* Tasks skeleton */} +
+
+
+ + +
+
+ + + +
+
+
+
); } diff --git a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/page.tsx b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/page.tsx index 92691c2d4..cee6954b4 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/page.tsx @@ -2,6 +2,7 @@ import PageWithBreadcrumb from '@/components/pages/PageWithBreadcrumb'; import { auth } from '@/utils/auth'; +import { extractDomain } from '@/utils/normalize-website'; import { CommentEntityType, db } from '@db'; import type { Metadata } from 'next'; import { headers } from 'next/headers'; @@ -12,6 +13,8 @@ import { TaskItems } from '../../../../../components/task-items/TaskItems'; import { VendorActions } from './components/VendorActions'; import { VendorInherentRiskChart } from './components/VendorInherentRiskChart'; import { VendorResidualRiskChart } from './components/VendorResidualRiskChart'; +import { VendorTabs } from './components/VendorTabs'; +import { VendorHeader } from './components/VendorHeader'; import { SecondaryFields } from './components/secondary-fields/secondary-fields'; interface PageProps { @@ -24,35 +27,41 @@ interface PageProps { export default async function VendorPage({ params, searchParams }: PageProps) { const { vendorId, orgId } = await params; const { taskItemId } = (await searchParams) ?? {}; - const vendor = await getVendor({ vendorId, organizationId: orgId }); - const assignees = await getAssignees(orgId); + + // Fetch data in parallel for faster loading + const [vendor, assignees] = await Promise.all([ + getVendor({ vendorId, organizationId: orgId }), + getAssignees(orgId), + ]); if (!vendor || !vendor.vendor) { redirect('/'); } - const shortTaskId = (id: string) => id.slice(-6).toUpperCase(); + // Hide vendor-level content when viewing a task in focus mode + const isViewingTask = Boolean(taskItemId); return ( } > + {!isViewingTask && } + {!isViewingTask && }
- {!taskItemId && ( + {!isViewingTask && ( <>
@@ -60,8 +69,12 @@ export default async function VendorPage({ params, searchParams }: PageProps) {
)} - - {!taskItemId && ( + + {!isViewingTask && ( )}
@@ -93,22 +106,46 @@ const getVendor = cache(async (params: { vendorId: string; organizationId: strin }, }); - if (vendor?.website) { - const globalVendor = await db.globalVendors.findFirst({ + // Fetch risk assessment from GlobalVendors if vendor has a website + // Find ALL duplicates and prefer the one WITH risk assessment data (most recent) + const domain = extractDomain(vendor?.website ?? null); + let globalVendor = null; + if (domain) { + const duplicates = await db.globalVendors.findMany({ where: { - website: vendor.website, + website: { + contains: domain, + }, + }, + select: { + website: true, + riskAssessmentData: true, + riskAssessmentVersion: true, + riskAssessmentUpdatedAt: true, }, + orderBy: [ + { riskAssessmentUpdatedAt: 'desc' }, + { createdAt: 'desc' }, + ], }); - - return { - vendor: vendor, - globalVendor, - }; + + // Prefer record WITH risk assessment data (most recent) + globalVendor = duplicates.find((gv) => gv.riskAssessmentData !== null) ?? duplicates[0] ?? null; } + // Merge GlobalVendors risk assessment data into vendor object for backward compatibility + const vendorWithRiskAssessment = vendor + ? { + ...vendor, + riskAssessmentData: globalVendor?.riskAssessmentData ?? null, + riskAssessmentVersion: globalVendor?.riskAssessmentVersion ?? null, + riskAssessmentUpdatedAt: globalVendor?.riskAssessmentUpdatedAt ?? null, + } + : null; + return { - vendor: vendor, - globalVendor: null, + vendor: vendorWithRiskAssessment, + globalVendor, }; }); diff --git a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/review/loading.tsx b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/review/loading.tsx new file mode 100644 index 000000000..af5196b16 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/review/loading.tsx @@ -0,0 +1,97 @@ +import { Skeleton } from '@comp/ui/skeleton'; + +export default function Loading() { + return ( +
+ {/* Vendor header skeleton */} +
+
+ + +
+ +
+ + {/* Tabs skeleton */} + + + {/* Risk Assessment skeleton */} +
+ {/* Header skeleton */} +
+ + +
+ + {/* Main content grid skeleton */} +
+ {/* Left column - 2/3 width */} +
+ {/* Security Assessment card */} +
+ +
+ + + +
+
+ + {/* Timeline card */} +
+ +
+ + + +
+
+
+ + {/* Right column - 1/3 width */} +
+ {/* Useful Links card */} +
+ +
+ + + +
+
+ + {/* Certifications card */} +
+ +
+ + + +
+
+ + {/* Vendor Details card */} +
+ +
+ + +
+
+
+
+
+
+ ); +} + diff --git a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/review/page.tsx b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/review/page.tsx new file mode 100644 index 000000000..a9a2cf698 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/review/page.tsx @@ -0,0 +1,162 @@ +'use server'; + +import PageWithBreadcrumb from '@/components/pages/PageWithBreadcrumb'; +import { VendorRiskAssessmentView } from '@/components/vendor-risk-assessment/VendorRiskAssessmentView'; +import { auth } from '@/utils/auth'; +import { extractDomain } from '@/utils/normalize-website'; +import { db } from '@db'; +import type { Metadata } from 'next'; +import { headers } from 'next/headers'; +import { redirect } from 'next/navigation'; +import { cache } from 'react'; +import { VendorActions } from '../components/VendorActions'; +import { VendorHeader } from '../components/VendorHeader'; +import { VendorTabs } from '../components/VendorTabs'; + +interface ReviewPageProps { + params: Promise<{ vendorId: string; locale: string; orgId: string }>; + searchParams?: Promise<{ + taskItemId?: string; + }>; +} + +export default async function ReviewPage({ params, searchParams }: ReviewPageProps) { + const { vendorId, orgId } = await params; + const { taskItemId } = (await searchParams) ?? {}; + + const vendorResult = await getVendor({ vendorId, organizationId: orgId }); + + if (!vendorResult || !vendorResult.vendor) { + 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; + + return ( + } + > + {!isViewingTask && } + {!isViewingTask && } +
+ {riskAssessmentData ? ( + + ) : ( +
+

+ {vendor.status === 'in_progress' + ? 'Risk assessment is being generated. Please check back soon.' + : 'No risk assessment found yet.'} +

+
+ )} +
+
+ ); +} + +const getVendor = cache(async (params: { vendorId: string; organizationId: string }) => { + const { vendorId, organizationId } = params; + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session?.user?.id) { + return null; + } + + const vendor = await db.vendor.findUnique({ + where: { + id: vendorId, + organizationId, + }, + select: { + id: true, + name: true, + description: true, + website: true, + status: true, + updatedAt: true, + createdAt: true, + category: true, + inherentProbability: true, + inherentImpact: true, + residualProbability: true, + residualImpact: true, + organizationId: true, + assigneeId: true, + }, + }); + + if (!vendor) { + return null; + } + + // Fetch risk assessment from GlobalVendors if vendor has a website + // Find ALL duplicates and prefer the one WITH risk assessment data (most recent) + const domain = extractDomain(vendor.website ?? null); + let globalVendor = null; + if (domain) { + const duplicates = await db.globalVendors.findMany({ + where: { + website: { + contains: domain, + }, + }, + select: { + website: true, + riskAssessmentData: true, + riskAssessmentVersion: true, + riskAssessmentUpdatedAt: true, + }, + orderBy: [ + { riskAssessmentUpdatedAt: 'desc' }, + { createdAt: 'desc' }, + ], + }); + + // Prefer record WITH risk assessment data (most recent) + globalVendor = duplicates.find((gv) => gv.riskAssessmentData !== null) ?? duplicates[0] ?? 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, + }, + }; +}); + +export async function generateMetadata(): Promise { + return { + title: 'Vendor Risk Assessment', + }; +} + diff --git a/apps/app/src/app/(app)/[orgId]/vendors/actions/create-vendor-action.ts b/apps/app/src/app/(app)/[orgId]/vendors/actions/create-vendor-action.ts index 7a2341440..b2683ea3d 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/actions/create-vendor-action.ts +++ b/apps/app/src/app/(app)/[orgId]/vendors/actions/create-vendor-action.ts @@ -2,12 +2,97 @@ import type { ActionResponse } from '@/types/actions'; import { auth } from '@/utils/auth'; +import { extractDomain, normalizeWebsite } from '@/utils/normalize-website'; import { db, type Vendor, VendorCategory, VendorStatus } from '@db'; +import axios from 'axios'; import { createSafeActionClient } from 'next-safe-action'; import { revalidatePath } from 'next/cache'; import { headers } from 'next/headers'; import { z } from 'zod'; +const getApiBaseUrl = (): string => { + return process.env.NEXT_PUBLIC_API_URL || process.env.API_BASE_URL || 'http://localhost:3333'; +}; + +const triggerRiskAssessmentIfMissing = async (params: { + organizationId: string; + vendor: Pick; +}): Promise => { + const normalizedWebsite = normalizeWebsite(params.vendor.website ?? null); + if (!normalizedWebsite) { + console.log('[createVendorAction] Skip risk assessment trigger (no valid website)', { + organizationId: params.organizationId, + vendorId: params.vendor.id, + vendorName: params.vendor.name, + vendorWebsite: params.vendor.website ?? null, + }); + return; + } + + // Check if GlobalVendors already has risk assessment data for this domain + // Find ALL duplicates and check if ANY has risk assessment data + const domain = extractDomain(params.vendor.website ?? null); + let existing = null; + if (domain) { + const duplicates = await db.globalVendors.findMany({ + where: { + website: { + contains: domain, + }, + }, + select: { website: true, riskAssessmentData: true }, + orderBy: [ + { riskAssessmentUpdatedAt: 'desc' }, + { createdAt: 'desc' }, + ], + }); + + // Prefer record WITH risk assessment data + existing = duplicates.find((gv) => gv.riskAssessmentData !== null) ?? duplicates[0] ?? null; + } + const existingHasData = Boolean(existing?.riskAssessmentData); + + // Only trigger *research* when GlobalVendors is missing data. + if (existingHasData) { + console.log('[createVendorAction] Skip risk assessment trigger (GlobalVendors already has data)', { + organizationId: params.organizationId, + vendorId: params.vendor.id, + vendorName: params.vendor.name, + normalizedWebsite, + }); + return; + } + + const token = process.env.INTERNAL_API_TOKEN; + + console.log('[createVendorAction] Trigger risk assessment research (GlobalVendors missing data)', { + organizationId: params.organizationId, + vendorId: params.vendor.id, + vendorName: params.vendor.name, + normalizedWebsite, + hasInternalToken: Boolean(token), + }); + + await axios.post( + `${getApiBaseUrl()}/v1/internal/vendors/risk-assessment/trigger-batch`, + { + organizationId: params.organizationId, + withResearch: true, + vendors: [ + { + vendorId: params.vendor.id, + vendorName: params.vendor.name, + vendorWebsite: normalizedWebsite, + }, + ], + }, + { + headers: token ? { 'X-Internal-Token': token } : undefined, + timeout: 15_000, + }, + ); +}; + const schema = z.object({ organizationId: z.string().min(1, 'Organization ID is required'), name: z.string().min(1, 'Name is required'), @@ -61,6 +146,22 @@ export const createVendorAction = createSafeActionClient() }, }); + // If we don't already have GlobalVendors risk assessment data for this website, trigger research. + // Best-effort: vendor creation should succeed even if the trigger fails. + try { + await triggerRiskAssessmentIfMissing({ + organizationId: input.parsedInput.organizationId, + vendor, + }); + } catch (error) { + console.warn('[createVendorAction] Risk assessment trigger failed (non-blocking)', { + organizationId: input.parsedInput.organizationId, + vendorId: vendor.id, + vendorName: vendor.name, + error: error instanceof Error ? error.message : String(error), + }); + } + revalidatePath(`/${input.parsedInput.organizationId}/vendors`); return { success: true, data: vendor }; diff --git a/apps/app/src/components/task-items/TaskItemEditableDescription.tsx b/apps/app/src/components/task-items/TaskItemEditableDescription.tsx index aee43a1d4..96d982aad 100644 --- a/apps/app/src/components/task-items/TaskItemEditableDescription.tsx +++ b/apps/app/src/components/task-items/TaskItemEditableDescription.tsx @@ -21,15 +21,35 @@ interface TaskItemEditableDescriptionProps { function parseDescription(desc: string | null | undefined): JSONContent | null { if (!desc) return null; + const wrapPlainText = (text: string): JSONContent => { + return { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text, + }, + ], + }, + ], + }; + }; + try { const parsed = typeof desc === 'string' ? JSON.parse(desc) : desc; if (parsed && typeof parsed === 'object' && (parsed.type === 'doc' || Array.isArray(parsed))) { return parsed as JSONContent; } + + // Valid JSON, but not a TipTap doc/array — preserve the original content as plain text. + return wrapPlainText(desc); } catch { - // Not JSON, return null + // Not JSON - convert plain text to TipTap JSON format + return wrapPlainText(desc); } - return null; } export function TaskItemEditableDescription({ diff --git a/apps/app/src/components/task-items/TaskItemFocusView.tsx b/apps/app/src/components/task-items/TaskItemFocusView.tsx index 862b3679e..3e44341ef 100644 --- a/apps/app/src/components/task-items/TaskItemFocusView.tsx +++ b/apps/app/src/components/task-items/TaskItemFocusView.tsx @@ -30,9 +30,7 @@ import { TaskItemFocusSidebar } from './TaskItemFocusSidebar'; import { getTaskIdShort } from './task-item-utils'; 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; @@ -68,7 +66,6 @@ export function TaskItemFocusView({ const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const pathname = usePathname(); - const isGeneratedTask = isVendorRiskAssessmentTaskItem(taskItem); const { optimisticUpdate, optimisticDelete } = useOptimisticTaskItems( entityId, @@ -172,9 +169,6 @@ export function TaskItemFocusView({
{/* Main Content */}
- {isGeneratedTask ? ( - - ) : ( - )} {/* Divider */}
diff --git a/apps/app/src/components/task-items/TaskItemItem.tsx b/apps/app/src/components/task-items/TaskItemItem.tsx index fbd5b8997..cae3deae6 100644 --- a/apps/app/src/components/task-items/TaskItemItem.tsx +++ b/apps/app/src/components/task-items/TaskItemItem.tsx @@ -40,7 +40,6 @@ import type { TaskItemStatus, } from '@/hooks/use-task-items'; import { - MoreHorizontal, Pencil, Trash2, List, @@ -61,8 +60,7 @@ import { useMemo, useState } from 'react'; import { toast } from 'sonner'; import { SelectAssignee } from '@/components/SelectAssignee'; import { format } from 'date-fns'; -import { isVendorRiskAssessmentTaskItem } from './generated-task/vendor-risk-assessment/is-vendor-risk-assessment-task-item'; -import { VendorRiskAssessmentTaskItemSkeletonRow } from './generated-task/vendor-risk-assessment/VendorRiskAssessmentTaskItemSkeletonRow'; +import { VerifyRiskAssessmentTaskItemSkeletonRow } from './verify-risk-assessment/VerifyRiskAssessmentTaskItemSkeletonRow'; const formatShortDate = (date: string | Date): string => { try { @@ -124,10 +122,8 @@ export function TaskItemItem({ onToggleExpanded, onStatusOrPriorityChange, }: TaskItemItemProps) { - const isGeneratingVendorRiskAssessment = - taskItem.status === 'in_progress' && isVendorRiskAssessmentTaskItem(taskItem); - if (isGeneratingVendorRiskAssessment) { - return ; + if (taskItem.title === 'Verify risk assessment' && taskItem.status === 'in_progress') { + return ; } const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); @@ -499,39 +495,20 @@ export function TaskItemItem({ {formatShortDate(taskItem.createdAt)}
- {/* Options Menu */} - - + {/* Delete Button */} - - - { - e.preventDefault(); - handleEditToggle(); - }} - > - - Edit - - setIsDeleteOpen(true)} - > - - Delete - - -
diff --git a/apps/app/src/components/task-items/TaskItems.tsx b/apps/app/src/components/task-items/TaskItems.tsx index daf1e8e7c..3d7cef7bd 100644 --- a/apps/app/src/components/task-items/TaskItems.tsx +++ b/apps/app/src/components/task-items/TaskItems.tsx @@ -13,7 +13,6 @@ import { TaskItemsBody } from './TaskItemsBody'; import { useOrganizationMembers } from '@/hooks/use-organization-members'; import { filterMembersByOwnerOrAdmin } from '@/utils/filter-members-by-role'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; -import { isVendorRiskAssessmentTaskItem } from './generated-task/vendor-risk-assessment/is-vendor-risk-assessment-task-item'; interface TaskItemsProps { entityId: string; @@ -67,26 +66,6 @@ export const TaskItems = ({ organizationId, }); - // Check if any tasks are currently generating (in_progress vendor risk assessments) - // If yes, enable polling so skeleton updates automatically when generation completes - const hasGeneratingTasks = useMemo(() => { - const taskItems = taskItemsResponse?.data?.data ?? []; - return taskItems.some( - (taskItem) => - taskItem.status === 'in_progress' && isVendorRiskAssessmentTaskItem(taskItem), - ); - }, [taskItemsResponse?.data?.data]); - - // Re-fetch with polling enabled if there are generating tasks - useEffect(() => { - if (hasGeneratingTasks) { - const interval = setInterval(() => { - refreshTaskItems(); - }, 3000); // Poll every 3 seconds - return () => clearInterval(interval); - } - }, [hasGeneratingTasks, refreshTaskItems]); - const { data: statsResponse, isLoading: statsLoading, @@ -171,12 +150,18 @@ export const TaskItems = ({ }, [taskItemsResponse]); const displayResponse = taskItemsResponse || previousDataRef.current; - const taskItems = displayResponse?.data?.data || []; + const allTaskItems = displayResponse?.data?.data || []; + const taskItems = allTaskItems; const paginationMeta = displayResponse?.data?.meta; const stats = statsResponse?.data; const hasTasks = stats && stats.total > 0; const isFocusMode = Boolean(selectedTaskItemId); - const selectedTaskItem = taskItems.find((t) => t.id === selectedTaskItemId) || null; + // When in focus mode, check allTaskItems first (before filtering) to ensure we can show the selected task + // even if it would be filtered out. Fall back to filtered taskItems if not found. + const selectedTaskItem = + allTaskItems.find((t) => t.id === selectedTaskItemId) || + taskItems.find((t) => t.id === selectedTaskItemId) || + null; // `useApiSWR` doesn't start fetching until organizationId is available, and during that time `isLoading` is false. // So we also treat "waiting for org" as initial loading for this section. @@ -186,6 +171,26 @@ export const TaskItems = ({ // Only show empty state if we're not loading AND we have no data AND we've received a response const shouldShowEmptyState = !taskItemsLoading && !taskItemsError && taskItems.length === 0 && taskItemsResponse !== undefined; + // If the vendor risk assessment is generating, we show a disabled-looking row. + // We need lightweight polling so the UI flips from in_progress -> todo automatically + // once the background job updates the task status. + const hasGeneratingVerifyRiskAssessmentTask = useMemo(() => { + return allTaskItems.some( + (t) => t.title === 'Verify risk assessment' && t.status === 'in_progress', + ); + }, [allTaskItems]); + + useEffect(() => { + if (selectedTaskItemId) return; + if (!hasGeneratingVerifyRiskAssessmentTask) return; + + const interval = setInterval(() => { + refreshTaskItems(); + }, 3000); + + return () => clearInterval(interval); + }, [hasGeneratingVerifyRiskAssessmentTask, refreshTaskItems, selectedTaskItemId]); + const defaultDescription = description || `Manage tasks related to this ${entityType}`; diff --git a/apps/app/src/components/task-items/TaskRichDescriptionField.tsx b/apps/app/src/components/task-items/TaskRichDescriptionField.tsx index b23cea4dd..c6e8736e9 100644 --- a/apps/app/src/components/task-items/TaskRichDescriptionField.tsx +++ b/apps/app/src/components/task-items/TaskRichDescriptionField.tsx @@ -49,6 +49,7 @@ export function TaskRichDescriptionField({ const fileInputRef = useRef(null); const { orgId: organizationId } = useParams<{ orgId: string }>(); const [isUploading, setIsUploading] = useState(false); + const isUploadingRef = useRef(false); // Add pulse animation and skeleton styles useEffect(() => { @@ -221,9 +222,20 @@ export function TaskRichDescriptionField({ onUpdate: ({ editor }) => { // Get content immediately when editor updates // This is called automatically when content changes (including when we insert files) + // Defer onChange during file uploads to avoid flushSync errors if (!editor.isDestroyed) { const content = editor.getJSON(); + if (isUploadingRef.current) { + // During upload, defer to avoid flushSync during render + queueMicrotask(() => { + if (!editor.isDestroyed) { + onChange(content); + } + }); + } else { + // Normal typing - can call synchronously onChange(content); + } } }, editorProps: { @@ -243,6 +255,7 @@ export function TaskRichDescriptionField({ // Handle async upload without blocking (async () => { setIsUploading(true); + isUploadingRef.current = true; try { const results = await onFileUpload(files); if (results && results.length > 0 && editor && !editor.isDestroyed) { @@ -266,13 +279,18 @@ export function TaskRichDescriptionField({ }); contentToInsert.push({ type: 'paragraph' }); editor.chain().focus().setTextSelection(pos).insertContent(contentToInsert).run(); - // Ensure onChange is called after drop insertion + // Ensure onChange is called after drop insertion (defer to avoid flushSync during render) + queueMicrotask(() => { + if (!editor.isDestroyed) { onChange(editor.getJSON()); + } + }); } } catch (error) { console.error('Failed to upload files:', error); } finally { setIsUploading(false); + isUploadingRef.current = false; } })(); @@ -294,6 +312,7 @@ export function TaskRichDescriptionField({ // Handle async upload without blocking (async () => { setIsUploading(true); + isUploadingRef.current = true; try { const results = await onFileUpload(files); if (results && results.length > 0 && editor && !editor.isDestroyed) { @@ -318,13 +337,18 @@ export function TaskRichDescriptionField({ contentToInsert.push({ type: 'paragraph' }); const currentPos = editor.state.selection.from; editor.chain().focus().setTextSelection(currentPos).insertContent(contentToInsert).run(); - // Ensure onChange is called after paste insertion + // Ensure onChange is called after paste insertion (defer to avoid flushSync during render) + queueMicrotask(() => { + if (!editor.isDestroyed) { onChange(editor.getJSON()); + } + }); } } catch (error) { console.error('Failed to upload files:', error); } finally { setIsUploading(false); + isUploadingRef.current = false; } })(); @@ -389,6 +413,7 @@ export function TaskRichDescriptionField({ // Notify parent that file selection started onFileSelectStart?.(); setIsUploading(true); + isUploadingRef.current = true; // Get current selection position before upload editor.chain().focus().run(); @@ -538,10 +563,13 @@ export function TaskRichDescriptionField({ // Manually trigger onChange to ensure parent state is synced // TipTap's onUpdate should fire, but we ensure it here for reliability + // Defer to avoid flushSync during render cycle + queueMicrotask(() => { if (editor && !editor.isDestroyed) { const content = editor.getJSON(); onChange(content); } + }); } catch (error) { console.error('Failed to upload files:', error); toast.error('Failed to attach file'); @@ -558,6 +586,7 @@ export function TaskRichDescriptionField({ } } finally { setIsUploading(false); + isUploadingRef.current = false; // Notify parent that file selection ended onFileSelectEnd?.(); } diff --git a/apps/app/src/components/task-items/generated-task/GeneratedTaskItemMainContent.tsx b/apps/app/src/components/task-items/generated-task/GeneratedTaskItemMainContent.tsx deleted file mode 100644 index 2c15bf7ad..000000000 --- a/apps/app/src/components/task-items/generated-task/GeneratedTaskItemMainContent.tsx +++ /dev/null @@ -1,31 +0,0 @@ -'use client'; - -import type { TaskItem } from '@/hooks/use-task-items'; -import { isVendorRiskAssessmentTaskItem } from './vendor-risk-assessment/is-vendor-risk-assessment-task-item'; -import { VendorRiskAssessmentTaskItemView } from './vendor-risk-assessment/VendorRiskAssessmentTaskItemView'; - -interface GeneratedTaskItemMainContentProps { - taskItem: TaskItem; -} - -/** - * UI for system-generated tasks. - * - * Generated tasks are typically: - * - read-only content (no inline edits) - * - rendered from structured data stored in `taskItem.description` - */ -export function GeneratedTaskItemMainContent({ taskItem }: GeneratedTaskItemMainContentProps) { - if (isVendorRiskAssessmentTaskItem(taskItem)) { - return ; - } - - // Future generated task types go here. - return ( -
- This is a generated task with no custom renderer yet. -
- ); -} - - diff --git a/apps/app/src/components/task-items/generated-task/vendor-risk-assessment/VendorRiskAssessmentTaskItemSkeletonRow.tsx b/apps/app/src/components/task-items/generated-task/vendor-risk-assessment/VendorRiskAssessmentTaskItemSkeletonRow.tsx deleted file mode 100644 index 7939e0b2e..000000000 --- a/apps/app/src/components/task-items/generated-task/vendor-risk-assessment/VendorRiskAssessmentTaskItemSkeletonRow.tsx +++ /dev/null @@ -1,62 +0,0 @@ -'use client'; - -import { Skeleton } from '@comp/ui/skeleton'; -import { Loader2, TrendingUp, Circle } from 'lucide-react'; - -/** - * Skeleton row shown while the system is generating the vendor Risk Assessment task. - * - * Intentionally non-interactive: users shouldn't open or edit the task until the - * background job finishes and populates the structured description. - * - * Layout mirrors the actual TaskItemItem row for visual consistency. - */ -export function VendorRiskAssessmentTaskItemSkeletonRow() { - return ( -
-
-
- {/* Priority Icon - Fixed width (matches real row) */} -
-
- -
-
- - {/* Task ID - Fixed width (matches real row) */} -
- -
- - {/* Status Icon - Fixed width (matches real row) */} -
- -
- - {/* Title - Flexible with max width (matches real row) */} -
-

Risk Assessment

-
- - {/* Spacer */} -
- - {/* Assignee - Fixed width (matches real row) */} -
- -
- - {/* Date - Fixed width (matches real row) */} -
- -
- - {/* Options Menu Placeholder - matches real row */} -
-
-
-
- ); -} - - 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 deleted file mode 100644 index 01239e386..000000000 --- a/apps/app/src/components/task-items/generated-task/vendor-risk-assessment/VendorRiskAssessmentTaskItemView.tsx +++ /dev/null @@ -1,313 +0,0 @@ -'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, index) => { - 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 deleted file mode 100644 index 82e6e2c07..000000000 --- a/apps/app/src/components/task-items/generated-task/vendor-risk-assessment/VendorRiskAssessmentTimelineCard.tsx +++ /dev/null @@ -1,126 +0,0 @@ -'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, index) => ( -
- - -
- ))} - - {rest.length > 0 ? ( - - {rest.map((item, index) => ( -
- - -
- ))} -
- ) : 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 deleted file mode 100644 index 3cfe41ed6..000000000 --- a/apps/app/src/components/task-items/generated-task/vendor-risk-assessment/is-vendor-risk-assessment-task-item.ts +++ /dev/null @@ -1,51 +0,0 @@ -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/verify-risk-assessment/VerifyRiskAssessmentTaskItemSkeletonRow.tsx b/apps/app/src/components/task-items/verify-risk-assessment/VerifyRiskAssessmentTaskItemSkeletonRow.tsx new file mode 100644 index 000000000..15e1a1525 --- /dev/null +++ b/apps/app/src/components/task-items/verify-risk-assessment/VerifyRiskAssessmentTaskItemSkeletonRow.tsx @@ -0,0 +1,58 @@ +'use client'; + +import { Skeleton } from '@comp/ui/skeleton'; +import { Clock, Lock } from 'lucide-react'; + +/** + * Disabled-looking row for the "Verify risk assessment" task while the + * vendor risk assessment is still generating. + * + * This prevents users from opening/editing the task until the assessment exists. + */ +export function VerifyRiskAssessmentTaskItemSkeletonRow() { + return ( +
+
+
+ {/* Priority icon placeholder */} +
+
+ +
+
+ + {/* ID placeholder */} +
+ +
+ + {/* Status indicator */} +
+ +
+ + {/* Title */} +
+

Verify risk assessment

+
+ +
+ + {/* Assignee placeholder */} +
+ +
+ + {/* Date placeholder */} +
+ +
+ +
+
+
+
+ ); +} + + diff --git a/apps/app/src/components/vendor-risk-assessment/SecurityAssessmentContent.tsx b/apps/app/src/components/vendor-risk-assessment/SecurityAssessmentContent.tsx new file mode 100644 index 000000000..0d66099da --- /dev/null +++ b/apps/app/src/components/vendor-risk-assessment/SecurityAssessmentContent.tsx @@ -0,0 +1,77 @@ +'use client'; + +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@comp/ui/collapsible'; +import { ChevronDown, ChevronUp } from 'lucide-react'; +import { useState } from 'react'; +import remarkGfm from 'remark-gfm'; +import { MemoizedReactMarkdown } from '@/components/markdown'; + +interface SecurityAssessmentContentProps { + text: string; + maxLength?: number; +} + +export function SecurityAssessmentContent({ + text, + maxLength = 500, +}: SecurityAssessmentContentProps) { + const [isExpanded, setIsExpanded] = useState(false); + const isLong = text.length > maxLength; + + if (!isLong) { + return ( +
+ + {text} + +
+ ); + } + + return ( + +
+
+
+ + {text} + +
+
+
+ {/* CollapsibleContent is intentionally unused: we render the full markdown once and control visibility via max-height */} + +
+
+ + + +
+ + ); +} + + diff --git a/apps/app/src/components/task-items/generated-task/vendor-risk-assessment/VendorRiskAssessmentCertificationsCard.tsx b/apps/app/src/components/vendor-risk-assessment/VendorRiskAssessmentCertificationsCard.tsx similarity index 70% rename from apps/app/src/components/task-items/generated-task/vendor-risk-assessment/VendorRiskAssessmentCertificationsCard.tsx rename to apps/app/src/components/vendor-risk-assessment/VendorRiskAssessmentCertificationsCard.tsx index 64793207d..a00ea69ca 100644 --- a/apps/app/src/components/task-items/generated-task/vendor-risk-assessment/VendorRiskAssessmentCertificationsCard.tsx +++ b/apps/app/src/components/vendor-risk-assessment/VendorRiskAssessmentCertificationsCard.tsx @@ -4,18 +4,11 @@ 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 { ExternalLink, ChevronDown, ChevronUp, ShieldCheck, Shield, XCircle, Clock, Calendar } from 'lucide-react'; -import { format, isValid } from 'date-fns'; +import { Clock, ExternalLink, Shield, ShieldCheck, XCircle, ChevronDown, ChevronUp } from 'lucide-react'; import { useMemo, useState } from 'react'; +import { filterCertifications } from './filter-certifications'; import type { VendorRiskAssessmentCertification } 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 yyyy'); -} - function CertificationRow({ cert }: { cert: VendorRiskAssessmentCertification }) { const statusIcon = cert.status === 'verified' ? ( @@ -28,6 +21,15 @@ function CertificationRow({ cert }: { cert: VendorRiskAssessmentCertification }) ); + const statusBadge = + cert.status === 'verified' ? ( + verified + ) : cert.status === 'expired' ? ( + expired + ) : cert.status === 'not_certified' ? ( + not certified + ) : null; + return (
@@ -36,26 +38,11 @@ function CertificationRow({ cert }: { cert: VendorRiskAssessmentCertification })

{cert.type}

- {cert.status === 'verified' && ( - - verified - - )} - {cert.status === 'expired' && ( - - expired - - )} - {cert.status === 'not_certified' && ( - - not certified - - )} + {statusBadge} {cert.url ? (
- {certifications.length > 0 ? ( - - {verifiedCount} verified - + {filteredCerts.length > 0 ? ( + {filteredVerifiedCount} verified ) : null} - {certifications.length === 0 ? ( + {filteredCerts.length === 0 ? (

No certifications found.

) : ( @@ -116,7 +102,10 @@ export function VendorRiskAssessmentCertificationsCard({ {rest.length > 0 ? ( {rest.map((cert, index) => ( - + ))} ) : null} diff --git a/apps/app/src/components/vendor-risk-assessment/VendorRiskAssessmentTimelineCard.tsx b/apps/app/src/components/vendor-risk-assessment/VendorRiskAssessmentTimelineCard.tsx new file mode 100644 index 000000000..718f7ccfc --- /dev/null +++ b/apps/app/src/components/vendor-risk-assessment/VendorRiskAssessmentTimelineCard.tsx @@ -0,0 +1,141 @@ +'use client'; + +import { Badge } from '@comp/ui/badge'; +import { Card, CardContent, CardHeader, CardTitle } from '@comp/ui/card'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@comp/ui/collapsible'; +import { ChevronDown, ChevronUp } 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, 'MMMM d, yyyy'); +} + +function NewsRow({ item }: { item: VendorRiskAssessmentNewsItem }) { + return ( +
+
+ {formatLongDate(item.date)} +
+ +
+ {item.url ? ( + + {item.title} + + ) : ( + {item.title} + )} + {item.summary && ( + <> + {' '}—{' '} + {item.summary} + + )} + {item.source && ( + <> + {' '} + ({item.source}) + + )} +
+
+ ); +} + +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. +

+ ) : ( + +
+ {/* Timeline rail */} +
+ +
+ {preview.map((item, index) => ( +
+
+ +
+ ))} + + {rest.length > 0 ? ( + + {rest.map((item, index) => ( +
+
+ +
+ ))} + + ) : null} +
+
+ + {rest.length > 0 ? ( +
+ + + +
+ ) : null} + + )} + + + ); +} + + diff --git a/apps/app/src/components/vendor-risk-assessment/VendorRiskAssessmentView.tsx b/apps/app/src/components/vendor-risk-assessment/VendorRiskAssessmentView.tsx new file mode 100644 index 000000000..67609689a --- /dev/null +++ b/apps/app/src/components/vendor-risk-assessment/VendorRiskAssessmentView.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { Card, CardContent, CardHeader, CardTitle } from '@comp/ui/card'; +import { Shield } from 'lucide-react'; +import { useMemo } from 'react'; +import { parseVendorRiskAssessmentDescription } from './parse-vendor-risk-assessment-description'; +import { VendorRiskAssessmentTimelineCard } from './VendorRiskAssessmentTimelineCard'; +import { SecurityAssessmentContent } from './SecurityAssessmentContent'; + +export type VendorRiskAssessmentViewSource = { + title: string; + description: string | null | undefined; + createdAt: string; + entityType?: string | null; + createdByName?: string | null; + createdByEmail?: string | null; +}; + +export function VendorRiskAssessmentView({ source }: { source: VendorRiskAssessmentViewSource }) { + const data = useMemo(() => { + return parseVendorRiskAssessmentDescription(source.description); + }, [source.description]); + + const links = data?.links ?? []; + const news = data?.news ?? []; + + return ( +
+
+ + + + + Security Assessment + + + + {data?.securityAssessment ? ( + + ) : ( +

+ No automated security assessment found. +

+ )} +
+
+ + +
+
+ ); +} + + diff --git a/apps/app/src/components/vendor-risk-assessment/filter-certifications.ts b/apps/app/src/components/vendor-risk-assessment/filter-certifications.ts new file mode 100644 index 000000000..2f6afd0ca --- /dev/null +++ b/apps/app/src/components/vendor-risk-assessment/filter-certifications.ts @@ -0,0 +1,57 @@ +import type { VendorRiskAssessmentCertification } from './vendor-risk-assessment-types'; + +/** + * Filter certifications to only show specific ones: + * - ISO 27001 (with partial matching: includes "iso" and "27001") + * - ISO 42001 (with partial matching: includes "iso" and "42001") + * - SOC 2 Type 1 (exact match) + * - SOC 2 Type 2 (exact match) + * - HIPAA (exact match) + */ +export function filterCertifications( + certifications: VendorRiskAssessmentCertification[] | null | undefined, +): VendorRiskAssessmentCertification[] { + if (!certifications || certifications.length === 0) { + return []; + } + + return certifications.filter((cert) => { + const typeLower = cert.type.toLowerCase().trim(); + + // ISO 27001 - partial matching + if (typeLower.includes('iso') && typeLower.includes('27001')) { + return true; + } + + // ISO 42001 - partial matching + if (typeLower.includes('iso') && typeLower.includes('42001')) { + return true; + } + + // SOC 2 Type 1 - check for "soc" and "type 1" or "type i" + if ( + typeLower.includes('soc') && + (typeLower.includes('type 1') || typeLower.includes('type i')) && + !typeLower.includes('type 2') && + !typeLower.includes('type ii') + ) { + return true; + } + + // SOC 2 Type 2 - check for "soc" and "type 2" or "type ii" + if ( + typeLower.includes('soc') && + (typeLower.includes('type 2') || typeLower.includes('type ii')) + ) { + return true; + } + + // HIPAA - exact match (case insensitive) + if (typeLower === 'hipaa' || typeLower === 'hipa') { + return true; + } + + return false; + }); +} + 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/vendor-risk-assessment/parse-vendor-risk-assessment-description.ts similarity index 97% rename from apps/app/src/components/task-items/generated-task/vendor-risk-assessment/parse-vendor-risk-assessment-description.ts rename to apps/app/src/components/vendor-risk-assessment/parse-vendor-risk-assessment-description.ts index e6ab8a8cd..8dfcfa3e6 100644 --- a/apps/app/src/components/task-items/generated-task/vendor-risk-assessment/parse-vendor-risk-assessment-description.ts +++ b/apps/app/src/components/vendor-risk-assessment/parse-vendor-risk-assessment-description.ts @@ -1,4 +1,7 @@ -import type { VendorRiskAssessmentDataV1, VendorRiskAssessmentLink } from './vendor-risk-assessment-types'; +import type { + VendorRiskAssessmentDataV1, + VendorRiskAssessmentLink, +} from './vendor-risk-assessment-types'; type TipTapNode = { type?: string; diff --git a/apps/app/src/components/task-items/generated-task/vendor-risk-assessment/vendor-risk-assessment-types.tsx b/apps/app/src/components/vendor-risk-assessment/vendor-risk-assessment-types.ts similarity index 100% rename from apps/app/src/components/task-items/generated-task/vendor-risk-assessment/vendor-risk-assessment-types.tsx rename to apps/app/src/components/vendor-risk-assessment/vendor-risk-assessment-types.ts diff --git a/apps/app/src/trigger/tasks/onboarding/onboard-organization-helpers.ts b/apps/app/src/trigger/tasks/onboarding/onboard-organization-helpers.ts index cae17ffe4..2ba9a0e43 100644 --- a/apps/app/src/trigger/tasks/onboarding/onboard-organization-helpers.ts +++ b/apps/app/src/trigger/tasks/onboarding/onboard-organization-helpers.ts @@ -851,11 +851,13 @@ export async function createVendors( }); // TODO: Un-comment this when UI part is ready - // await triggerVendorRiskAssessmentsViaApi({ - // organizationId, - // vendors: vendorsForRiskAssessment, - // withResearch: true, - // }); + await triggerVendorRiskAssessmentsViaApi({ + organizationId, + vendors: vendorsForRiskAssessment, + // Onboarding should NOT force expensive research if GlobalVendors already has data. + // If data is missing, the API/Trigger pipeline will still do research. + withResearch: false, + }); // Trigger background research for each vendor (best-effort) await triggerVendorResearch(createdVendors); diff --git a/apps/app/src/utils/normalize-website.ts b/apps/app/src/utils/normalize-website.ts new file mode 100644 index 000000000..581e9e075 --- /dev/null +++ b/apps/app/src/utils/normalize-website.ts @@ -0,0 +1,62 @@ +/** + * Extract domain from website URL for GlobalVendors lookup. + * Removes www. prefix and returns just the domain (e.g., "example.com"). + * + * Examples: + * - https://www.example.com/anything -> example.com + * - https://example.com/ -> example.com + * - http://www.example.com -> example.com + * - example.com (no protocol) -> example.com + */ +export function extractDomain(website: string | null | undefined): string | null { + if (!website) return null; + + const trimmed = website.trim(); + if (!trimmed) return null; + + try { + // Add protocol if missing to make URL parsing work + const urlString = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; + const url = new URL(urlString); + // Remove www. prefix and return just the domain + return url.hostname.toLowerCase().replace(/^www\./, ''); + } catch { + return null; + } +} + +/** + * Canonical website key for GlobalVendors storage: + * - Keeps protocol distinct (http vs https). + * - Treats "www." and non-"www" as the same vendor (drops leading www.). + * - Ignores path/query/hash (uses origin-style key). + * + * Examples: + * - https://www.example.com/anything -> https://example.com + * - https://example.com/ -> https://example.com + * - http://www.example.com -> http://example.com + * - example.com (no protocol) -> null (caller treats as invalid/missing) + */ +export function normalizeWebsite(website: string | null | undefined): string | null { + if (!website) return null; + + const trimmed = website.trim(); + if (!trimmed) return null; + + // Require explicit protocol to avoid silently forcing https. + if (!/^https?:\/\//i.test(trimmed)) { + return null; + } + + try { + const url = new URL(trimmed); + const protocol = url.protocol.toLowerCase(); + const hostname = url.hostname.toLowerCase().replace(/^www\./, ''); + const port = url.port ? `:${url.port}` : ''; + return `${protocol}//${hostname}${port}`; + } catch { + return null; + } +} + + diff --git a/packages/db/prisma/migrations/20251229205657_add_vendor_risk_assessment_fields/migration.sql b/packages/db/prisma/migrations/20251229205657_add_vendor_risk_assessment_fields/migration.sql new file mode 100644 index 000000000..360db147b --- /dev/null +++ b/packages/db/prisma/migrations/20251229205657_add_vendor_risk_assessment_fields/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "Vendor" ADD COLUMN "riskAssessmentData" JSONB, +ADD COLUMN "riskAssessmentUpdatedAt" TIMESTAMP(3), +ADD COLUMN "riskAssessmentVersion" TEXT; diff --git a/packages/db/prisma/migrations/20251231192605_add_risk_assessment_to_global_vendors_and_remove_from_vendor/migration.sql b/packages/db/prisma/migrations/20251231192605_add_risk_assessment_to_global_vendors_and_remove_from_vendor/migration.sql new file mode 100644 index 000000000..72fc1f74a --- /dev/null +++ b/packages/db/prisma/migrations/20251231192605_add_risk_assessment_to_global_vendors_and_remove_from_vendor/migration.sql @@ -0,0 +1,17 @@ +/* + Warnings: + + - You are about to drop the column `riskAssessmentData` on the `Vendor` table. All the data in the column will be lost. + - You are about to drop the column `riskAssessmentUpdatedAt` on the `Vendor` table. All the data in the column will be lost. + - You are about to drop the column `riskAssessmentVersion` on the `Vendor` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "GlobalVendors" ADD COLUMN "riskAssessmentData" JSONB, +ADD COLUMN "riskAssessmentUpdatedAt" TIMESTAMP(3), +ADD COLUMN "riskAssessmentVersion" TEXT; + +-- AlterTable +ALTER TABLE "Vendor" DROP COLUMN "riskAssessmentData", +DROP COLUMN "riskAssessmentUpdatedAt", +DROP COLUMN "riskAssessmentVersion"; diff --git a/packages/db/prisma/schema/shared.prisma b/packages/db/prisma/schema/shared.prisma index 5c9969dac..83339d52d 100644 --- a/packages/db/prisma/schema/shared.prisma +++ b/packages/db/prisma/schema/shared.prisma @@ -66,6 +66,11 @@ model GlobalVendors { subprocessors String[] type_of_company String? + // Vendor Risk Assessment (shared across all organizations) + riskAssessmentData Json? + riskAssessmentVersion String? + riskAssessmentUpdatedAt DateTime? + approved Boolean @default(false) createdAt DateTime @default(now()) diff --git a/packages/ui/src/components/badge.tsx b/packages/ui/src/components/badge.tsx index a6f023480..16e03042d 100644 --- a/packages/ui/src/components/badge.tsx +++ b/packages/ui/src/components/badge.tsx @@ -18,7 +18,7 @@ const badgeVariants = cva( "flex items-center opacity-80 px-3 font-mono gap-2 whitespace-nowrap border border bg-primary/10 text-primary hover:bg-primary/5 before:content-[''] before:absolute before:left-0 before:top-0 before:bottom-0 before:w-0.5 before:bg-primary", warning: 'border-transparent bg-warning text-white hover:bg-warning/80', success: - 'border-transparent bg-green-600 text-white hover:bg-green-600/80 dark:bg-green-600 dark:text-white', + 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80', }, }, defaultVariants: {