From 05d87d4fa2fd359009f7a7e0c35365e2d482e125 Mon Sep 17 00:00:00 2001
From: Tofik Hasanov
Date: Tue, 5 May 2026 13:03:35 -0400
Subject: [PATCH 02/15] fix(billing): surface wallet credits to pentest +
bg-check UIs
The backend already falls back from Stripe subscription to the
BillingCreditBalance wallet when an active subscription is missing or
exhausted, but `/v1/billing/status` only returned subscription data.
Both the pentest page and the background-check wizard computed their
"available scans" balance from subscription remainder alone, so admin-
granted credits were invisible: the New Scan button rerouted to
billing even though the create endpoint would have happily consumed a
wallet credit. Now `getStatus` aggregates wallet balances per product
and the two UIs add them to the displayed allowance, mirroring the
backend's consumption decision.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
apps/api/src/billing/billing.service.spec.ts | 81 +++++++++++++
apps/api/src/billing/billing.service.ts | 46 ++++++--
apps/api/src/billing/billing.types.ts | 10 ++
.../components/backgroundCheckTypes.ts | 7 ++
.../useEmployeeBackgroundCheckData.test.ts | 108 ++++++++++++++++++
.../useEmployeeBackgroundCheckData.ts | 26 ++++-
.../_components/SplitView.tsx | 34 ++----
.../_components/pentest-allowance.test.ts | 107 +++++++++++++++++
.../_components/pentest-allowance.ts | 71 ++++++++++++
.../settings/billing/emptyBillingStatus.ts | 1 +
.../(app)/[orgId]/settings/billing/types.ts | 8 ++
11 files changed, 460 insertions(+), 39 deletions(-)
create mode 100644 apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/useEmployeeBackgroundCheckData.test.ts
create mode 100644 apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/pentest-allowance.test.ts
create mode 100644 apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/pentest-allowance.ts
diff --git a/apps/api/src/billing/billing.service.spec.ts b/apps/api/src/billing/billing.service.spec.ts
index 05084a2b17..d2cc35915d 100644
--- a/apps/api/src/billing/billing.service.spec.ts
+++ b/apps/api/src/billing/billing.service.spec.ts
@@ -208,6 +208,87 @@ describe('BillingService', () => {
);
});
+ it('aggregates wallet credit balances per product in getStatus', async () => {
+ // The customer-facing /v1/billing/status response is the only
+ // surface the pentest + BG-check UIs read from. If we ever stop
+ // including creditBalances here, both UIs silently regress to
+ // paywalling users whose admin-granted credits would be consumed
+ // by the create endpoint. Lock the contract with this test.
+ const listBalances = jest.fn().mockResolvedValue([
+ {
+ id: 'bcb_1',
+ productKey: 'pentest',
+ skuKey: null,
+ balance: 3,
+ totalGranted: 5,
+ totalConsumed: 2,
+ totalRefunded: 0,
+ lastSource: 'manual',
+ updatedAt: '2026-05-01T00:00:00.000Z',
+ },
+ {
+ id: 'bcb_2',
+ productKey: 'pentest',
+ skuKey: 'pentest_monthly_1',
+ balance: 2,
+ totalGranted: 2,
+ totalConsumed: 0,
+ totalRefunded: 0,
+ lastSource: 'topup',
+ updatedAt: '2026-05-01T00:00:00.000Z',
+ },
+ {
+ id: 'bcb_3',
+ productKey: 'background_check',
+ skuKey: null,
+ balance: 4,
+ totalGranted: 4,
+ totalConsumed: 0,
+ totalRefunded: 0,
+ lastSource: 'manual',
+ updatedAt: '2026-05-01T00:00:00.000Z',
+ },
+ ]);
+ const service = new BillingService(
+ mockStripeService({
+ invoices: { list: jest.fn().mockResolvedValue({ data: [] }) },
+ customers: { retrieve: jest.fn().mockResolvedValue({}) },
+ paymentMethods: { retrieve: jest.fn() },
+ }),
+ { syncSubscriptionItem: jest.fn() } as never,
+ { listBalances } as never,
+ );
+
+ const result = await service.getStatus('org_1');
+
+ expect(listBalances).toHaveBeenCalledWith('org_1');
+ expect(result.creditBalances).toEqual(
+ expect.arrayContaining([
+ { productKey: 'pentest', balance: 5 },
+ { productKey: 'background_check', balance: 4 },
+ ]),
+ );
+ expect(result.creditBalances).toHaveLength(2);
+ });
+
+ it('returns an empty creditBalances array when no credits service is wired in', async () => {
+ // BillingCreditsService is @Optional() so unit tests can keep
+ // hand-constructing BillingService without it. Verify the absent-
+ // dependency branch produces a typesafe empty array, not undefined.
+ const service = new BillingService(
+ mockStripeService({
+ invoices: { list: jest.fn().mockResolvedValue({ data: [] }) },
+ customers: { retrieve: jest.fn().mockResolvedValue({}) },
+ paymentMethods: { retrieve: jest.fn() },
+ }),
+ { syncSubscriptionItem: jest.fn() } as never,
+ );
+
+ const result = await service.getStatus('org_1');
+
+ expect(result.creditBalances).toEqual([]);
+ });
+
it('marks trial eligibility false after any product subscription history', async () => {
organizationBillingSubscriptionFindMany.mockResolvedValue([
{
diff --git a/apps/api/src/billing/billing.service.ts b/apps/api/src/billing/billing.service.ts
index 43fa2d84cb..7fe8792bf4 100644
--- a/apps/api/src/billing/billing.service.ts
+++ b/apps/api/src/billing/billing.service.ts
@@ -2,6 +2,7 @@ import {
BadRequestException,
Injectable,
NotFoundException,
+ Optional,
} from '@nestjs/common';
import { db } from '@db';
import {
@@ -12,6 +13,7 @@ import {
isSubscriptionBillingSkuKey,
} from '@trycompai/billing';
import { StripeService } from '../stripe/stripe.service';
+import { BillingCreditsService } from './billing-credits.service';
import { findOrCreateBillingCustomer } from './billing-customer';
import { BillingEntitlementsService } from './billing-entitlements.service';
import { listBillingInvoices } from './billing-invoices';
@@ -38,6 +40,10 @@ export class BillingService {
constructor(
private readonly stripeService: StripeService,
private readonly entitlements: BillingEntitlementsService,
+ // Optional so existing unit tests that hand-construct BillingService
+ // (without a credits service) keep working. In production the
+ // BillingModule always provides it.
+ @Optional() private readonly credits?: BillingCreditsService,
) {}
async getStatus(organizationId: string): Promise {
@@ -67,18 +73,31 @@ export class BillingService {
db.backgroundCheckRequest.count({ where: { organizationId } }),
db.securityPenetrationTestRun.count({ where: { organizationId } }),
]);
- const [invoices, preferences, usageRows] = await Promise.all([
- listBillingInvoices({
- stripeService: this.stripeService,
- stripeCustomerId: billing?.stripeCustomerId ?? null,
- }),
- getBillingPreferences({
- stripeService: this.stripeService,
- stripeCustomerId: billing?.stripeCustomerId ?? null,
- fallbackCompanyName: organization.name,
- }),
- listBillingUsageRows({ organizationId, subscriptions }),
- ]);
+ const [invoices, preferences, usageRows, creditBalances] =
+ await Promise.all([
+ listBillingInvoices({
+ stripeService: this.stripeService,
+ stripeCustomerId: billing?.stripeCustomerId ?? null,
+ }),
+ getBillingPreferences({
+ stripeService: this.stripeService,
+ stripeCustomerId: billing?.stripeCustomerId ?? null,
+ fallbackCompanyName: organization.name,
+ }),
+ listBillingUsageRows({ organizationId, subscriptions }),
+ // Sum wallet balances per product. There can be multiple
+ // BillingCreditBalance rows per (org, product) when grants are
+ // scoped to a specific SKU, so we aggregate before returning.
+ this.credits
+ ? this.credits.listBalances(organizationId)
+ : Promise.resolve([]),
+ ]);
+
+ const creditBalancesByProduct = new Map();
+ for (const balance of creditBalances) {
+ const current = creditBalancesByProduct.get(balance.productKey) ?? 0;
+ creditBalancesByProduct.set(balance.productKey, current + balance.balance);
+ }
return {
hasBilling: !!billing,
@@ -98,6 +117,9 @@ export class BillingService {
currentPeriodEnd: subscription.currentPeriodEnd?.toISOString() ?? null,
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
})),
+ creditBalances: Array.from(creditBalancesByProduct.entries()).map(
+ ([productKey, balance]) => ({ productKey, balance }),
+ ),
invoices,
};
}
diff --git a/apps/api/src/billing/billing.types.ts b/apps/api/src/billing/billing.types.ts
index f9db394ae9..fd3b4e13c4 100644
--- a/apps/api/src/billing/billing.types.ts
+++ b/apps/api/src/billing/billing.types.ts
@@ -1,3 +1,4 @@
+import type { BillingProductKey } from '@trycompai/billing';
import type { BillingInvoice } from './billing-invoices';
import type { BillingPreferences } from './billing-preferences';
@@ -24,6 +25,15 @@ export interface BillingStatus {
currentPeriodEnd: string | null;
cancelAtPeriodEnd: boolean;
}>;
+ // Aggregated wallet balance per product. Mirrors what
+ // `BillingEntitlementsService.tryConsumeIncludedUsageForProduct` falls
+ // back to when a Stripe subscription is missing or exhausted, so the UI
+ // can keep its allowance display in sync with the backend's actual
+ // consumption decision.
+ creditBalances: Array<{
+ productKey: BillingProductKey;
+ balance: number;
+ }>;
invoices: BillingInvoice[];
}
diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/backgroundCheckTypes.ts b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/backgroundCheckTypes.ts
index 44347d001f..448129631b 100644
--- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/backgroundCheckTypes.ts
+++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/backgroundCheckTypes.ts
@@ -43,6 +43,13 @@ export interface BackgroundCheckBillingStatus {
currentPeriodEnd: string | null;
cancelAtPeriodEnd: boolean;
}>;
+ // Wallet credits per product. Optional because older API responses
+ // and existing test fixtures don't set it; treated as zero balance
+ // when absent.
+ creditBalances?: Array<{
+ productKey: 'pentest' | 'background_check';
+ balance: number;
+ }>;
}
export function isCompletedBackgroundCheck(status: BackgroundCheckStatus): boolean {
diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/useEmployeeBackgroundCheckData.test.ts b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/useEmployeeBackgroundCheckData.test.ts
new file mode 100644
index 0000000000..b42b63ac34
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/useEmployeeBackgroundCheckData.test.ts
@@ -0,0 +1,108 @@
+import { describe, expect, it } from 'vitest';
+import type { BackgroundCheckBillingStatus } from './backgroundCheckTypes';
+import { getBackgroundChecksRemaining } from './useEmployeeBackgroundCheckData';
+
+function status(
+ overrides: Partial = {},
+): BackgroundCheckBillingStatus {
+ return {
+ hasPaymentMethod: false,
+ setupAt: null,
+ ...overrides,
+ };
+}
+
+describe('getBackgroundChecksRemaining', () => {
+ it('returns null when billing status has not loaded', () => {
+ expect(getBackgroundChecksRemaining({ billingStatus: undefined })).toBeNull();
+ });
+
+ it('returns null when no subscription and no wallet credits exist', () => {
+ expect(getBackgroundChecksRemaining({ billingStatus: status() })).toBeNull();
+ });
+
+ it('returns subscription remainder when an active subscription exists', () => {
+ expect(
+ getBackgroundChecksRemaining({
+ billingStatus: status({
+ subscriptions: [
+ {
+ skuKey: 'background_checks_monthly_3',
+ status: 'active',
+ includedQuantity: 3,
+ usedQuantity: 1,
+ currentPeriodStart: null,
+ currentPeriodEnd: null,
+ cancelAtPeriodEnd: false,
+ },
+ ],
+ }),
+ }),
+ ).toBe(2);
+ });
+
+ it('returns wallet balance when no subscription but wallet credits exist', () => {
+ // Admin grant flow: organization has no subscription, platform
+ // admin granted 5 BG-check credits. Backend would consume from
+ // wallet on POST, so the wizard must allow the request.
+ expect(
+ getBackgroundChecksRemaining({
+ billingStatus: status({
+ creditBalances: [{ productKey: 'background_check', balance: 5 }],
+ }),
+ }),
+ ).toBe(5);
+ });
+
+ it('sums subscription remainder and wallet credits', () => {
+ expect(
+ getBackgroundChecksRemaining({
+ billingStatus: status({
+ subscriptions: [
+ {
+ skuKey: 'background_checks_monthly_3',
+ status: 'trialing',
+ includedQuantity: 3,
+ usedQuantity: 3,
+ currentPeriodStart: null,
+ currentPeriodEnd: null,
+ cancelAtPeriodEnd: false,
+ },
+ ],
+ creditBalances: [{ productKey: 'background_check', balance: 5 }],
+ }),
+ }),
+ ).toBe(5);
+ });
+
+ it('ignores pentest wallet credits when computing background-check allowance', () => {
+ expect(
+ getBackgroundChecksRemaining({
+ billingStatus: status({
+ creditBalances: [{ productKey: 'pentest', balance: 10 }],
+ }),
+ }),
+ ).toBeNull();
+ });
+
+ it('ignores cancelled subscriptions', () => {
+ expect(
+ getBackgroundChecksRemaining({
+ billingStatus: status({
+ subscriptions: [
+ {
+ skuKey: 'background_checks_monthly_3',
+ status: 'canceled',
+ includedQuantity: 3,
+ usedQuantity: 0,
+ currentPeriodStart: null,
+ currentPeriodEnd: null,
+ cancelAtPeriodEnd: false,
+ },
+ ],
+ creditBalances: [{ productKey: 'background_check', balance: 2 }],
+ }),
+ }),
+ ).toBe(2);
+ });
+});
diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/useEmployeeBackgroundCheckData.ts b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/useEmployeeBackgroundCheckData.ts
index 348047cdfe..c55edecb6a 100644
--- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/useEmployeeBackgroundCheckData.ts
+++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/useEmployeeBackgroundCheckData.ts
@@ -54,11 +54,31 @@ export function getBackgroundChecksRemaining({
}: {
billingStatus: BackgroundCheckBillingStatus | undefined;
}): number | null {
- const subscription = (billingStatus?.subscriptions ?? []).find(
+ if (!billingStatus) return null;
+ const subscription = (billingStatus.subscriptions ?? []).find(
(item) =>
getBillingSkuProductKey(item.skuKey) === 'background_check' &&
(item.status === 'active' || item.status === 'trialing'),
);
- if (!subscription) return null;
- return Math.max(subscription.includedQuantity - subscription.usedQuantity, 0);
+ // Wallet credits granted by platform admins. The backend's
+ // `BillingEntitlementsService.tryConsumeIncludedUsageForProduct`
+ // falls back to this wallet when no active subscription exists or
+ // the subscription's included usage is exhausted, so we mirror that
+ // logic here — otherwise the wizard paywalls users whose admin-
+ // granted credits would actually be consumed by the create call.
+ const walletBalance =
+ (billingStatus.creditBalances ?? []).find(
+ (entry) => entry.productKey === 'background_check',
+ )?.balance ?? 0;
+ if (!subscription) {
+ // Returning `null` keeps the existing "no allowance — go pick a
+ // plan" wizard path. We only have a positive allowance if there
+ // are wallet credits to consume.
+ return walletBalance > 0 ? walletBalance : null;
+ }
+ const subscriptionRemaining = Math.max(
+ subscription.includedQuantity - subscription.usedQuantity,
+ 0,
+ );
+ return subscriptionRemaining + walletBalance;
}
diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/SplitView.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/SplitView.tsx
index 79147c1014..0846203fff 100644
--- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/SplitView.tsx
+++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/SplitView.tsx
@@ -6,13 +6,16 @@ import type {
PentestIssue,
PentestRun,
} from '@/lib/security/penetration-tests-client';
-import { getBillingSkuProductKey } from '@trycompai/billing';
import { cn } from '@trycompai/design-system/cn';
import { ArrowLeft } from '@trycompai/design-system/icons';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'sonner';
import useSWR from 'swr';
+import {
+ getPentestAllowance,
+ type PentestBillingStatusInput,
+} from './pentest-allowance';
import {
useCreatePenetrationTest,
usePenetrationTest,
@@ -27,15 +30,6 @@ import { OverviewPane } from './OverviewPane';
import { RunList } from './RunList';
import './pentest-tokens.css';
-interface BillingStatus {
- subscriptions?: Array<{
- skuKey: string;
- status: string;
- includedQuantity: number;
- usedQuantity: number;
- }>;
-}
-
interface SplitViewProps {
orgId: string;
selectedRunId: string | null;
@@ -61,28 +55,20 @@ export function SplitView({ orgId, selectedRunId, mode = 'default' }: SplitViewP
const { issues } = usePenetrationTestIssues(orgId, selectedRunId ?? '', selectedRun?.status);
const { events } = usePenetrationTestEvents(orgId, selectedRunId ?? '', selectedRun?.status);
const { createReport, isCreating } = useCreatePenetrationTest(orgId);
- const { data: billingStatus } = useSWR(
+ const { data: billingStatus } = useSWR(
orgId ? (['/v1/billing/status', orgId] as const) : null,
async ([endpoint, organizationId]: readonly [string, string]) => {
- const response = await api.get(endpoint, organizationId);
+ const response = await api.get(
+ endpoint,
+ organizationId,
+ );
if (response.status < 200 || response.status >= 300) {
throw new Error(response.error ?? 'Failed to load billing status');
}
return response.data ?? {};
},
);
- const pentestSubscription = (billingStatus?.subscriptions ?? []).find(
- (subscription) =>
- getBillingSkuProductKey(subscription.skuKey) === 'pentest' &&
- (subscription.status === 'active' || subscription.status === 'trialing'),
- );
- const subscriptionBalance = pentestSubscription
- ? Math.max(pentestSubscription.includedQuantity - pentestSubscription.usedQuantity, 0)
- : null;
- // Keep `balance` undefined while billing is loading so the page does not
- // flash a blocked state before subscription allowance is known.
- const balance = subscriptionBalance ?? (billingStatus === undefined ? undefined : 0);
- const planRequired = subscriptionBalance === null && billingStatus !== undefined;
+ const { balance, planRequired } = getPentestAllowance(billingStatus);
const quotaLabel = 'Plan';
const showEmptyState =
diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/pentest-allowance.test.ts b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/pentest-allowance.test.ts
new file mode 100644
index 0000000000..22e1be6e2c
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/pentest-allowance.test.ts
@@ -0,0 +1,107 @@
+import { describe, expect, it } from 'vitest';
+import { getPentestAllowance } from './pentest-allowance';
+
+describe('getPentestAllowance', () => {
+ it('returns undefined balance while billing status is loading', () => {
+ expect(getPentestAllowance(undefined)).toEqual({
+ balance: undefined,
+ planRequired: false,
+ });
+ });
+
+ it('reports plan required when neither subscription nor wallet credits exist', () => {
+ expect(getPentestAllowance({})).toEqual({
+ balance: 0,
+ planRequired: true,
+ });
+ });
+
+ it('uses subscription remainder when a trial is active', () => {
+ expect(
+ getPentestAllowance({
+ subscriptions: [
+ {
+ skuKey: 'pentest_monthly_1',
+ status: 'trialing',
+ includedQuantity: 1,
+ usedQuantity: 0,
+ },
+ ],
+ }),
+ ).toEqual({ balance: 1, planRequired: false });
+ });
+
+ it('uses wallet credits when no subscription exists', () => {
+ // The "platform admin granted credits to a paywalled org" case —
+ // backend falls back to wallet, so the UI must too.
+ expect(
+ getPentestAllowance({
+ creditBalances: [{ productKey: 'pentest', balance: 5 }],
+ }),
+ ).toEqual({ balance: 5, planRequired: false });
+ });
+
+ it('sums subscription remainder and wallet credits', () => {
+ // Trial used (0 remaining) + 5 wallet credits => 5 total. Without
+ // this fix the UI would compute balance=0 and reroute to billing.
+ expect(
+ getPentestAllowance({
+ subscriptions: [
+ {
+ skuKey: 'pentest_monthly_1',
+ status: 'trialing',
+ includedQuantity: 1,
+ usedQuantity: 1,
+ },
+ ],
+ creditBalances: [{ productKey: 'pentest', balance: 5 }],
+ }),
+ ).toEqual({ balance: 5, planRequired: false });
+ });
+
+ it('clamps negative subscription remainder to zero before adding wallet credits', () => {
+ // Defensive: if Stripe ever reports usedQuantity > includedQuantity
+ // (e.g. proration glitch), the UI should not subtract from wallet.
+ expect(
+ getPentestAllowance({
+ subscriptions: [
+ {
+ skuKey: 'pentest_monthly_1',
+ status: 'active',
+ includedQuantity: 1,
+ usedQuantity: 5,
+ },
+ ],
+ creditBalances: [{ productKey: 'pentest', balance: 3 }],
+ }),
+ ).toEqual({ balance: 3, planRequired: false });
+ });
+
+ it('ignores wallet credits for unrelated products', () => {
+ expect(
+ getPentestAllowance({
+ creditBalances: [
+ { productKey: 'background_check', balance: 10 },
+ ],
+ }),
+ ).toEqual({ balance: 0, planRequired: true });
+ });
+
+ it('ignores cancelled subscriptions', () => {
+ // Match the backend's access-status filter — only active/trialing
+ // subscriptions count toward subscription remainder.
+ expect(
+ getPentestAllowance({
+ subscriptions: [
+ {
+ skuKey: 'pentest_monthly_1',
+ status: 'canceled',
+ includedQuantity: 1,
+ usedQuantity: 0,
+ },
+ ],
+ creditBalances: [{ productKey: 'pentest', balance: 2 }],
+ }),
+ ).toEqual({ balance: 2, planRequired: false });
+ });
+});
diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/pentest-allowance.ts b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/pentest-allowance.ts
new file mode 100644
index 0000000000..a39384ef39
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/_components/pentest-allowance.ts
@@ -0,0 +1,71 @@
+import { getBillingSkuProductKey } from '@trycompai/billing';
+
+/**
+ * Shape of `/v1/billing/status` fields the pentest UI consumes. We only
+ * declare what we read so the type doesn't drift from the broader
+ * BillingStatus contract every time an unrelated billing field
+ * changes.
+ */
+export interface PentestBillingStatusInput {
+ subscriptions?: Array<{
+ skuKey: string;
+ status: string;
+ includedQuantity: number;
+ usedQuantity: number;
+ }>;
+ creditBalances?: Array<{
+ productKey: 'pentest' | 'background_check';
+ balance: number;
+ }>;
+}
+
+export interface PentestAllowance {
+ /**
+ * Total runs the user can start right now: subscription remainder +
+ * admin-granted wallet credits. `undefined` while billing is still
+ * loading so the page does not flash a paywalled state.
+ */
+ balance: number | undefined;
+ /**
+ * True only when neither an active subscription nor a wallet credit
+ * exists. The UI uses this to decide whether to show "+ New Scan" or
+ * "View plans".
+ */
+ planRequired: boolean;
+}
+
+/**
+ * Compute the user-visible pentest allowance from billing status.
+ *
+ * Mirrors `BillingEntitlementsService.tryConsumeIncludedUsageForProduct`:
+ * the backend consumes from the active Stripe subscription first and
+ * falls back to the per-product wallet (BillingCreditBalance). Surfacing
+ * both here keeps the UI's gating in lockstep with what the create
+ * endpoint will actually accept — without this, admin-granted credits
+ * are invisible to the customer and the New Scan button incorrectly
+ * redirects to billing.
+ */
+export function getPentestAllowance(
+ billingStatus: PentestBillingStatusInput | undefined,
+): PentestAllowance {
+ if (billingStatus === undefined) {
+ return { balance: undefined, planRequired: false };
+ }
+ const subscription = (billingStatus.subscriptions ?? []).find(
+ (item) =>
+ getBillingSkuProductKey(item.skuKey) === 'pentest' &&
+ (item.status === 'active' || item.status === 'trialing'),
+ );
+ const subscriptionRemaining = subscription
+ ? Math.max(subscription.includedQuantity - subscription.usedQuantity, 0)
+ : null;
+ const walletBalance =
+ (billingStatus.creditBalances ?? []).find(
+ (entry) => entry.productKey === 'pentest',
+ )?.balance ?? 0;
+
+ return {
+ balance: (subscriptionRemaining ?? 0) + walletBalance,
+ planRequired: subscriptionRemaining === null && walletBalance === 0,
+ };
+}
diff --git a/apps/app/src/app/(app)/[orgId]/settings/billing/emptyBillingStatus.ts b/apps/app/src/app/(app)/[orgId]/settings/billing/emptyBillingStatus.ts
index 635758166c..defe12071e 100644
--- a/apps/app/src/app/(app)/[orgId]/settings/billing/emptyBillingStatus.ts
+++ b/apps/app/src/app/(app)/[orgId]/settings/billing/emptyBillingStatus.ts
@@ -9,6 +9,7 @@ export const emptyBillingStatus: BackgroundCheckBillingStatus = {
},
invoices: [],
subscriptions: [],
+ creditBalances: [],
trialEligibility: {
pentest: false,
background_check: false,
diff --git a/apps/app/src/app/(app)/[orgId]/settings/billing/types.ts b/apps/app/src/app/(app)/[orgId]/settings/billing/types.ts
index 71f1b1885d..6f8fc66c26 100644
--- a/apps/app/src/app/(app)/[orgId]/settings/billing/types.ts
+++ b/apps/app/src/app/(app)/[orgId]/settings/billing/types.ts
@@ -34,6 +34,14 @@ export interface BackgroundCheckBillingStatus {
currentPeriodEnd: string | null;
cancelAtPeriodEnd: boolean;
}>;
+ // Aggregated wallet credit balance per product. The API guarantees
+ // one entry per product key; an absent product means zero balance.
+ // Optional on the client so older payloads (and existing test
+ // fixtures) keep typechecking.
+ creditBalances?: Array<{
+ productKey: 'pentest' | 'background_check';
+ balance: number;
+ }>;
invoices?: BillingInvoice[];
}
From 250a392e9141b266c01c62a42dd03639e330a562 Mon Sep 17 00:00:00 2001
From: Tofik Hasanov
Date: Tue, 5 May 2026 14:42:40 -0400
Subject: [PATCH 03/15] refactor(stripe): move upgrade-page auto-approval into
API
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The /upgrade page was instantiating its own Stripe client and writing
hasAccess directly to the DB from a Next.js server component. That meant
STRIPE_SECRET_KEY had to live on Vercel in addition to the API, the
hasAccess flip skipped the API's audit log + RBAC, and we had two
Stripe clients drifting apart over time.
Move both the Stripe lookup and the hasAccess write into a new API
endpoint:
POST /v1/organization-access/auto-approve
guarded by HybridAuthGuard + PermissionGuard with
@RequirePermission('organization', 'update'). The endpoint reuses the
existing global StripeService — no second Stripe client. Decision
matrix preserved exactly: self-hosted, @trycomp.ai email, or
domain-matched active Stripe customer.
App side: upgrade page now calls serverApi instead of importing
@/lib/stripe and writing to db; lib/stripe.ts and the
STRIPE_SECRET_KEY env declaration are removed from the Next.js app.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
apps/api/src/app.module.ts | 2 +
.../organization-access.controller.ts | 44 ++++
.../organization-access.module.ts | 11 +
.../organization-access.service.spec.ts | 239 ++++++++++++++++++
.../organization-access.service.ts | 115 +++++++++
apps/api/src/stripe/domain.utils.spec.ts | 46 ++++
apps/api/src/stripe/domain.utils.ts | 57 +++++
apps/api/src/stripe/stripe.service.ts | 99 ++++++++
.../src/app/(app)/upgrade/[orgId]/page.tsx | 58 ++---
apps/app/src/env.mjs | 2 -
apps/app/src/lib/stripe.ts | 186 --------------
11 files changed, 632 insertions(+), 227 deletions(-)
create mode 100644 apps/api/src/organization-access/organization-access.controller.ts
create mode 100644 apps/api/src/organization-access/organization-access.module.ts
create mode 100644 apps/api/src/organization-access/organization-access.service.spec.ts
create mode 100644 apps/api/src/organization-access/organization-access.service.ts
create mode 100644 apps/api/src/stripe/domain.utils.spec.ts
create mode 100644 apps/api/src/stripe/domain.utils.ts
delete mode 100644 apps/app/src/lib/stripe.ts
diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts
index 318ba6afde..bf6d8b15fb 100644
--- a/apps/api/src/app.module.ts
+++ b/apps/api/src/app.module.ts
@@ -14,6 +14,7 @@ import { awsConfig } from './config/aws.config';
import { betterAuthConfig } from './config/better-auth.config';
import { HealthModule } from './health/health.module';
import { OrganizationModule } from './organization/organization.module';
+import { OrganizationAccessModule } from './organization-access/organization-access.module';
import { PoliciesModule } from './policies/policies.module';
import { RisksModule } from './risks/risks.module';
import { TasksModule } from './tasks/tasks.module';
@@ -74,6 +75,7 @@ import { BillingModule } from './billing/billing.module';
]),
AuthModule,
OrganizationModule,
+ OrganizationAccessModule,
PeopleModule,
RisksModule,
VendorsModule,
diff --git a/apps/api/src/organization-access/organization-access.controller.ts b/apps/api/src/organization-access/organization-access.controller.ts
new file mode 100644
index 0000000000..67d38850fa
--- /dev/null
+++ b/apps/api/src/organization-access/organization-access.controller.ts
@@ -0,0 +1,44 @@
+import {
+ Controller,
+ HttpCode,
+ HttpStatus,
+ Post,
+ UseGuards,
+} from '@nestjs/common';
+import { ApiOperation, ApiTags } from '@nestjs/swagger';
+import { AuthContext, OrganizationId } from '../auth/auth-context.decorator';
+import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
+import { PermissionGuard } from '../auth/permission.guard';
+import { RequirePermission } from '../auth/require-permission.decorator';
+import type { AuthContext as AuthContextType } from '../auth/types';
+import {
+ AutoApproveResult,
+ OrganizationAccessService,
+} from './organization-access.service';
+
+@ApiTags('Organization')
+@Controller({ path: 'organization-access', version: '1' })
+@UseGuards(HybridAuthGuard, PermissionGuard)
+export class OrganizationAccessController {
+ constructor(
+ private readonly organizationAccessService: OrganizationAccessService,
+ ) {}
+
+ @Post('auto-approve')
+ @HttpCode(HttpStatus.OK)
+ @RequirePermission('organization', 'update')
+ @ApiOperation({
+ summary: 'Auto-approve organization access via domain or self-hosted check',
+ description:
+ 'Grants hasAccess on the active organization if the requesting user is an internal trycomp.ai user, the deployment is self-hosted, or the user email domain matches the organization website domain and is an active Stripe customer.',
+ })
+ async autoApprove(
+ @OrganizationId() organizationId: string,
+ @AuthContext() authContext: AuthContextType,
+ ): Promise {
+ return this.organizationAccessService.autoApproveAccess({
+ organizationId,
+ userEmail: authContext.userEmail,
+ });
+ }
+}
diff --git a/apps/api/src/organization-access/organization-access.module.ts b/apps/api/src/organization-access/organization-access.module.ts
new file mode 100644
index 0000000000..378b96789e
--- /dev/null
+++ b/apps/api/src/organization-access/organization-access.module.ts
@@ -0,0 +1,11 @@
+import { Module } from '@nestjs/common';
+import { AuthModule } from '../auth/auth.module';
+import { OrganizationAccessController } from './organization-access.controller';
+import { OrganizationAccessService } from './organization-access.service';
+
+@Module({
+ imports: [AuthModule],
+ controllers: [OrganizationAccessController],
+ providers: [OrganizationAccessService],
+})
+export class OrganizationAccessModule {}
diff --git a/apps/api/src/organization-access/organization-access.service.spec.ts b/apps/api/src/organization-access/organization-access.service.spec.ts
new file mode 100644
index 0000000000..211b60779f
--- /dev/null
+++ b/apps/api/src/organization-access/organization-access.service.spec.ts
@@ -0,0 +1,239 @@
+import { NotFoundException } from '@nestjs/common';
+import { db } from '@db';
+import { OrganizationAccessService } from './organization-access.service';
+
+jest.mock('@db', () => ({
+ db: {
+ organization: {
+ findUnique: jest.fn(),
+ update: jest.fn(),
+ },
+ },
+}));
+
+const mockedDb = db as unknown as {
+ organization: {
+ findUnique: jest.Mock;
+ update: jest.Mock;
+ };
+};
+
+const buildService = (
+ overrides: Partial<{ isDomainActiveCustomer: jest.Mock }> = {},
+) => {
+ const isDomainActiveCustomer =
+ overrides.isDomainActiveCustomer ?? jest.fn().mockResolvedValue(false);
+ const stripeService = { isDomainActiveCustomer } as never;
+ return {
+ service: new OrganizationAccessService(stripeService),
+ isDomainActiveCustomer,
+ };
+};
+
+describe('OrganizationAccessService', () => {
+ const ORIGINAL_SELF_HOSTED = process.env.SELF_HOSTED;
+ const ORIGINAL_NEXT_SELF_HOSTED = process.env.NEXT_PUBLIC_SELF_HOSTED;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ delete process.env.SELF_HOSTED;
+ delete process.env.NEXT_PUBLIC_SELF_HOSTED;
+ mockedDb.organization.update.mockResolvedValue({});
+ });
+
+ afterAll(() => {
+ if (ORIGINAL_SELF_HOSTED === undefined) {
+ delete process.env.SELF_HOSTED;
+ } else {
+ process.env.SELF_HOSTED = ORIGINAL_SELF_HOSTED;
+ }
+ if (ORIGINAL_NEXT_SELF_HOSTED === undefined) {
+ delete process.env.NEXT_PUBLIC_SELF_HOSTED;
+ } else {
+ process.env.NEXT_PUBLIC_SELF_HOSTED = ORIGINAL_NEXT_SELF_HOSTED;
+ }
+ });
+
+ it('throws NotFoundException when org does not exist', async () => {
+ mockedDb.organization.findUnique.mockResolvedValue(null);
+ const { service } = buildService();
+
+ await expect(
+ service.autoApproveAccess({
+ organizationId: 'org_x',
+ userEmail: 'a@b.com',
+ }),
+ ).rejects.toBeInstanceOf(NotFoundException);
+ });
+
+ it('returns already-has-access without writing when org has access', async () => {
+ mockedDb.organization.findUnique.mockResolvedValue({
+ id: 'org_1',
+ hasAccess: true,
+ website: 'acme.com',
+ });
+ const { service } = buildService();
+
+ const result = await service.autoApproveAccess({
+ organizationId: 'org_1',
+ userEmail: 'user@acme.com',
+ });
+
+ expect(result).toEqual({
+ hasAccess: true,
+ autoApproved: false,
+ reason: 'already-has-access',
+ });
+ expect(mockedDb.organization.update).not.toHaveBeenCalled();
+ });
+
+ it('grants on self-hosted via SELF_HOSTED env', async () => {
+ process.env.SELF_HOSTED = 'true';
+ mockedDb.organization.findUnique.mockResolvedValue({
+ id: 'org_1',
+ hasAccess: false,
+ website: null,
+ });
+ const { service, isDomainActiveCustomer } = buildService();
+
+ const result = await service.autoApproveAccess({
+ organizationId: 'org_1',
+ userEmail: 'user@gmail.com',
+ });
+
+ expect(result).toEqual({
+ hasAccess: true,
+ autoApproved: true,
+ reason: 'self-hosted',
+ });
+ expect(mockedDb.organization.update).toHaveBeenCalledWith({
+ where: { id: 'org_1' },
+ data: { hasAccess: true },
+ });
+ expect(isDomainActiveCustomer).not.toHaveBeenCalled();
+ });
+
+ it('grants on @trycomp.ai email without consulting Stripe', async () => {
+ mockedDb.organization.findUnique.mockResolvedValue({
+ id: 'org_1',
+ hasAccess: false,
+ website: 'acme.com',
+ });
+ const { service, isDomainActiveCustomer } = buildService();
+
+ const result = await service.autoApproveAccess({
+ organizationId: 'org_1',
+ userEmail: 'tofik@trycomp.ai',
+ });
+
+ expect(result).toEqual({
+ hasAccess: true,
+ autoApproved: true,
+ reason: 'trycomp-email',
+ });
+ expect(isDomainActiveCustomer).not.toHaveBeenCalled();
+ expect(mockedDb.organization.update).toHaveBeenCalled();
+ });
+
+ it('grants when user email domain matches org website AND is an active Stripe customer', async () => {
+ mockedDb.organization.findUnique.mockResolvedValue({
+ id: 'org_1',
+ hasAccess: false,
+ website: 'https://acme.com',
+ });
+ const { service, isDomainActiveCustomer } = buildService({
+ isDomainActiveCustomer: jest.fn().mockResolvedValue(true),
+ });
+
+ const result = await service.autoApproveAccess({
+ organizationId: 'org_1',
+ userEmail: 'cfo@acme.com',
+ });
+
+ expect(result).toEqual({
+ hasAccess: true,
+ autoApproved: true,
+ reason: 'stripe-customer',
+ });
+ expect(isDomainActiveCustomer).toHaveBeenCalledWith('acme.com');
+ expect(mockedDb.organization.update).toHaveBeenCalled();
+ });
+
+ it('does not grant when domain matches but Stripe says not an active customer', async () => {
+ mockedDb.organization.findUnique.mockResolvedValue({
+ id: 'org_1',
+ hasAccess: false,
+ website: 'acme.com',
+ });
+ const { service } = buildService({
+ isDomainActiveCustomer: jest.fn().mockResolvedValue(false),
+ });
+
+ const result = await service.autoApproveAccess({
+ organizationId: 'org_1',
+ userEmail: 'cfo@acme.com',
+ });
+
+ expect(result).toEqual({
+ hasAccess: false,
+ autoApproved: false,
+ reason: 'not-eligible',
+ });
+ expect(mockedDb.organization.update).not.toHaveBeenCalled();
+ });
+
+ it('does not grant when user email domain mismatches org website', async () => {
+ mockedDb.organization.findUnique.mockResolvedValue({
+ id: 'org_1',
+ hasAccess: false,
+ website: 'acme.com',
+ });
+ const { service, isDomainActiveCustomer } = buildService();
+
+ const result = await service.autoApproveAccess({
+ organizationId: 'org_1',
+ userEmail: 'cfo@example.com',
+ });
+
+ expect(result.autoApproved).toBe(false);
+ expect(result.reason).toBe('not-eligible');
+ expect(isDomainActiveCustomer).not.toHaveBeenCalled();
+ });
+
+ it('does not grant when user domain is a public mailbox provider', async () => {
+ mockedDb.organization.findUnique.mockResolvedValue({
+ id: 'org_1',
+ hasAccess: false,
+ website: 'gmail.com', // pathological — even if website "matches", we refuse
+ });
+ const { service, isDomainActiveCustomer } = buildService();
+
+ const result = await service.autoApproveAccess({
+ organizationId: 'org_1',
+ userEmail: 'someone@gmail.com',
+ });
+
+ expect(result.autoApproved).toBe(false);
+ expect(isDomainActiveCustomer).not.toHaveBeenCalled();
+ });
+
+ it('does not grant when user email is missing', async () => {
+ mockedDb.organization.findUnique.mockResolvedValue({
+ id: 'org_1',
+ hasAccess: false,
+ website: 'acme.com',
+ });
+ const { service } = buildService();
+
+ const result = await service.autoApproveAccess({
+ organizationId: 'org_1',
+ userEmail: undefined,
+ });
+
+ expect(result).toEqual({
+ hasAccess: false,
+ autoApproved: false,
+ reason: 'not-eligible',
+ });
+ });
+});
diff --git a/apps/api/src/organization-access/organization-access.service.ts b/apps/api/src/organization-access/organization-access.service.ts
new file mode 100644
index 0000000000..818acecfc9
--- /dev/null
+++ b/apps/api/src/organization-access/organization-access.service.ts
@@ -0,0 +1,115 @@
+import { Injectable, Logger, NotFoundException } from '@nestjs/common';
+import { db } from '@db';
+import { extractDomain, isPublicEmailDomain } from '../stripe/domain.utils';
+import { StripeService } from '../stripe/stripe.service';
+
+export type AutoApproveReason =
+ | 'already-has-access'
+ | 'self-hosted'
+ | 'trycomp-email'
+ | 'stripe-customer'
+ | 'not-eligible';
+
+export interface AutoApproveResult {
+ hasAccess: boolean;
+ autoApproved: boolean;
+ reason: AutoApproveReason;
+}
+
+const isSelfHosted = (): boolean =>
+ process.env.SELF_HOSTED === 'true' ||
+ process.env.NEXT_PUBLIC_SELF_HOSTED === 'true';
+
+@Injectable()
+export class OrganizationAccessService {
+ private readonly logger = new Logger(OrganizationAccessService.name);
+
+ constructor(private readonly stripeService: StripeService) {}
+
+ /**
+ * Decide whether to grant `hasAccess` to the org for the given user, and
+ * persist the flag if so. Membership is enforced upstream by HybridAuthGuard
+ * (the auth guard rejects requests where the user isn't an active member of
+ * the active organization), but we re-fetch the organization here for the
+ * website + current hasAccess values.
+ *
+ * Decision rules (in order):
+ * 1. Org already has access → no-op.
+ * 2. Self-hosted instance → grant.
+ * 3. User email at @trycomp.ai → grant (internal team).
+ * 4. User email domain matches the org website domain AND that domain has
+ * an active Stripe customer → grant. Public mailbox domains are
+ * excluded.
+ * 5. Otherwise → not eligible.
+ */
+ async autoApproveAccess(input: {
+ organizationId: string;
+ userEmail: string | undefined;
+ }): Promise {
+ const { organizationId, userEmail } = input;
+
+ const organization = await db.organization.findUnique({
+ where: { id: organizationId },
+ select: { id: true, hasAccess: true, website: true },
+ });
+
+ if (!organization) {
+ throw new NotFoundException('Organization not found');
+ }
+
+ if (organization.hasAccess) {
+ return {
+ hasAccess: true,
+ autoApproved: false,
+ reason: 'already-has-access',
+ };
+ }
+
+ if (isSelfHosted()) {
+ await this.grantAccess(organizationId);
+ this.logger.log(
+ `Auto-approved org ${organizationId} (reason: self-hosted)`,
+ );
+ return { hasAccess: true, autoApproved: true, reason: 'self-hosted' };
+ }
+
+ const userEmailDomain = extractDomain(userEmail);
+ const orgWebsiteDomain = extractDomain(organization.website);
+
+ if (!userEmailDomain) {
+ return { hasAccess: false, autoApproved: false, reason: 'not-eligible' };
+ }
+
+ const isTrycompEmail = userEmailDomain === 'trycomp.ai';
+
+ const canAutoApproveViaDomain =
+ !isTrycompEmail &&
+ Boolean(orgWebsiteDomain) &&
+ userEmailDomain === orgWebsiteDomain &&
+ !isPublicEmailDomain(userEmailDomain);
+
+ const isStripeCustomer = canAutoApproveViaDomain
+ ? await this.stripeService.isDomainActiveCustomer(userEmailDomain)
+ : false;
+
+ if (isTrycompEmail || isStripeCustomer) {
+ await this.grantAccess(organizationId);
+ const reason: AutoApproveReason = isTrycompEmail
+ ? 'trycomp-email'
+ : 'stripe-customer';
+ this.logger.log(
+ `Auto-approved org ${organizationId} (reason: ${reason}, domain: ${userEmailDomain})`,
+ );
+ return { hasAccess: true, autoApproved: true, reason };
+ }
+
+ return { hasAccess: false, autoApproved: false, reason: 'not-eligible' };
+ }
+
+ private async grantAccess(organizationId: string): Promise {
+ await db.organization.update({
+ where: { id: organizationId },
+ data: { hasAccess: true },
+ });
+ }
+}
diff --git a/apps/api/src/stripe/domain.utils.spec.ts b/apps/api/src/stripe/domain.utils.spec.ts
new file mode 100644
index 0000000000..b9df4964a0
--- /dev/null
+++ b/apps/api/src/stripe/domain.utils.spec.ts
@@ -0,0 +1,46 @@
+import { extractDomain, isPublicEmailDomain } from './domain.utils';
+
+describe('extractDomain', () => {
+ it('returns null for empty input', () => {
+ expect(extractDomain(null)).toBeNull();
+ expect(extractDomain(undefined)).toBeNull();
+ expect(extractDomain('')).toBeNull();
+ });
+
+ it('extracts domain from email', () => {
+ expect(extractDomain('user@acme.com')).toBe('acme.com');
+ expect(extractDomain('USER@ACME.COM')).toBe('acme.com');
+ });
+
+ it('extracts hostname from a URL and strips www.', () => {
+ expect(extractDomain('https://www.acme.com/foo')).toBe('acme.com');
+ expect(extractDomain('http://acme.com')).toBe('acme.com');
+ });
+
+ it('adds protocol when missing', () => {
+ expect(extractDomain('acme.com')).toBe('acme.com');
+ expect(extractDomain('www.acme.com/path')).toBe('acme.com');
+ });
+
+ it('returns null on garbage input', () => {
+ expect(extractDomain(' ')).toBeNull();
+ });
+});
+
+describe('isPublicEmailDomain', () => {
+ it('flags common free providers', () => {
+ expect(isPublicEmailDomain('gmail.com')).toBe(true);
+ expect(isPublicEmailDomain('outlook.com')).toBe(true);
+ expect(isPublicEmailDomain('icloud.com')).toBe(true);
+ });
+
+ it('is case- and trailing-dot-insensitive', () => {
+ expect(isPublicEmailDomain('GMAIL.COM')).toBe(true);
+ expect(isPublicEmailDomain('gmail.com.')).toBe(true);
+ });
+
+ it('returns false for a real company domain', () => {
+ expect(isPublicEmailDomain('acme.com')).toBe(false);
+ expect(isPublicEmailDomain('trycomp.ai')).toBe(false);
+ });
+});
diff --git a/apps/api/src/stripe/domain.utils.ts b/apps/api/src/stripe/domain.utils.ts
new file mode 100644
index 0000000000..954f5646f6
--- /dev/null
+++ b/apps/api/src/stripe/domain.utils.ts
@@ -0,0 +1,57 @@
+// Public/free mailbox providers — domain ownership of these does NOT imply
+// company affiliation, so they must never be used for domain-based auto-approval.
+const PUBLIC_EMAIL_DOMAINS = new Set([
+ // Google
+ 'gmail.com',
+ 'googlemail.com',
+ // Microsoft
+ 'outlook.com',
+ 'hotmail.com',
+ 'live.com',
+ 'msn.com',
+ // Yahoo
+ 'yahoo.com',
+ 'ymail.com',
+ // Apple
+ 'icloud.com',
+ 'me.com',
+ 'mac.com',
+ // Proton
+ 'proton.me',
+ 'protonmail.com',
+ 'pm.me',
+ // AOL
+ 'aol.com',
+]);
+
+export const isPublicEmailDomain = (domain: string): boolean => {
+ const normalized = domain.toLowerCase().trim().replace(/\.$/, '');
+ return PUBLIC_EMAIL_DOMAINS.has(normalized);
+};
+
+/**
+ * Extract a normalized domain from either a website URL or an email address.
+ * Returns null on empty/invalid input.
+ */
+export const extractDomain = (
+ input: string | null | undefined,
+): string | null => {
+ if (!input) return null;
+
+ try {
+ if (input.includes('@') && !input.includes('://')) {
+ const domain = input.split('@')[1]?.toLowerCase().trim();
+ return domain || null;
+ }
+
+ let url = input.trim().toLowerCase();
+ if (!url.startsWith('http://') && !url.startsWith('https://')) {
+ url = `https://${url}`;
+ }
+
+ const parsed = new URL(url);
+ return parsed.hostname.replace(/^www\./, '');
+ } catch {
+ return null;
+ }
+};
diff --git a/apps/api/src/stripe/stripe.service.ts b/apps/api/src/stripe/stripe.service.ts
index 3eac26d69a..274a29ee8e 100644
--- a/apps/api/src/stripe/stripe.service.ts
+++ b/apps/api/src/stripe/stripe.service.ts
@@ -1,5 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import Stripe from 'stripe';
+import { isPublicEmailDomain } from './domain.utils';
@Injectable()
export class StripeService {
@@ -28,4 +29,102 @@ export class StripeService {
isConfigured(): boolean {
return this.client !== null;
}
+
+ /**
+ * Look up a Stripe customer by company domain. Prefers an exact match against
+ * the customer's `domain` metadata, then falls back to scanning customers
+ * whose primary email belongs to that domain. Public mailbox domains
+ * (gmail.com, etc.) are never matched — domain ownership of those does not
+ * imply company affiliation.
+ */
+ async findCustomerByDomain(
+ domain: string,
+ ): Promise<{ customerId: string; customerName: string | null } | null> {
+ if (!this.client || !domain) {
+ return null;
+ }
+
+ const normalizedDomain = domain.toLowerCase().trim().replace(/\.$/, '');
+ if (isPublicEmailDomain(normalizedDomain)) {
+ return null;
+ }
+
+ try {
+ const byMetadata = await this.client.customers.search({
+ query: `metadata["domain"]:"${normalizedDomain}"`,
+ limit: 1,
+ });
+
+ if (byMetadata.data.length > 0) {
+ const customer = byMetadata.data[0];
+ return {
+ customerId: customer.id,
+ customerName: customer.name ?? null,
+ };
+ }
+
+ // `email~` is substring matching — re-filter for exact email-domain match.
+ const byEmail = await this.client.customers.search({
+ query: `email~"@${normalizedDomain}"`,
+ limit: 25,
+ });
+
+ const exactMatch = byEmail.data.find((customer) => {
+ const email = customer.email ?? '';
+ const emailDomain = email.split('@')[1]?.toLowerCase().trim() ?? '';
+ return emailDomain === normalizedDomain;
+ });
+
+ if (exactMatch) {
+ return {
+ customerId: exactMatch.id,
+ customerName: exactMatch.name ?? null,
+ };
+ }
+
+ return null;
+ } catch (error) {
+ this.logger.error(
+ `Error searching Stripe customers by domain "${normalizedDomain}"`,
+ error instanceof Error ? error.stack : undefined,
+ );
+ return null;
+ }
+ }
+
+ /**
+ * Returns true when the given domain belongs to a Stripe customer with at
+ * least one active subscription. Used for domain-based auto-approval of
+ * organization access.
+ */
+ async isDomainActiveCustomer(domain: string): Promise {
+ if (!this.client) {
+ return false;
+ }
+
+ const normalizedDomain = domain.toLowerCase().trim().replace(/\.$/, '');
+ if (!normalizedDomain || isPublicEmailDomain(normalizedDomain)) {
+ return false;
+ }
+
+ const customer = await this.findCustomerByDomain(normalizedDomain);
+ if (!customer) {
+ return false;
+ }
+
+ try {
+ const subscriptions = await this.client.subscriptions.list({
+ customer: customer.customerId,
+ status: 'active',
+ limit: 1,
+ });
+ return subscriptions.data.length > 0;
+ } catch (error) {
+ this.logger.error(
+ `Error checking active subscriptions for customer "${customer.customerId}"`,
+ error instanceof Error ? error.stack : undefined,
+ );
+ return false;
+ }
+ }
}
diff --git a/apps/app/src/app/(app)/upgrade/[orgId]/page.tsx b/apps/app/src/app/(app)/upgrade/[orgId]/page.tsx
index d33ae89074..45913640f3 100644
--- a/apps/app/src/app/(app)/upgrade/[orgId]/page.tsx
+++ b/apps/app/src/app/(app)/upgrade/[orgId]/page.tsx
@@ -1,6 +1,5 @@
-import { extractDomain, isDomainActiveStripeCustomer, isPublicEmailDomain } from '@/lib/stripe';
+import { serverApi } from '@/lib/api-server';
import { auth } from '@/utils/auth';
-import { env } from '@/env.mjs';
import { db } from '@db/server';
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';
@@ -13,6 +12,12 @@ interface PageProps {
}>;
}
+interface AutoApproveResponse {
+ hasAccess: boolean;
+ autoApproved: boolean;
+ reason: string;
+}
+
export default async function UpgradePage({ params }: PageProps) {
const { orgId } = await params;
@@ -44,7 +49,9 @@ export default async function UpgradePage({ params }: PageProps) {
redirect('/');
}
- // Sync activeOrganizationId only after membership is verified
+ // Sync activeOrganizationId only after membership is verified.
+ // Required so the API's HybridAuthGuard resolves the right org from session
+ // when we call /v1/organization-access/auto-approve below.
const currentActiveOrgId = authSession.session.activeOrganizationId;
if (!currentActiveOrgId || currentActiveOrgId !== orgId) {
try {
@@ -61,45 +68,18 @@ export default async function UpgradePage({ params }: PageProps) {
let hasAccess = member.organization.hasAccess;
- // Auto-approve based on user's email domain or self-hosted instance
+ // Auto-approval (self-hosted, trycomp emails, domain-matched Stripe customers)
+ // is decided server-side by the API, which also persists hasAccess. Soft-fail
+ // so a transient API error never blocks the booking step from rendering.
if (!hasAccess) {
- // Auto-approve for self-hosted/OSS instances
- const isSelfHosted = env.NEXT_PUBLIC_SELF_HOSTED === 'true';
+ const response = await serverApi.post(
+ '/v1/organization-access/auto-approve',
+ );
- if (isSelfHosted) {
- await db.organization.update({
- where: { id: orgId },
- data: { hasAccess: true },
- });
+ if (response.data?.hasAccess) {
hasAccess = true;
- } else {
- const userEmail = authSession.user.email;
- const userEmailDomain = extractDomain(userEmail ?? '');
- const orgWebsiteDomain = extractDomain(member.organization.website ?? '');
-
- if (userEmailDomain) {
- // Auto-approve for trycomp.ai emails (internal team)
- const isTrycompEmail = userEmailDomain === 'trycomp.ai';
-
- const canAutoApproveViaDomain =
- !isTrycompEmail &&
- Boolean(orgWebsiteDomain) &&
- userEmailDomain === orgWebsiteDomain &&
- !isPublicEmailDomain(userEmailDomain);
-
- // Check Stripe for other domains
- const isStripeCustomer = canAutoApproveViaDomain
- ? await isDomainActiveStripeCustomer(userEmailDomain)
- : false;
-
- if (isTrycompEmail || isStripeCustomer) {
- await db.organization.update({
- where: { id: orgId },
- data: { hasAccess: true },
- });
- hasAccess = true;
- }
- }
+ } else if (response.error) {
+ console.error('[UpgradePage] auto-approve API error:', response.error);
}
}
diff --git a/apps/app/src/env.mjs b/apps/app/src/env.mjs
index b4e338eab9..d97b739e5d 100644
--- a/apps/app/src/env.mjs
+++ b/apps/app/src/env.mjs
@@ -42,7 +42,6 @@ export const env = createEnv({
DUB_REFER_URL: z.string().optional(),
NOVU_API_KEY: z.string().optional(),
SERVICE_TOKEN_TRIGGER: z.string().optional(),
- STRIPE_SECRET_KEY: z.string().optional(),
BACKEND_API_URL: z.string().optional(),
RETOOL_COMP_API_SECRET: z.string().optional(),
APP_AWS_ENDPOINT: z.string().optional(),
@@ -107,7 +106,6 @@ export const env = createEnv({
NOVU_API_KEY: process.env.NOVU_API_KEY,
NEXT_PUBLIC_NOVU_APPLICATION_IDENTIFIER: process.env.NEXT_PUBLIC_NOVU_APPLICATION_IDENTIFIER,
SERVICE_TOKEN_TRIGGER: process.env.SERVICE_TOKEN_TRIGGER,
- STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
BACKEND_API_URL: process.env.BACKEND_API_URL,
RETOOL_COMP_API_SECRET: process.env.RETOOL_COMP_API_SECRET,
APP_AWS_ENDPOINT: process.env.APP_AWS_ENDPOINT,
diff --git a/apps/app/src/lib/stripe.ts b/apps/app/src/lib/stripe.ts
deleted file mode 100644
index 7fa87b21a7..0000000000
--- a/apps/app/src/lib/stripe.ts
+++ /dev/null
@@ -1,186 +0,0 @@
-import { env } from '@/env.mjs';
-import Stripe from 'stripe';
-
-// Initialize Stripe client with secret key from environment
-const stripeSecretKey = env.STRIPE_SECRET_KEY;
-
-if (!stripeSecretKey) {
- console.warn('STRIPE_SECRET_KEY is not set - Stripe auto-approval will be disabled');
-}
-
-// Domains that should NEVER be used for domain-based auto-approval.
-// These are shared/public mailbox providers where domain ownership does not imply company affiliation.
-const PUBLIC_EMAIL_DOMAINS = new Set([
- // Google
- 'gmail.com',
- 'googlemail.com',
- // Microsoft
- 'outlook.com',
- 'hotmail.com',
- 'live.com',
- 'msn.com',
- // Yahoo
- 'yahoo.com',
- 'ymail.com',
- // Apple
- 'icloud.com',
- 'me.com',
- 'mac.com',
- // Proton
- 'proton.me',
- 'protonmail.com',
- 'pm.me',
- // AOL
- 'aol.com',
-]);
-
-export const isPublicEmailDomain = (domain: string): boolean => {
- const normalized = domain.toLowerCase().trim().replace(/\.$/, '');
- return PUBLIC_EMAIL_DOMAINS.has(normalized);
-};
-
-export const stripe = stripeSecretKey
- ? new Stripe(stripeSecretKey, {
- apiVersion: '2026-02-25.clover',
- })
- : null;
-
-/**
- * Extract domain from a website URL or email
- * @param input - URL (e.g., "https://example.com") or email (e.g., "user@example.com")
- * @returns Normalized domain (e.g., "example.com")
- */
-export const extractDomain = (input: string): string | null => {
- if (!input) return null;
-
- try {
- // If it looks like an email, extract domain from after @
- if (input.includes('@') && !input.includes('://')) {
- const domain = input.split('@')[1]?.toLowerCase().trim();
- return domain || null;
- }
-
- // Otherwise, treat as URL
- let url = input.trim().toLowerCase();
-
- // Add protocol if missing
- if (!url.startsWith('http://') && !url.startsWith('https://')) {
- url = `https://${url}`;
- }
-
- const parsed = new URL(url);
- return parsed.hostname.replace(/^www\./, '');
- } catch {
- return null;
- }
-};
-
-/**
- * Check if a domain belongs to an existing Stripe customer
- * Searches by customer email domain and metadata
- *
- * @param domain - The domain to check (e.g., "acme.com")
- * @returns Customer ID if found, null otherwise
- */
-export const findStripeCustomerByDomain = async (
- domain: string,
-): Promise<{ customerId: string; customerName: string | null } | null> => {
- if (!stripe) {
- console.warn('Stripe client not initialized - skipping customer lookup');
- return null;
- }
-
- if (!domain) {
- return null;
- }
-
- const normalizedDomain = domain.toLowerCase().trim().replace(/\.$/, '');
-
- // Defense-in-depth: never treat public mailbox domains as proof of company ownership.
- if (isPublicEmailDomain(normalizedDomain)) {
- return null;
- }
-
- try {
- // Prefer exact domain match via metadata when available.
- const customersWithMetadata = await stripe.customers.search({
- query: `metadata["domain"]:"${normalizedDomain}"`,
- limit: 1,
- });
-
- if (customersWithMetadata.data.length > 0) {
- const customer = customersWithMetadata.data[0];
- return {
- customerId: customer.id,
- customerName: customer.name ?? null,
- };
- }
-
- // Fallback: Stripe's email~ operator is substring matching; post-filter for exact email domain.
- const customers = await stripe.customers.search({
- query: `email~"@${normalizedDomain}"`,
- limit: 25,
- });
-
- const exactDomainCustomer = customers.data.find((customer) => {
- const email = customer.email ?? '';
- const emailDomain = email.split('@')[1]?.toLowerCase().trim() ?? '';
- return emailDomain === normalizedDomain;
- });
-
- if (exactDomainCustomer) {
- return {
- customerId: exactDomainCustomer.id,
- customerName: exactDomainCustomer.name ?? null,
- };
- }
-
- return null;
- } catch (error) {
- console.error('Error searching Stripe customers:', error);
- return null;
- }
-};
-
-/**
- * Check if a domain is an active Stripe customer with a valid subscription
- *
- * @param domain - The domain to check
- * @returns true if domain has an active subscription
- */
-export const isDomainActiveStripeCustomer = async (domain: string): Promise => {
- const normalizedDomain = domain.toLowerCase().trim().replace(/\.$/, '');
-
- if (!normalizedDomain) {
- return false;
- }
-
- // Never auto-approve based on public email domains.
- if (isPublicEmailDomain(normalizedDomain)) {
- return false;
- }
-
- const customer = await findStripeCustomerByDomain(normalizedDomain);
-
- if (!customer) {
- return false;
- }
-
- if (!stripe) {
- return false;
- }
-
- try {
- // Check if customer has an active subscription
- const subscriptions = await stripe.subscriptions.list({
- customer: customer.customerId,
- status: 'active',
- limit: 1,
- });
-
- return subscriptions.data.length > 0;
- } catch (error) {
- console.error('Error checking Stripe subscriptions:', error);
- return false;
- }
-};
From e42e6ef8661db6dc65a7bf57c12e52dd817f9c64 Mon Sep 17 00:00:00 2001
From: Tofik Hasanov
Date: Tue, 5 May 2026 15:47:26 -0400
Subject: [PATCH 04/15] fix(upgrade): keep self-hosted check on the page to
avoid OSS regression
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
NEXT_PUBLIC_SELF_HOSTED is a Next.js build-time env that the OSS Docker
deployment sets on the app container only — there is no propagation to
the API container (the root docker-compose.yml ships only app + portal
services). Moving the entire auto-approval flow into the API would have
broken self-hosted/OSS deployments, since neither SELF_HOSTED nor
NEXT_PUBLIC_SELF_HOSTED is available there.
Restore the inline self-hosted branch on the upgrade page (preserves
original behavior bit-for-bit) and route only the Stripe-customer +
@trycomp.ai paths through the API. The single remaining DB write on the
page is gated on a build-time deploy flag, not user input — so the
"all mutations through the API" rule is preserved in spirit for every
user-facing decision.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../src/app/(app)/upgrade/[orgId]/page.tsx | 36 +++++++++++++------
1 file changed, 26 insertions(+), 10 deletions(-)
diff --git a/apps/app/src/app/(app)/upgrade/[orgId]/page.tsx b/apps/app/src/app/(app)/upgrade/[orgId]/page.tsx
index 45913640f3..533604bd6f 100644
--- a/apps/app/src/app/(app)/upgrade/[orgId]/page.tsx
+++ b/apps/app/src/app/(app)/upgrade/[orgId]/page.tsx
@@ -1,3 +1,4 @@
+import { env } from '@/env.mjs';
import { serverApi } from '@/lib/api-server';
import { auth } from '@/utils/auth';
import { db } from '@db/server';
@@ -68,18 +69,33 @@ export default async function UpgradePage({ params }: PageProps) {
let hasAccess = member.organization.hasAccess;
- // Auto-approval (self-hosted, trycomp emails, domain-matched Stripe customers)
- // is decided server-side by the API, which also persists hasAccess. Soft-fail
- // so a transient API error never blocks the booking step from rendering.
if (!hasAccess) {
- const response = await serverApi.post(
- '/v1/organization-access/auto-approve',
- );
-
- if (response.data?.hasAccess) {
+ // Self-hosted instances auto-approve every org. The flag is a Next.js
+ // build-time env var (NEXT_PUBLIC_SELF_HOSTED) that the OSS Docker
+ // deployment sets on the app container only — the API container does NOT
+ // have this env, so the check stays on the page. The DB write here is the
+ // single exception to "all mutations through the API" — it's gated on a
+ // build-time deploy flag, not user input.
+ if (env.NEXT_PUBLIC_SELF_HOSTED === 'true') {
+ await db.organization.update({
+ where: { id: orgId },
+ data: { hasAccess: true },
+ });
hasAccess = true;
- } else if (response.error) {
- console.error('[UpgradePage] auto-approve API error:', response.error);
+ } else {
+ // Stripe-domain auto-approval (and the @trycomp.ai shortcut) live in the
+ // API so STRIPE_SECRET_KEY only has to exist on the API and the
+ // hasAccess flip is RBAC-checked + audit-logged. Soft-fail so a transient
+ // API error never blocks the booking step from rendering.
+ const response = await serverApi.post(
+ '/v1/organization-access/auto-approve',
+ );
+
+ if (response.data?.hasAccess) {
+ hasAccess = true;
+ } else if (response.error) {
+ console.error('[UpgradePage] auto-approve API error:', response.error);
+ }
}
}
From 1a97746fb239117fbb384f5c7f199141e09b4ee6 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Wed, 6 May 2026 05:17:17 -0400
Subject: [PATCH 05/15] feat(risks): treatment plan as first-class + vendor AI
widening + matrix polish
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(api): add independent-dimension schema for vendor risk assessment
* feat(api): widen vendor AI risk assessment to independent likelihood + impact
Previously the AI picked one of five canonical levels and we mapped it to
one of five diagonal cells on the 5x5 matrix, so vendor scores pooled at
1/10 or 2/10. Now the assessment outputs both dimensions independently,
threading through to vendor.inherentProbability / .inherentImpact unchanged.
- Replaces the single-level/diagonal-mapping block with extractInherentRisk
- Removes riskLevelSchema, normalizeRiskLevel, mapRiskLevelToLikelihood,
mapRiskLevelToImpact, and the normalization trip through gpt-5.2
- Updates the upstream Firecrawl agent schema + prompt to score both
dimensions independently (legacy risk_level retained as optional so
pre-ENG-221 stored payloads still parse)
Part of ENG-221.
Co-Authored-By: Claude Opus 4.7 (1M context)
* fix(api): apply ENG-221 Phase 1 code-review feedback
- Firecrawl agent schema now REQUIRES likelihood/impact/rationale so the
LLM cannot skip them and silently default to a diagonal cell.
- extractInherentRisk returning null now preserves existing vendor scores
instead of overwriting them with possible x moderate. Emits a warn log.
- Extract assessmentOutputSchema + extractInherentRisk to their own module
(vendor-risk-assessment/assessment-output.ts) so tests no longer need to
jest.mock the @db module.
- TODO comments point legacy risk_level fields at the backfill follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context)
* chore(ai): upgrade gpt-5.2 / gpt-5.1 references to gpt-5.5
GPT-5.5 shipped 2026-04-23 with a 1M-token context window and is now the
recommended frontier model. Swaps the two full-model call sites:
- apps/app/src/trigger/tasks/auditor/generate-auditor-content.ts
- apps/api/src/policies/policies.controller.ts
Left the gpt-5-mini / gpt-4o-mini / gpt-5 / gpt-4.1-mini sites alone —
no gpt-5.5-mini variant exists yet, and the other older-model sites are
used for deliberately cost-tuned flows.
Co-Authored-By: Claude Opus 4.7 (1M context)
* feat(db): add treatment strategy fields to Vendor model (ENG-221)
* feat(app): add suggested-residual util with strategy + completion model (ENG-221)
* feat(api): accept treatment strategy fields on Vendor update (ENG-221)
* feat(app): regenerate flow writes treatment description instead of comment (ENG-221)
* chore(db): regenerate api prisma client after vendor treatment fields (ENG-221)
* feat(app): matrix UX polish + ghost marker for suggested residual (ENG-221)
Adds axis tier tooltips, a color legend, a dashed ghost marker for the
suggested residual, and an "Accept suggested residual" button to
RiskMatrixChart. Wires `suggestedLikelihood` / `suggestedImpact` and
`titleInfo` through the four wrappers (risk + vendor × inherent + residual)
so customers can see, at a glance, what the matrix cells and axis tiers
mean and how their treatment plan affects the suggested residual.
Splits the matrix grid render into MatrixBody.tsx to keep files under the
300-line guideline; adds AxisTooltip.tsx, MatrixLegend.tsx, and a
component spec covering legend, suggestion-differs gating, accept-snap,
and ghost-marker suppression when suggestion matches active.
* feat(app): treatment-plan tab scaffold with strategy, editor, linked-work, delta (ENG-221)
* feat(app): add Treatment Plan tab to Risk detail page (ENG-221)
Adds a new "Treatment Plan" tab between Overview and Risk Matrix on the
risk detail page. Reuses the shared TreatmentPlanTab component (strategy
picker, description editor, linked work, delta chip). Removes the legacy
"Regenerate Risk Mitigation" block from the Settings tab since
regeneration now lives inline in the treatment plan's description editor.
Extends RisksService.findById to include linked tasks with status and
controls so the tab can render the linked-work suggestion and residual
completion preview.
Co-Authored-By: Claude Opus 4.7 (1M context)
* feat(app): add Treatment Plan tab to Vendor detail page (ENG-221)
Adds a "Treatment Plan" tab between Overview and Risk Matrix on the
vendor detail page, reusing the shared TreatmentPlanTab component. Wires
update handlers to PATCH treatmentStrategy/treatmentStrategyDescription
via the existing updateVendor action and reuses the existing
regenerateMitigation endpoint. Removes the legacy "Regenerate
Mitigation" block from the Settings tab (regeneration now lives inside
the treatment plan tab); "Regenerate Assessment" remains in Settings.
Extends VendorsService.findById to include linked tasks with status and
controls. Extends the frontend Vendor/UpdateVendorData types with
treatmentStrategy and treatmentStrategyDescription fields.
Co-Authored-By: Claude Opus 4.7 (1M context)
* feat(app): residual risk column on vendor list (ENG-221)
* feat(app): residual risk column on risk list (ENG-221)
* fix(app): address cubic review findings on ENG-221
Identified by cubic (cubic.dev) on PR #2671:
- generate-risk-mitigation / generate-vendor-mitigation: stop short-circuiting
the fanout when no owner/admin exists. Now descriptions still generate;
the reassignment step is just skipped. authorId is optional on the
individual-task payload.
- ResidualRiskChart / VendorResidualRiskChart: only compute and pass a
suggestion when tasks are actually loaded. Fallback to [] produced a
misleading "0% complete" ghost cell on unhydrated views.
- TreatmentPlanTab: sync local strategy state when the entity prop changes
(e.g. after SWR revalidation).
- DescriptionEditor: disable the textarea during save and avoid re-syncing
draft from props while saving, so mid-save typing isn't overwritten.
- LinkedWork: task link path /task/ -> /tasks/[taskId] to match the real
Next.js route.
- suggested-residual.ts: drop the stale reference to the gitignored spec
path in the JSDoc; point readers at the in-file STRATEGY_REDUCTION table.
Skipped (out of scope): cubic flagged rejectUnauthorized: false + the
localhost regex in apps/api/prisma/client.js. That logic lives in
packages/db/src/client.ts (and three other sibling prisma clients) and
pre-dates ENG-221 — our commit only regenerated the compiled artifact.
Fixing it is a broader security review, separate ticket.
Co-Authored-By: Claude Opus 4.7 (1M context)
* feat(app): redesign Treatment Plan tab per Direction B1 (ENG-221)
* feat(app): add link-suggestions util with dept boost + threshold + top-K
* chore(app): add @upstash/vector dep for risk/vendor linkage
* feat(app): add upstash + openai embedding helpers for entity linkage
* feat(app): add link-risks-and-vendors-to-work trigger task
* feat(app): run risk/vendor linkage between createRisks and mitigations
* feat(app): extend RISK_MITIGATION_PROMPT with linked tasks/controls grounding
* feat(app): ground risk + vendor mitigation prompts in linked tasks/controls
* fix(app): make hallucination-guard regex case-insensitive + match real identifiers
* feat(app): add auto-link endpoints for risk + vendor
* feat(app): on-demand Auto-link tasks button on Treatment Plan tab
* refactor(app): extract runLinkage so on-demand routes return real link counts
* feat(app): add unlink endpoints for risk + vendor tasks
* feat(app): unlink × per task in Linked Work
* feat(app): vendor 3-branch render based on Vendor.status
Adds three-branch rendering to VendorResidualRiskChart based on Vendor.status:
- not_assessed: render a NotAssessedState empty-state card with a "Run risk
assessment" button that triggers the existing AI assessment endpoint
- in_progress: render the matrix with a "Preliminary - assessment still
running" subtitle so the suggested residual ghost cell isn't trusted as
final
- assessed: unchanged from today
Adds a new optional `preliminary?: boolean` prop to RiskMatrixChart and
RiskMatrix5x5 that controls only the subtitle render (no math change).
Reuses the existing `triggerAssessment` mutation in use-vendors.ts.
Co-Authored-By: Claude Opus 4.7 (1M context)
* fix(app): add credentials:include to vendor regenerate-mitigation fetch
* fix(app): explicit ownership check on auto-link routes (404 on cross-org)
* feat(app): auto-link via trigger.dev with realtime metadata phases
* feat(app): subscribe AutoLinkButton to live trigger.dev run progress
* fix(app): color hero numerals + scale markers by actual risk level
* refactor(app): pin treatment-plan citations to real entities (eliminate hallucinations)
The LLM no longer chooses which controls/tasks/policies to cite. The backend
now selects 5 citations deterministically (controls -> tasks -> policies ->
gap fillers) and the LLM only writes a 5-sentence JSON object via
generateObject. Suffixes like "(Control: cc1-1 Risk Assessments)" are
appended programmatically, so they cannot drift from reality.
This removes the regex-based guardAgainstHallucinatedCodes retry loop, which
only caught a narrow set of fabrication patterns and was fragile against
title-cased / re-cased codes.
* feat(app): refresh treatment plan when a task is unlinked
After a successful unlink we fire-and-forget the corresponding mitigation
trigger task so the persisted treatmentStrategyDescription reflects the new
linkage. Failures here are swallowed because the unlink itself already
succeeded and is the user-facing operation.
* feat(app): re-link tasks from scratch with confirmation + realtime progress
* feat(app): rename relink to Re-assess, move into Linked work header
Co-Authored-By: Claude Opus 4.7 (1M context)
* feat(app): restructure Treatment Plan tab to B1-flat layout
Collapse the three nested cards under the hero into a single bordered
workspace divided by 1px vertical rules. Strategy options become
borderless rows with a left-edge accent on the active item. Description
editor textarea is flush with the column flow; footer separates via a
hairline rule. Linked work groups drop their card wrappers, use a 3px
progress bar, and hairline-divide rows. The Re-assess trigger becomes
a subtle 11px muted-foreground inline-flex affordance that tints to
primary on hover. No behavior change.
Co-Authored-By: Claude Opus 4.7 (1M context)
* feat(app): suggestionsOnly mode in runLinkage + apply endpoint
The previous auto-link flow ran the AI scan and persisted task links in one
step. The new flow splits these: the auto-link route triggers the linkage
task in `suggestionsOnly` mode (returns SuggestedTask + SuggestedControl
arrays in run.output, no DB writes), and a new `/auto-link/apply` endpoint
persists only the user-confirmed selection — supporting both additive
(replace=false) and re-assess sync (replace=true) semantics.
Controls in the suggestions block are derived through tasks per the
existing Risk↔Task↔Control ADR (no direct Risk↔Control linkage). The UI
will render them as read-only and dim them when their parent task is
unchecked.
Co-Authored-By: Claude Opus 4.7 (1M context)
* feat(app): replace immediate-apply auto-link with review-before-apply UX
Linked Work column now drives a 4-state machine (linked / empty / loading /
suggestions) via the new component:
- empty: dashed CTA with "Suggest with AI" — kicks off the AI scan
- loading: spinner + 4 staggered shimmer rows
- suggestions: tinted banner + tasks (with checkboxes + confidence pills) +
read-only Controls section (derived through selected tasks per ADR) +
Re-run / Discard / Link N footer
- linked: existing flat Tasks/Controls list with subtle "Re-assess" trigger
Re-assess mode pre-checks current+AI tasks and applies with replace=true
(sync semantics). Fresh suggest applies with replace=false (additive).
The previous AutoLinkButton + RelinkButton (with AlertDialog confirm) are
no longer wired in but kept intact for backward compat. Hooks expose new
suggestRiskLinks/applyRiskLinks (and vendor variants) alongside the
deprecated autoLinkRisk/relinkRisk.
The Re-assess affordance lives at the top-right of the AutoLinkSuggestions
linked-state body rather than the column header. Visually equivalent and
keeps the column header free of state-dependent actions.
Co-Authored-By: Claude Opus 4.7 (1M context)
* feat(app): strategy-aware treatment plan UX (drop avoid, mitigate-only linked work, merged empty state)
- StrategyPicker: Mitigate first (default + workhorse). Drop Avoid from new
picks; keep it visible only when an existing risk is already set to avoid
(legacy state). Order: Mitigate, Accept, Transfer.
- TreatmentPlanTab: hide column 03 (Linked work) for non-Mitigate strategies
(only Mitigate's residual is task-driven). Adapt column 02 title/subtitle
to "Rationale" for Accept/Transfer.
- Mitigate fully-empty (no plan AND no linked tasks): merge columns 02+03
into a single 2-column layout with a "Mitigation plan" CTA spanning the
wider space.
- TreatmentHero: derive residual from the chosen strategy via previewResidual
(matrix marker = full-completion target). Hero numeral uses
interpolatedResidualScore so partial Mitigate completion shows live
progress on the 1-10 scale even when the matrix cell would otherwise stay
put due to integer step rounding. Third stat adapts per strategy
("Task Completion %", "Accepted as-is", etc.). Narrative reflects empty
state ("since no mitigation plan is in place yet").
- LinkedWork: drop the unlink (×) button; tasks and controls now click
through to the entity in a new tab. Incomplete tasks/controls show a red
× instead of a muted dash; controls completeness derives from parent
task status.
- DescriptionEditor: remove the redundant "AI draft" header button — the
footer "Regenerate with AI" already covers it.
Co-Authored-By: Claude Opus 4.7 (1M context)
* feat(app): kick-off CTA when treatment plan and links are both empty
When Mitigate has no plan and no linked tasks, render a single centered
'Let AI kick this off' panel in cols 02+03 instead of the editor. The
panel offers two paths: 'Draft plan & suggest links' (runs the AI flow
which produces both plan and links) or 'Start from scratch' (dismisses
the kickoff so the editor renders for manual entry). Below the buttons,
two preview rows ('02 · Plan' / '03 · Links') describe what the AI will
fill in. Once either plan or links exist, the regular layout takes over.
* feat(risks): show kickoff empty state across cols 02+03 when mitigate has no linked work
When Mitigate has no linked tasks, render only the kickoff CTA spanning
the merged 02+03 column — the editor stays hidden until linked work
exists or the user dismisses. Adds a 'kickoff-with-plan' variant that
adapts copy to "your plan stays as-is unless you regenerate" so users
who already have a plan still see a single, full-width entry point.
The kickoff panel now uses a primary-tinted background that matches
the selected-strategy chrome.
Co-Authored-By: Claude Opus 4.7 (1M context)
* fix(risks): broaden auto-link suggestions and scope to purchased frameworks
Two complementary fixes for the on-demand suggestion path:
1. Scope tasks to purchased-framework coverage. A task is in scope when
it has at least one non-archived control (controls archive when their
framework subscription drops) or has no controls at all (custom user
tasks). Stale tasks tied only to archived controls are excluded.
2. Loosen the suggestionsOnly threshold. The autonomous onboarding path
keeps 0.65/topK=5 (high precision, no human review). The review-
before-apply path now uses 0.40/topK=15 with a 50-candidate vector
query so genuinely related work in the 0.4-0.6 cosine band — the
range that dominates `text-embedding-3-small` for short compliance
prose — actually surfaces. The user picks from the list, so favoring
recall is the right tradeoff.
Co-Authored-By: Claude Opus 4.7 (1M context)
* feat(risks): add LLM reranker for auto-link suggestions
Cosine similarity on text-embedding-3-small does well on recall but
collapses scores into a tight 0.6 band on short compliance prose, which
is bad for ordering. Empirical sample (Data Leakage via Personal Laptops
risk on a real org): "Office Door Monitoring" and "Employee Performance
Evaluations" scored ~0.62 alongside genuinely-relevant tasks like 2FA
and Encryption at Rest, while the primary control "Secure Devices"
(BitLocker, FileVault, MDM) didn't make the top 15.
Bridge that gap with a precision-step reranker:
- New util `rerank-suggestions.ts` calls gpt-5-mini with strict 0-10
scoring rubric: 10 = primary control, 7-9 = supporting, 4-6 = weakly
related, 1-3 = tangential, 0 = irrelevant. Rubric explicitly warns
against surface-keyword false positives.
- `runLinkage` (suggestionsOnly path) now feeds top-30 cosine candidates
through the reranker and slices to top-15 by rerank score. The
autonomous onboarding path keeps its strict 0.65 cosine cutoff
unchanged — no LLM cost on bulk runs.
- Reranker failure (network / OpenAI outage) falls back to cosine
ordering so the suggestions UI never breaks.
- Score map values are scaled 0-1 from the reranker's 0-10 so the
existing SuggestedTask UI continues to display them as a percentage.
Cost: ~$0.001 per risk (one call, ~30 short rows). Latency: 2-5s on
top of the existing scanning flow.
Co-Authored-By: Claude Opus 4.7 (1M context)
* feat(risks): persist auto-link runs so users don't lose progress on reload
The auto-link AI scan was already running on trigger.dev, but the runId
lived only in component state. Closing the tab orphaned the in-flight
run from the UI — when the user came back, they'd see the empty state
even though the run had completed (or was still running) server-side.
Persist runId on Risk + Vendor:
- Schema migration adds `autoLinkRunId` and `autoLinkRunStartedAt` to
Risk and Vendor. Set when /auto-link triggers a new run, cleared by
/auto-link/apply (user committed) or /auto-link/active DELETE (user
discarded).
- New `GET /auto-link/active` mints a fresh public-access token (the
previous one expires after 15 minutes) so the UI can re-subscribe via
`useRealtimeRun` after a reload. Returns `{ runId: null }` when the
trigger.dev run has been purged (TTL elapsed) so we don't subscribe
to a dead id.
- New `DELETE /auto-link/active` drops the persisted runId for Discard.
Front-end state machine handles every trigger.dev run status:
- AutoLinkSuggestions calls `onResume` on mount; if a run exists, jumps
straight into the loading state and re-subscribes.
- LoadingState now distinguishes WAITING_FOR_DEPLOY / QUEUED / DELAYED /
INTERRUPTED / WAITING_TO_RESUME / EXECUTING with status-specific copy
derived from `run.status` + `run.metadata.phase`. Adds a progress bar
for the embedding-tasks phase (current / total).
- New FailedState shows a retry-able error for terminal failures
(FAILED, CANCELED, CRASHED, SYSTEM_FAILURE, EXPIRED, TIMED_OUT).
Retry kicks off a fresh /auto-link call; Discard clears the runId.
Also widens the 5x5 matrix (CELL_SIZE 36 -> 44) and centers it in its
card so the empty space on the right of the hero shrinks.
Co-Authored-By: Claude Opus 4.7 (1M context)
* perf(linkage): parallelize matching, scope-skip irrelevant entities, quieter logs
Several efficiency wins on the linkage trigger task plus a logging
overhaul:
- Drop `PrismaInstrumentation` from trigger.config — every Prisma query
was emitting a `prisma:client:operation` span that drowned out our
own task logs. Per-task `logger.info` calls give us the visibility
we actually need.
- Run the 3 initial scope queries (risks, vendors, tasks) in parallel
via Promise.all instead of awaiting each sequentially.
- Skip the irrelevant scope entirely: when the caller pins riskId,
don't load + embed all vendors (and vice versa). Saves an OpenAI
embedding round-trip per Suggest click on orgs with many vendors.
- Replace-step disconnects (`tasks: { set: [] }`) now fan out via
Promise.all instead of running serially.
- Per-entity matching loops (vector query → rerank → DB update) use a
bounded-concurrency runner (4 at a time). For onboarding with ~10-20
entities this is the biggest wall-clock win — each iteration was
previously serial on Upstash + OpenAI round-trips.
- Linkage task now logs phase boundaries with timings, scope sizes,
rerank input/output (top-10 ids + scores), and a clear ✓/✗ summary
with elapsed time. console.info inside `runLinkage` flows into the
trigger.dev run log without coupling the lib to the trigger SDK.
Co-Authored-By: Claude Opus 4.7 (1M context)
* perf(linkage): upgrade to text-embedding-3-large (1536-dim Matryoshka)
Switch from `text-embedding-3-small` to `text-embedding-3-large` truncated
to 1536 dims via the OpenAI Matryoshka `dimensions` parameter. Even
truncated, -3-large outperforms -3-small on MTEB (~64.6 vs 62.2 avg). The
1536-dim cap keeps the existing Upstash Vector index (provisioned at 1536)
usable without a recreate or one-time re-embed of every org.
Cost goes from ~$0.0002 to ~$0.0008 per scan — negligible.
The first run after deploy will overwrite each org's task vectors with
the new -3-large embeddings; matching is internally consistent since both
the task vectors and the query vector come from the same model on every
run.
Co-Authored-By: Claude Opus 4.7 (1M context)
* fix(app): silence prisma `export *` warning + memoize resume callback
Two related Turbopack-noise fixes:
- `apps/app/prisma/{index,server}.ts` were doing `export * from
'@prisma/client'`. The Prisma client is CommonJS, so Turbopack emits
"unexpected export *" on every compile and re-emits it on each HMR
cycle. Replace the wildcard with `export type *` (works fine for the
type surface) plus an explicit list of runtime values (Prisma,
PrismaClient, every enum). Same public surface, clean compile.
- Memoize `handleResumeAutoLink` and `handleDiscardAutoLinkRun` in
RiskPageClient and VendorDetailTabs with `useCallback`. Without the
memoization, the callbacks got new identities on every parent render,
which re-fired the resume `useEffect` in AutoLinkSuggestions and
triggered a fresh GET `/auto-link/active` per render.
Co-Authored-By: Claude Opus 4.7 (1M context)
* fix(risks): hero shows inherent → target, with partial-progress subline
Previously the hero numeral was the *interpolated* score by task completion,
so linking tasks (without completing them) didn't move the headline at
all — yet the narrative still said "...assuming linked tasks complete on
schedule". That read self-contradictory: at 0% completion the score said
"no change" while the strategy clearly forecasts a reduction.
Two changes:
- Headline always shows inherent → full-completion target. Linking tasks
to a Mitigate plan now visibly moves the right-hand numeral.
- When Mitigate is partway through (0 < completion < 1), a smaller
"Currently X/10 — Y% of plan complete" line renders under the headline,
colored by the interpolated current level. Real-time progress still
shows up — just not as the dominant figure.
Test fixture updated: with Likely × Major inherent and Mitigate's
-1L/-1I target, the headline now renders 7 → 4 (was 7 → 7 with 0% complete).
Co-Authored-By: Claude Opus 4.7 (1M context)
* feat(risks): add "How is this calculated?" popover to the treatment hero
CISOs reviewing the score want to understand how it was derived, not
just see the numbers. Added a small affordance in the hero header that
opens a popover with a concise explanation:
- Inherent risk: standard 5x5 likelihood x impact, normalized 1-10
- Treatment target: per-strategy projection (mitigate / transfer /
accept / avoid), described in plain language
- Current vs. target: how completion drives the in-progress score
- Footer references NIST SP 800-30 / ISO 27005 alignment
Copy is deliberately CISO-credible (industry vocabulary, framework
references) but does NOT publish the exact step-down coefficients —
those live in `lib/suggested-residual.ts` and can evolve as we
calibrate without renegotiating the user-facing explanation.
Co-Authored-By: Claude Opus 4.7 (1M context)
* fix(risks): make score explainer specific (formulas) and drop Avoid
Previous version was too vague — read like marketing copy, not an
explanation. CISOs want the actual math.
The popover now shows:
- Step 1 inherent: explicit raw = L x I and score = ceil(raw / 2.5)
formulas in monospace blocks
- Step 2 target: per-strategy axis effects (Mitigate moves both axes,
Transfer moves impact only, Accept = inherent), without publishing
the exact step-down counts
- Step 3 current vs. target: explicit completion formula and the linear
interpolation between inherent and target by task completion
- NIST SP 800-30 (semi-quantitative) + ISO 27005 (treatment) reference
Avoid is gone — we dropped it from the strategy picker earlier, so it
no longer needs an entry in the explainer.
Co-Authored-By: Claude Opus 4.7 (1M context)
* fix(risks): blur backdrop on score explainer + honest standard references
Two refinements to the "How is this calculated?" popover:
- Switch from the DS Popover wrapper to base-ui's Popover directly so we
can mount a `Popover.Backdrop`. Adds a subtle 2px backdrop blur over a
30% bg overlay when the popover opens — focuses attention on the
explainer without making it feel modal.
- Sharpen the methodology claim. The previous "aligns with NIST/ISO" was
too marketing-y. New References section links to:
- NIST SP 800-30 Rev. 1 (canonical csrc.nist.gov URL, verified) —
noting Appendix I recognizes 5x5 matrices as semi-quantitative
- ISO/IEC 27005:2022 (iso.org standard page) — naming the actual
treatment categories (risk modification / sharing / retention) and
mapping them to our Mitigate / Transfer / Accept
Plus a closing italic disclaimer that the 1-10 normalization and step-
down magnitudes are our own calibration, so we're not overclaiming
alignment.
Co-Authored-By: Claude Opus 4.7 (1M context)
* feat(risks): markdown preview + auto-grow editor for treatment plans
DescriptionEditor now defaults to a rendered markdown preview when the
plan has content. Click "Edit" to flip to a textarea that auto-grows to
fit the content (no fixed height + manual scrollbar) — useful for AI-
generated plans that often run several paragraphs with bullets.
- ReactMarkdown + remark-gfm with treatment-plan-tuned components
(paragraphs, bullet/ordered lists, headings demoted by one level,
bold/italic, links, code spans, blockquotes, hr).
- useLayoutEffect sizes the textarea on draft change and on mode flip.
- "Cancel" while editing reverts the draft and returns to preview;
"Save" persists and flips back to preview.
- Empty state still shows the textarea straight away.
- Mode flips back to preview automatically when AI regen completes.
Co-Authored-By: Claude Opus 4.7 (1M context)
* fix(risks): treatment plan intro now reflects actual citation counts
Was hardcoded as "This plan reduces the risk through 5 controls:" no
matter what — but the 5 citations are a mix of controls (max 3), tasks
(max 2), policies, and gaps. The user-visible Linked Work column shows
the underlying transitive control count (often 6+), so the prose said
"5 controls" while the column showed "6 controls" → confusion.
New `buildCitationsHeading` counts each citation kind and builds a
grammatically correct intro via Intl.ListFormat:
"This plan addresses the risk through 3 controls, 1 task, and 1 policy:"
"This plan addresses the risk through 1 control and 1 task:"
"This plan addresses the risk through 2 recommended gaps:"
Extracted to its own pure module so the unit tests don't need to load
the DB client (which `onboard-organization-helpers.ts` imports at the
top level). 7 tests cover singular/plural, multi-kind ordering, gap-
only fallback, and empty input.
Existing risks/vendors keep their old prose until next regeneration —
the fix applies whenever the user clicks "Regenerate with AI".
Co-Authored-By: Claude Opus 4.7 (1M context)
* fix(risks): heading reports full linked totals, not citation counts
The previous heading counted citations (capped at 3 controls + 2 tasks),
so a risk with 6 linked controls + 8 linked tasks showed "3 controls and
2 tasks" — contradicting the Linked Work column that displays the full
6+8.
Plumb the full linked totals from the grounding context (which already
loads them for the LLM prompt) through to `buildCitationsHeading`. The
heading now reports those totals and labels the bullets as
"Highlights below:" when they're a strict subset of what's linked:
6 controls + 8 tasks linked, 5 citations:
"This plan addresses the risk through 6 controls and 8 tasks.
Highlights below:"
2 controls + 1 task linked, 3 citations covering all:
"This plan addresses the risk through 2 controls and 1 task:"
Falls back to citation-kind counts when nothing is linked (e.g. only
gaps and policy citations exist).
Tests updated for the new signature.
Co-Authored-By: Claude Opus 4.7 (1M context)
* feat(risks): live progress while AI regenerates the treatment plan
Previously the regenerate-mitigation flow was fire-and-forget from the
UI's perspective: POST returned immediately after triggering the
trigger.dev task, the editor's "AI is drafting" message disappeared,
and users had to refresh manually 30-60s later to see the new prose.
Now the same realtime pattern as auto-link:
- POST `/regenerate-mitigation` (risk + vendor) returns the trigger.dev
runId + a 15-min public access token alongside the trigger.
- `DescriptionEditor` accepts a `regenRun` handle, subscribes via
`useRealtimeRun`, and renders status-specific copy:
- WAITING_FOR_DEPLOY → "Starting AI scan…"
- QUEUED / DELAYED → "Queued — waiting to start…"
- INTERRUPTED → "Resuming…"
- EXECUTING → "AI is drafting your treatment plan…"
- New `RegenProgress` component renders a small status card with a
spinner, headline, sub-line, and the "you can keep editing; your
edits will win" reassurance.
- On COMPLETED → parent clears the run handle, refetches the risk/
vendor, shows a success toast, and the new markdown rendering kicks
in automatically.
- On terminal failure (FAILED/CRASHED/CANCELED/TIMED_OUT/EXPIRED/
SYSTEM_FAILURE) → parent clears the handle and surfaces a
status-specific error toast.
Also memoizes `handleRegenSettled` in both parents so the editor's
`useEffect([status])` doesn't re-fire on parent re-renders.
Co-Authored-By: Claude Opus 4.7 (1M context)
* style(risks): drop hero narrative one font size (text-base → text-sm)
Co-Authored-By: Claude Opus 4.7 (1M context)
* fix(risks): table residual column derives from strategy, matching the hero
The risks table was rendering `RiskScoreBadge` from the stored
`residualLikelihood` / `residualImpact` fields, while the Treatment
Plan hero shows the strategy-derived target via `previewResidual`.
For risks where the stored residual disagreed with the active
strategy (the common case after a strategy change), the table and
the hero showed different residuals.
Compute the table's residual the same way the hero does: pass the
risk's likelihood + impact + strategy through `previewResidual` and
render the resulting target.
Also expands the `@db` mock in two existing test files (RisksTable
and RiskPageClient) so suggested-residual / risk-score helpers can
load — the test suites that were exercising tied-to-DB enums now
get the values they need. RisksTable suite is fully green;
RiskPageClient was already failing on unrelated mock gaps before
this change and still is — the mock expansion is strictly additive.
Co-Authored-By: Claude Opus 4.7 (1M context)
* feat(risks): independent description per treatment strategy
Switching from Mitigate to Accept (or Transfer) used to leave the
previous strategy's bullet-style plan visible under "Rationale" — wrong
content for the new strategy. Now each strategy has its own saved text
that's swapped in/out of the active `treatmentStrategyDescription`
field as the user changes strategy.
Schema:
- Adds `strategyDescriptions Json?` to Risk and Vendor. Stores
{ mitigate?, accept?, transfer?, avoid? } so a Mitigate plan, an
Accept rationale, and a Transfer rationale can coexist on one row.
- Migration backfills the column from the existing
`treatmentStrategyDescription` keyed by the row's current strategy.
API (NestJS):
- `risks.service.updateById` and `vendors.service.updateById` now
resolve strategy/description changes through a shared helper:
- Strategy change: save current text into the OLD slot, load the
NEW slot's saved text into the active field (or null if empty).
- Description change: mirror into the active strategy's slot.
- Both: strategy swap runs first; explicit description wins.
- New `apps/api/src/risks/strategy-descriptions.ts` is the single
source of truth for the swap logic; vendors imports it.
- 8 unit tests cover swap, mirror, both-changing, empty-clears,
malformed-map, and the no-op cases.
Trigger task (regenerate-mitigation):
- After writing the LLM-generated text into
`treatmentStrategyDescription`, also mirror it into
`strategyDescriptions[]` so the saved text persists
across strategy switches without an extra DB roundtrip from the API.
UI:
- No changes. The frontend continues to read
`treatmentStrategyDescription`; the API swap ensures it's always the
active strategy's text.
Co-Authored-By: Claude Opus 4.7 (1M context)
* fix(risks): swap displayed description instantly when strategy changes
The API now keeps per-strategy text in `strategyDescriptions` and swaps
into `treatmentStrategyDescription` on strategy change, but the UI was
still sourcing the editor's value from the entity's active field.
Result: optimistically flipping local strategy left the previous
strategy's content visible in the editor for the SWR revalidation
window (and indefinitely if the user kept editing in that window).
Plumb `strategyDescriptions` through to the treatment plan tab and
derive the displayed description as:
strategy === entity.treatmentStrategy
? entity.treatmentStrategyDescription // active row
: entity.strategyDescriptions?.[strategy] // saved per-strategy
?? '' // empty if none yet
So Mitigate → Accept now instantly shows the Accept rationale (or
empty), not the Mitigate plan. Saved Accept text is preserved if the
user flips back. Wired identically on Risk and Vendor pages.
Co-Authored-By: Claude Opus 4.7 (1M context)
* fix(risks): hero level label matches score band, not raw L×I band
The hero showed "from HIGH to HIGH" for a 7→5 risk reduction even
though the 5 lands visually in the Medium segment of the RiskScale
bar below it. Cause: level was derived from raw (likelihood × impact,
1-25 scale) where 12 is in the high band (>9), but the user-facing
score is the normalized 1-10, where 5 is in the medium band (5-6).
The two scales use different bucket sizes:
raw thresholds → very-low ≤1, low 2-4, medium 5-9, high 10-16, very-high 17+
score buckets → very-low 1-2, low 3-4, medium 5-6, high 7-8, very-high 9-10
Add `getRiskLevelFromScore` in `lib/risk-score.ts` (mirrors RiskScale's
5 visual segments) and use it in `TreatmentHero` for the inherent,
target, and current-interpolated level labels. Drops the
`approximateRawFromScore` workaround that was bridging the gap with
half-precision raw guesses.
Co-Authored-By: Claude Opus 4.7 (1M context)
* fix(risks): coverage gate — no claimed reduction without linked work
Without at least one linked task, a Mitigate or Transfer risk used to
still show its full strategy ceiling as the target ("7 → 5") because
the math was strategy-derived only. The strategy alone isn't audit
evidence — projecting a -2 swing with nothing linked failed the
"explain this to an auditor" sniff test.
Add an `hasLinkedWork` flag to `previewResidual`. When the entity has
no linked tasks AND the strategy isn't Accept (which is inherent by
definition), the function returns `inherent` as the target. Mitigate
and Transfer now collapse to no-change until at least one task is
linked.
TreatmentHero passes `hasLinkedWork: tasks.length > 0`. The narrative
copy switches to a coverage-gate explanation when active:
- Mitigate: "...until tasks supporting the strategy are linked."
- Transfer: "...until a task documenting the transfer arrangement is
linked."
- Accept: unaffected (target is inherent regardless).
Score explainer popover gets a new "Coverage gate" section so the
methodology stays published.
Tests: existing strategy-derived test now passes a linked task so the
gate is satisfied; new test asserts that 0-tasks Mitigate shows
7 → 7 plus the gate copy.
Co-Authored-By: Claude Opus 4.7 (1M context)
* fix(risks): risk matrix cells colored by score band, not raw heat
Cells were colored by `(L_idx + I_idx) / 8` heat thresholds — a
geometric/raw heuristic that didn't agree with the 1-10 score banding
the headline numeral and the bottom RiskScale use. A target landing on
(Possible × Moderate) showed a yellow cell while the headline "4/10"
read green (Low) and the bottom scale tick was in the green Low
segment.
Compute each cell's normalized score (ceil(L*I / 2.5)) and bucket it
through the same `getRiskLevelFromScore` thresholds. Cell backgrounds
mirror RiskScale's 5 segments so the matrix, the headline color, and
the bottom scale all agree on what counts as Low / Medium / High.
Co-Authored-By: Claude Opus 4.7 (1M context)
* fix(risks): "Now" marker on matrix tracks task completion
The Now (red) marker was pinned to the inherent cell regardless of how
much of the linked treatment plan was complete — so the matrix said
"in High territory" while the headline showed Currently 6/10 (Medium)
and 50% complete. Visually inconsistent with the headline.
Add a `completion` prop (0..1, default 0) to RiskMatrix5x5 and render
the Now dot at a position interpolated between the inherent cell and
the residual target cell:
nowL = inherentL + (targetL - inherentL) × completion
nowI = inherentI + (targetI - inherentI) × completion
Lifted the dot out of the cell loop into an absolutely-positioned
overlay so it can sit between cells when partial completion lands it
off-grid. 200ms ease-out transition on left/top for a smooth slide as
tasks tick toward done.
TreatmentHero passes `completion` (already computed for the headline
interpolation) so the matrix and the headline numeral now move
together.
Co-Authored-By: Claude Opus 4.7 (1M context)
* fix(risks): explicit overlap state when Now reaches Target
At 100% completion the red Now dot was rendering on top of the green
Target dot, hiding the win. Make the merged state visually distinct:
swap to a single primary-colored disc with a centered Checkmark icon
and a soft primary halo, signaling "reached target — plan complete."
Trigger condition is `completion >= 1 AND target ≠ inherent`, so
Accept-from-day-one risks (where dots overlap because the strategy
projects no reduction) keep the standard two-dot rendering rather
than claiming a "completed" reduction that didn't exist.
Co-Authored-By: Claude Opus 4.7 (1M context)
* fix(risks): hero numeral color uses same tokens as the RiskScale bar
The numeral colors were bespoke `oklch(...)` values while the bar
segments below used `--success` / `--warning` / `--destructive`
blends. They drifted out of agreement — the big "4/10" rendered in
one shade of green, the Low segment of the bar in a different one.
Switch the level-color map to opaque mixes of the same tokens the
bar uses, in matching hue ratios:
very-low → success
low → mix(success 50%, warning)
medium → warning
high → mix(warning 50%, destructive)
very-high → destructive
Same colors propagate to the strong-tag spans in the narrative and
the score tick on the RiskScale (the tick reads from LEVEL_COLOR via
the existing `inherentColor` / `residualColor` props), so the
numeral, narrative, tick, and segment now all live in the same
color family.
Co-Authored-By: Claude Opus 4.7 (1M context)
* fix(risks): single risk-score column reflecting current treatment state
The list view had two near-duplicate columns (SEVERITY + RESIDUAL RISK)
that resolved to the same value for any risk without active treatment
progress — which is most rows in onboarding. Drop SEVERITY entirely and
rename the surviving column to RISK SCORE, showing the *current*
treatment-aware score (interpolated between inherent and the strategy
target by linked-task completion). The detail page hero retains the
full inherent → target breakdown for users who want the breakdown.
Backend: include `tasks: { id, status }` on the risks list response so
the table can compute interpolated current scores without an extra
roundtrip per row.
Color/level cleanup:
- Extract LEVEL_COLOR (var(--success) / var(--warning) / var(--destructive)
blends) to lib/risk-score.ts so the hero, badge, scale, and matrix
share one color source.
- RiskScoreBadge now accepts `score: number` directly, derives level
via getRiskLevelFromScore (matching the visual band thresholds), and
uses the shared LEVEL_COLOR via inline `--band` CSS variable. Drops
the bespoke Tailwind palette classes that drifted from the bar.
- Replace the inline `getSeverityBadge` (its own custom thresholds,
raw-based) with RiskScoreBadge so all callers go through one styled
source.
Co-Authored-By: Claude Opus 4.7 (1M context)
* fix(risks): add SEVERITY label column back, both columns reflect current state
Restore the SEVERITY column for at-a-glance triage but reframe both
visible columns to describe the *current* (treatment-aware) state of
each risk:
SEVERITY — qualitative level chip ("Low" / "Medium" / "High" /
"Critical" / "Very low") for fast scanning
RISK SCORE — precise 1-10 numeric for the same current state
Both badges read from `currentSeverityScore(risk)` (interpolated
between inherent and the strategy target by linked-task completion),
share the same `--band` color, and stay perfectly in sync as
treatment progresses. The detail page hero retains the full
inherent → target breakdown for users who want the journey, not just
the current snapshot — a 7→4 reduction is visible there once they
click in.
`RiskScoreBadge` gains a `labelOnly` prop that swaps the rendered
text from "X/10" to the level label while keeping the same color
treatment, enabling the two-format presentation from a single
component.
Co-Authored-By: Claude Opus 4.7 (1M context)
* fix(risks): drop colored chip on SEVERITY column — plain text label
Two colored chips of the same band on every row read as visual noise
because they always carry the same color signal. Keep the colored chip
on RISK SCORE (the precise number is what changes at a glance) and
render SEVERITY as plain text (Low / Medium / High / etc.) — gives the
qualitative read without doubling the color load.
Move LEVEL_LABEL alongside LEVEL_COLOR in lib/risk-score.ts so any
caller that needs the human-readable name can grab it without going
through the badge.
Co-Authored-By: Claude Opus 4.7 (1M context)
* feat(risks): add Severity / Status / Owner filters to the risks list
Three filter dropdowns next to the search bar, each URL-backed via
nuqs so filter state survives reload and is shareable:
- Severity (Very low / Low / Medium / High / Very high)
- Status (Open / Pending / Closed / Archived)
- Owner (each assigned member, populated from the existing
`assignees` prop)
Status and Owner pass through to the existing `useRisks` query
params (server-side filter on the risks API). Severity is computed
from the current treatment-aware score, so it can't be queried by
the API — applied client-side after the fetch.
Adds a "Clear filters" ghost button visible whenever any filter or
search term is active, so users can reset back to the unfiltered
list with one click.
Test mocks extended with the DS Select primitives.
Co-Authored-By: Claude Opus 4.7 (1M context)
* fix(risks): filter dropdowns render the selected label, not the raw value
base-ui's Select.Value doesn't auto-resolve to a SelectItem's label —
without a render function it shows the raw `value` (so "all" / "high"
/ "open" appeared instead of "All severities" / "High" / "Open"). Pass
a `(value) => label` render-prop on each filter's SelectValue:
- Severity → uses LEVEL_LABEL ("Very low" / "Low" / etc.)
- Status → new local STATUS_LABEL map
- Owner → looks up the assignee in `assignees` and renders
user.name (falls back to email, then "Unknown")
When the value is "all" (or unset), each trigger renders the
"All severities / All statuses / All owners" copy instead of the raw
sentinel.
Co-Authored-By: Claude Opus 4.7 (1M context)
* fix(risks): tighten Owner filter to roles that can actually own risks
The Owner dropdown was populated from the people endpoint with the
filter !['employee','contractor'].includes(p.role). Two gaps:
1. `auditor` slipped through — auditors are read-only, can't update or
own a risk.
2. Comma-separated multi-role values (e.g. 'admin,employee') broke
the includes() check, so portal-only members with a multi-role
string were appearing as eligible owners.
Replace the role filter with a `canOwnRisks` helper that splits the
comma-separated role field, allows owner / admin / any custom role,
and explicitly excludes auditor / employee / contractor. Custom roles
pass through because the org defines them — we can't know their
resolved permissions client-side, and being conservative here would
exclude legitimate custom "Risk Manager" / "GRC Lead" style roles.
Co-Authored-By: Claude Opus 4.7 (1M context)
* fix(auth): pass `permission: undefined` alongside `permissions` to satisfy zod 4
better-auth's hasPermission endpoint validates the body with a zod
union of two variants — one shaped { permission, permissions: undefined }
and the other { permission: undefined, permissions }. Under zod 4,
`z.undefined()` requires the key to be explicitly present with the
undefined value, not absent. Sending only `{ permissions }` (key
permission absent) fails BOTH variants with "[body] Invalid input"
at runtime, even though the value-level shape was correct.
Result: every permission-gated endpoint hit by an authenticated user
threw a 400 "VALIDATION_ERROR" inside the guard, which the guard
caught and re-threw as 403 ForbiddenException. Frontend pages reading
gated endpoints (e.g. GET /v1/frameworks?includeControls=true) failed
to load.
Fix: build the body via a named variable (`{ permissions, permission:
undefined }`) so TS's excess-property check — which only fires on
object literals — doesn't reject the extra key, and the runtime
union schema gets the both-keys-explicitly-present shape it requires.
Reproduced with `zod` v4 against the exact schema in
node_modules/better-auth: only the both-keys form passes.
Co-Authored-By: Claude Opus 4.7 (1M context)
* feat(risks): paginate auto-link Tasks/Controls lists at 10 per page
A scan that returns 30+ suggestions made the column grow taller than
the viewport. Paginate both lists at 10 per page with prev/next
controls and a "X-Y of N" range label. Pagination chrome only renders
when there's more than one page (small lists stay clean).
The page resets to the last valid page if the underlying list shrinks
(re-run, items removed) so the user doesn't see an empty page.
Co-Authored-By: Claude Opus 4.7 (1M context)
* fix(risks): autonomous linkage now uses recall+rerank; mitigation leaves status pending
Three related post-onboarding UX fixes.
1. Autonomous (onboarding) linkage was leaving most risks empty.
The autonomous path used the strict default 0.65 cosine threshold,
which lets through almost nothing in the 0.4-0.6 band that dominates
short compliance prose. Result on a fresh org: 9 of 11 risks had zero
linked tasks after onboarding, leaving the user with an empty kickoff
state on every risk.
Apply the same recall+rerank pipeline that suggestions-only uses:
top-50 cosine candidates, top-30 fed to the LLM reranker, persist any
match scored ≥ 5/10 (medium-relevance and up), capped at 8 per risk.
Conservative because the user isn't reviewing — false positives stick
until manually unlinked. Falls back to cosine ordering if the rerank
call fails.
2. `generate-risk-mitigation` no longer marks risks `closed`.
Auto-closing implies the user reviewed the AI plan, which they didn't.
Set status to `pending` instead so the risk surfaces in "needs
review" lists. Reassignment to owner/admin behavior is unchanged.
3. UI placeholder while auto-mitigation is in flight.
When a Mitigate-strategy risk has linked tasks but no description yet
(the auto-link → mitigation gap), show a "AI is preparing your
treatment plan…" indicator instead of the empty editor. Polling
(already 5s) refreshes when the description fills in. Heuristic-based
so no new schema; the condition naturally resolves once the plan
arrives.
Co-Authored-By: Claude Opus 4.7 (1M context)
* fix(risks,perf): baseline risk status pending; bump linkage concurrency to 16
Two follow-ups from the post-onboarding test on the new org:
1. The hardcoded baseline "Intentional Fraud and Misuse" risk in
`ensureBaselineRisks` was being created with status `closed` —
that's the same review-skipping behavior the trigger task fix
already corrected for AI-generated risks. Now created with status
`pending` for consistency.
2. `MATCH_CONCURRENCY` was set conservatively at 4. Each iteration is
~3-5s (vector query + LLM rerank + Prisma update), so a 12-risk
onboarding was taking 3+ batches when one of the bottlenecks. Bump
to 16 — well within Upstash + OpenAI rate limits for typical
onboarding sizes — so 12-25 entities finish in roughly 2 batches.
Co-Authored-By: Claude Opus 4.7 (1M context)
* perf(linkage): bump MATCH_CONCURRENCY 16→32 to unblock onboarding
A 12-risk + 11-vendor onboarding was taking 287s in the linkage step
(82s risks + 203s vendors), which blocked the entire mitigation
fan-out behind it (orchestrator uses triggerAndWait on linkage so
mitigation generation can use the freshly-linked tasks/controls for
grounded prose).
Each iteration is dominated by the LLM rerank call (~5-10s). Going
from 4→32 in-flight cuts the wall-clock to roughly 1 batch, well
within gpt-5-mini and Upstash rate limits for typical onboarding
sizes (10-25 entities).
Note: the right architectural fix is true fan-out per entity using
trigger.dev's queue-level concurrency (50), splitting runLinkage
into embedScope() + matchEntity(). Filed as a follow-up — this
concurrency bump is the immediate unblocker.
Co-Authored-By: Claude Opus 4.7 (1M context)
* fix(linkage): floor of 3 links per entity to avoid zero-result onboardings
For "Phishing and Social Engineering Attacks" on a fresh org with 71
relevant tasks (Training / Competence Records, 2FA, Incident Response,
Access Review Log, etc.), autonomous linkage persisted zero matches
because the reranker scored every candidate below 5/10. Result: the
risk landed in the kickoff empty state and looked like nothing
happened.
The min-score-5 gate was too strict on its own. Add a floor: if fewer
than 3 candidates score ≥ 5/10, fall back to the top-3 by reranker
score regardless of magnitude. The user reviews via the Linked Work
column — better to start with 3 decent matches than zero.
The high-confidence path is unchanged (≥ 5/10 wins, capped at 8). The
floor only kicks in for niche risks where the LLM was conservative.
Co-Authored-By: Claude Opus 4.7 (1M context)
* chore(linkage): per-risk diagnostic logs to surface zero-result causes
When suggestions return 0 tasks/controls there's no way to tell from
the trigger.dev console whether the cosine query came back empty, the
scope filter dropped everything, or the reranker scored nothing high
enough. Add structured per-iteration logs:
[linkage] risk "X" → cosine returned N candidates (top scores: a, b, c)
[linkage] risk "X" → M of N cosine matches dropped (not in task scope)
[linkage] risk "X" → suggestions: A tasks, B controls
[linkage] risk "X" → persisted N task link(s)
[linkage] risk "X" → 0 candidates after linkSuggestions; skipping rerank
Same shape will be added to vendor matching as a follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context)
* chore: add project-level .mcp.json for trigger.dev MCP server
Anyone with Claude Code working on this repo gets the trigger.dev tools
(runs, logs, deploys, docs search) without per-user setup. After
pulling, restart Claude Code; the first authenticated tool call will
prompt for trigger.dev login.
Co-Authored-By: Claude Opus 4.7 (1M context)
* fix(linkage): wait for vector index + content-hash dedup on re-embeds
Two production fixes confirmed end-to-end on a fresh org:
1. Race-condition wait. Upstash Vector returns 200 from upsert before
the HNSW index has ingested the write, so cosine queries that race
ahead silently return zero candidates. Hit during onboarding when
6 of 11 risks landed with 0 task links because their queries fired
~4s after upsert vs the next 5 risks ~5s after. Adds `waitForIndexed`
helper that polls `info().pendingVectorCount` until it drains, plus
a new `waiting-for-index` LinkagePhase. After the fix, the same
onboarding flow links 11/11 risks (drain takes ~1.1s, 4 polls).
2. Content-hash dedup. Re-embedding every task/risk/vendor on every
linkage run was the main driver of the recent OpenAI + Upstash
cost spike. Adds `embeddingHash String?` to Task/Risk/Vendor (hash
of model + dims + department + text) and skips both the OpenAI
embed AND the Upstash upsert when the stored hash matches. First
linkage run on a fresh org: `tasks 71 new / 0 cached`. Subsequent
Suggest click on the same org: `tasks 0 new / 71 cached; skipping
index drain wait` — zero billed embedding work.
Tests: 4 new unit + 2 new integration in embedding/, including a
race-condition guard that pins the order (findSimilarTasks does not
fire until waitForIndexed resolves) and a cache-skip integration
test that asserts the wait is bypassed when nothing was upserted.
Co-Authored-By: Claude Opus 4.7 (1M context)
* chore(docs): regenerate openapi.json from API start
Co-Authored-By: Claude Opus 4.7 (1M context)
* fix: address Cubic AI review findings on PR #2671
Addresses 32 of 32 outstanding Cubic findings across security,
ENG-221 spec compliance, stale-closure bugs, error message
hygiene, and perf/correctness.
P1 (security/correctness):
- Cross-org task validation in risks/vendors auto-link/apply routes
before set/connect — prevents linking another org's tasks (#2, #3).
- Permission guards on every Next API mutation route via new
requireApiPermission helper (#9).
- Active-route token mint failures only clear the runId on confirmed
"run gone" cases (404 / "not found"); transient failures return
502 and preserve the runId so the next attempt can resume (#4, #5).
- Vendor mitigation now triggers AFTER the linkage gate so vendor
AI generation sees the linked tasks/controls grounding (#7, #26).
- RelinkButton completion effect reads link counts from typed
`run.output` instead of metadata, eliminating stale-closure
reports of 0 links (#6).
- Prisma client refuses to connect to non-local Postgres without
TLS verification unless PRISMA_ALLOW_INSECURE_TLS=1 is set
explicitly. Localhost detection now uses the parsed URL
hostname, not a regex over the full connection string (#1, #10).
ENG-221 spec violations:
- Avoid is now a first-class strategy option in the picker (#36).
- ScoreExplainer mentions Avoid in the coverage gate (#35).
- suggestedResidual gates Avoid on linked-work coverage (#39).
- Linked Work column shows for non-mitigate strategies (#37).
Stale-closure / state-race bugs:
- AutoLinkSuggestions.parts useEffect uses refs for output and
callbacks so the COMPLETED transition reads fresh values (#32).
- AutoLinkSuggestions resume callback no longer overwrites a
newer state transition (e.g. user-started suggest run) (#33).
- onUnlinkTask prop wired to LinkedWork with a per-row trash icon
(#34).
Error/UX hygiene:
- 5 routes return generic 500 messages instead of raw error.message
(#17, #18, #19, #20, #21).
- DELETE routes verify the link exists before disconnect (#22).
- DELETE routes fire-and-forget the treatment-plan refresh (#30, #31).
- regenerate-mitigation tolerates token-mint failures after the
run is already triggered, returning runId with null token so
client retries don't start duplicate runs (#29).
- AutoLinkButton surfaces a distinct toast when post-link refresh
fails, separately from link failures (#23).
Determinism + scope correctness:
- Citation grounding loaders add orderBy on tasks, controls, and
requirementsMapped so mitigation citations are stable across
re-runs (#40).
- RiskPageClient falls through to server-rendered initialRisk.tasks
so Linked Work doesn't blink empty between SSR and SWR (#28).
- RisksTable severity filter now fetches the org's full risk set
when active so filtering and pagination metadata are correct
globally (#27).
- embedding/index.ts marked `import 'server-only'`. vitest setup
stubs the module so existing tests still run (#38).
Test fixes:
- link-suggestions "candidate department is none" test now actually
asserts that the boost rule excludes `none` by setting source
department to `none` and proving the un-boosted candidate wins (#24).
- suggested-residual avoid test updated to cover both the empty-tasks
(no operational evidence yet) and linked-tasks (pin to floor) cases.
Tests: 43/43 in src/lib/{link-suggestions,suggested-residual,embedding}.
Co-Authored-By: Claude Opus 4.7 (1M context)
* fix: address Cubic AI follow-up review on PR #2671
8 findings across stale-state bugs, defense-in-depth gating,
shared-component reuse, and tooling hygiene.
P1:
- DescriptionEditor no longer wipes in-progress edits when `value`
changes mid-typing — the resync effect now skips while mode is
'edit', not just while saving. This was overwriting user input
whenever SWR revalidated or AI regen completed.
- onUnlinkTask wiring on RiskPageClient + VendorDetailTabs is now
gated behind `canUpdate` so the trash button doesn't render for
read-only users. The server-side Next API routes already enforce
the same `risk:update` / `vendor:update` check (added in the
prior commit) — this is defense in depth.
P2:
- TreatmentHero: `isEmpty` narrative branch now wins over
`isGatedByCoverage`. Since `isEmpty` is a strict subset (Mitigate
+ no plan + no linked work) of `isGatedByCoverage`, the empty
message was being shadowed and never rendered.
- AutoLinkSuggestions: applying with no selections in additive
mode no longer flips the UI back to the empty state — landing
state now considers BOTH existing linked tasks AND just-applied
selections, gated on whether mode was reassess (replace=true).
- link-risks-and-vendors-to-work trigger task: phase metadata now
always writes every field (using null when absent in the new
phase) so stale `current`/`total`/link counts don't leak across
phase boundaries to the realtime UI subscriber.
- VendorsTable residual sort: unassessed vendors are forced to the
end of the list regardless of sort direction. Their default
(very_unlikely × insignificant = 1) was clustering them as
lowest-residual even though we render them as `—`.
- NotAssessedState component is now neutral by default and accepts
description/headline/ctaLabel overrides. The vendor caller
passes its own copy. Lets the component be reused on the risk
surface without leaking vendor-only language.
P3:
- .mcp.json (root + worktree) switches `npx` → `bunx` per repo
tooling convention.
Tests: 43/43 in src/lib/{link-suggestions,suggested-residual,embedding}.
Co-Authored-By: Claude Opus 4.7 (1M context)
* fix(api): hoist hasPermission body to a variable to satisfy TS excess-property check
Inline form was rejected by tsc on this branch:
Object literal may only specify known properties,
but 'permission' does not exist in type ...
Excess-property check only fires on fresh object literals; widening
through a `body` variable accepts the wider runtime shape that the
zod union schema actually requires. Same intent as the main hot-fix —
both paths still pass `permission: undefined` explicitly.
Co-Authored-By: Claude Opus 4.7 (1M context)
---------
Co-authored-by: Mariano
Co-authored-by: Claude Opus 4.7 (1M context)
---
.mcp.json | 8 +
apps/api/prisma/client.js | 62 +-
apps/api/prisma/client.ts | 44 +-
apps/api/prisma/index.js | 1 -
apps/api/src/auth/permission.guard.ts | 13 +-
apps/api/src/policies/policies.controller.ts | 2 +-
apps/api/src/risks/risks.service.ts | 28 +-
.../src/risks/strategy-descriptions.spec.ts | 124 +++
apps/api/src/risks/strategy-descriptions.ts | 91 +++
.../vendor/vendor-risk-assessment-task.ts | 165 +---
.../vendor-risk-assessment/agent-schema.ts | 20 +
.../vendor-risk-assessment/agent-types.ts | 32 +
.../assessment-output.spec.ts | 99 +++
.../assessment-output.ts | 39 +
.../vendor-risk-assessment/description.ts | 3 +
.../firecrawl-agent-core.ts | 8 +-
.../firecrawl-agent-prompt.ts | 4 +-
.../firecrawl-agent-schema-json.ts | 24 +-
.../src/vendors/dto/update-vendor.dto.spec.ts | 50 ++
apps/api/src/vendors/dto/update-vendor.dto.ts | 28 +-
apps/api/src/vendors/vendors.service.ts | 18 +-
apps/app/package.json | 1 +
apps/app/prisma/index.ts | 66 +-
apps/app/prisma/server.ts | 65 +-
.../risk/(overview)/RisksTable.test.tsx | 49 ++
.../[orgId]/risk/(overview)/RisksTable.tsx | 269 +++++-
.../(app)/[orgId]/risk/(overview)/page.tsx | 25 +-
.../components/RiskPageClient.test.tsx | 37 +
.../[riskId]/components/RiskPageClient.tsx | 159 +++-
.../ModernSingleStatusTaskList.test.tsx | 2 +
.../tasks/components/TaskList.test.tsx | 1 +
.../components/VendorsTable.test.tsx | 40 +-
.../(overview)/components/VendorsTable.tsx | 46 +-
.../components/VendorDetailTabs.tsx | 178 ++--
.../components/VendorInherentRiskChart.tsx | 1 +
.../VendorResidualRiskChart.test.tsx | 54 +-
.../components/VendorResidualRiskChart.tsx | 55 +-
.../risks/[riskId]/auto-link/active/route.ts | 122 +++
.../risks/[riskId]/auto-link/apply/route.ts | 101 +++
.../app/api/risks/[riskId]/auto-link/route.ts | 68 ++
.../[riskId]/regenerate-mitigation/route.ts | 48 +-
.../app/api/risks/[riskId]/relink/route.ts | 57 ++
.../risks/[riskId]/tasks/[taskId]/route.ts | 107 +++
.../[vendorId]/auto-link/active/route.ts | 103 +++
.../[vendorId]/auto-link/apply/route.ts | 93 +++
.../api/vendors/[vendorId]/auto-link/route.ts | 68 ++
.../[vendorId]/regenerate-mitigation/route.ts | 45 +-
.../api/vendors/[vendorId]/relink/route.ts | 57 ++
.../[vendorId]/tasks/[taskId]/route.ts | 104 +++
.../src/components/risks/RiskScoreBadge.tsx | 67 +-
.../components/risks/charts/AxisTooltip.tsx | 29 +
.../risks/charts/InherentRiskChart.tsx | 1 +
.../components/risks/charts/MatrixBody.tsx | 188 +++++
.../components/risks/charts/MatrixLegend.tsx | 38 +
.../risks/charts/ResidualRiskChart.test.tsx | 6 +-
.../risks/charts/ResidualRiskChart.tsx | 32 +-
.../risks/charts/RiskMatrixChart.spec.tsx | 117 +++
.../risks/charts/RiskMatrixChart.tsx | 250 +++---
.../treatment-plan/AutoLinkButton.spec.tsx | 97 +++
.../risks/treatment-plan/AutoLinkButton.tsx | 177 ++++
.../AutoLinkSuggestions.parts.tsx | 459 +++++++++++
.../AutoLinkSuggestions.sections.tsx | 225 +++++
.../AutoLinkSuggestions.spec.tsx | 238 ++++++
.../treatment-plan/AutoLinkSuggestions.tsx | 295 +++++++
.../AutoLinkSuggestions.types.ts | 46 ++
.../treatment-plan/DescriptionEditor.tsx | 345 ++++++++
.../risks/treatment-plan/LinkedWork.spec.tsx | 50 ++
.../risks/treatment-plan/LinkedWork.tsx | 200 +++++
.../risks/treatment-plan/NotAssessedState.tsx | 52 ++
.../treatment-plan/RelinkButton.spec.tsx | 58 ++
.../risks/treatment-plan/RelinkButton.tsx | 198 +++++
.../risks/treatment-plan/RiskMatrix5x5.tsx | 244 ++++++
.../risks/treatment-plan/RiskScale.tsx | 91 +++
.../risks/treatment-plan/ScoreExplainer.tsx | 141 ++++
.../risks/treatment-plan/StrategyPicker.tsx | 109 +++
.../risks/treatment-plan/TreatmentHero.tsx | 341 ++++++++
.../treatment-plan/TreatmentPlanTab.spec.tsx | 135 +++
.../risks/treatment-plan/TreatmentPlanTab.tsx | 310 +++++++
apps/app/src/hooks/use-risks.ts | 141 +++-
apps/app/src/hooks/use-vendors.ts | 134 ++-
apps/app/src/lib/embedding/embedding.spec.ts | 247 ++++++
apps/app/src/lib/embedding/index.ts | 249 ++++++
.../app/src/lib/embedding/run-linkage.spec.ts | 705 ++++++++++++++++
apps/app/src/lib/embedding/run-linkage.ts | 767 ++++++++++++++++++
apps/app/src/lib/link-suggestions.spec.ts | 130 +++
apps/app/src/lib/link-suggestions.ts | 57 ++
apps/app/src/lib/permissions.server.ts | 51 ++
apps/app/src/lib/rerank-suggestions.spec.ts | 100 +++
apps/app/src/lib/rerank-suggestions.ts | 130 +++
apps/app/src/lib/risk-score.ts | 39 +
apps/app/src/lib/strategy-descriptions.ts | 29 +
apps/app/src/lib/suggested-residual.spec.ts | 119 +++
apps/app/src/lib/suggested-residual.ts | 192 +++++
apps/app/src/test-utils/setup.ts | 4 +
.../tasks/auditor/generate-auditor-content.ts | 2 +-
.../build-citations-heading.spec.ts | 92 +++
.../onboarding/build-citations-heading.ts | 80 ++
.../onboarding/generate-risk-mitigation.ts | 18 +-
.../onboarding/generate-vendor-mitigation.ts | 30 +-
.../link-risks-and-vendors-to-work.spec.ts | 139 ++++
.../link-risks-and-vendors-to-work.ts | 112 +++
.../onboard-organization-helpers.ts | 393 ++++++++-
.../tasks/onboarding/onboard-organization.ts | 48 +-
.../prompts/risk-mitigation.spec.ts | 22 +
.../onboarding/prompts/risk-mitigation.ts | 74 +-
.../select-mitigation-citations.spec.ts | 110 +++
.../onboarding/select-mitigation-citations.ts | 97 +++
apps/app/trigger.config.ts | 7 +-
bun.lock | 45 +-
packages/db/package.json | 2 +-
.../migration.sql | 3 +
.../migration.sql | 7 +
.../migration.sql | 24 +
.../migration.sql | 8 +
packages/db/prisma/schema/risk.prisma | 17 +
packages/db/prisma/schema/task.prisma | 6 +
packages/db/prisma/schema/vendor.prisma | 12 +
packages/docs/openapi.json | 84 +-
118 files changed, 11278 insertions(+), 671 deletions(-)
create mode 100644 .mcp.json
create mode 100644 apps/api/src/risks/strategy-descriptions.spec.ts
create mode 100644 apps/api/src/risks/strategy-descriptions.ts
create mode 100644 apps/api/src/trigger/vendor/vendor-risk-assessment/assessment-output.spec.ts
create mode 100644 apps/api/src/trigger/vendor/vendor-risk-assessment/assessment-output.ts
create mode 100644 apps/app/src/app/api/risks/[riskId]/auto-link/active/route.ts
create mode 100644 apps/app/src/app/api/risks/[riskId]/auto-link/apply/route.ts
create mode 100644 apps/app/src/app/api/risks/[riskId]/auto-link/route.ts
create mode 100644 apps/app/src/app/api/risks/[riskId]/relink/route.ts
create mode 100644 apps/app/src/app/api/risks/[riskId]/tasks/[taskId]/route.ts
create mode 100644 apps/app/src/app/api/vendors/[vendorId]/auto-link/active/route.ts
create mode 100644 apps/app/src/app/api/vendors/[vendorId]/auto-link/apply/route.ts
create mode 100644 apps/app/src/app/api/vendors/[vendorId]/auto-link/route.ts
create mode 100644 apps/app/src/app/api/vendors/[vendorId]/relink/route.ts
create mode 100644 apps/app/src/app/api/vendors/[vendorId]/tasks/[taskId]/route.ts
create mode 100644 apps/app/src/components/risks/charts/AxisTooltip.tsx
create mode 100644 apps/app/src/components/risks/charts/MatrixBody.tsx
create mode 100644 apps/app/src/components/risks/charts/MatrixLegend.tsx
create mode 100644 apps/app/src/components/risks/charts/RiskMatrixChart.spec.tsx
create mode 100644 apps/app/src/components/risks/treatment-plan/AutoLinkButton.spec.tsx
create mode 100644 apps/app/src/components/risks/treatment-plan/AutoLinkButton.tsx
create mode 100644 apps/app/src/components/risks/treatment-plan/AutoLinkSuggestions.parts.tsx
create mode 100644 apps/app/src/components/risks/treatment-plan/AutoLinkSuggestions.sections.tsx
create mode 100644 apps/app/src/components/risks/treatment-plan/AutoLinkSuggestions.spec.tsx
create mode 100644 apps/app/src/components/risks/treatment-plan/AutoLinkSuggestions.tsx
create mode 100644 apps/app/src/components/risks/treatment-plan/AutoLinkSuggestions.types.ts
create mode 100644 apps/app/src/components/risks/treatment-plan/DescriptionEditor.tsx
create mode 100644 apps/app/src/components/risks/treatment-plan/LinkedWork.spec.tsx
create mode 100644 apps/app/src/components/risks/treatment-plan/LinkedWork.tsx
create mode 100644 apps/app/src/components/risks/treatment-plan/NotAssessedState.tsx
create mode 100644 apps/app/src/components/risks/treatment-plan/RelinkButton.spec.tsx
create mode 100644 apps/app/src/components/risks/treatment-plan/RelinkButton.tsx
create mode 100644 apps/app/src/components/risks/treatment-plan/RiskMatrix5x5.tsx
create mode 100644 apps/app/src/components/risks/treatment-plan/RiskScale.tsx
create mode 100644 apps/app/src/components/risks/treatment-plan/ScoreExplainer.tsx
create mode 100644 apps/app/src/components/risks/treatment-plan/StrategyPicker.tsx
create mode 100644 apps/app/src/components/risks/treatment-plan/TreatmentHero.tsx
create mode 100644 apps/app/src/components/risks/treatment-plan/TreatmentPlanTab.spec.tsx
create mode 100644 apps/app/src/components/risks/treatment-plan/TreatmentPlanTab.tsx
create mode 100644 apps/app/src/lib/embedding/embedding.spec.ts
create mode 100644 apps/app/src/lib/embedding/index.ts
create mode 100644 apps/app/src/lib/embedding/run-linkage.spec.ts
create mode 100644 apps/app/src/lib/embedding/run-linkage.ts
create mode 100644 apps/app/src/lib/link-suggestions.spec.ts
create mode 100644 apps/app/src/lib/link-suggestions.ts
create mode 100644 apps/app/src/lib/rerank-suggestions.spec.ts
create mode 100644 apps/app/src/lib/rerank-suggestions.ts
create mode 100644 apps/app/src/lib/strategy-descriptions.ts
create mode 100644 apps/app/src/lib/suggested-residual.spec.ts
create mode 100644 apps/app/src/lib/suggested-residual.ts
create mode 100644 apps/app/src/trigger/tasks/onboarding/build-citations-heading.spec.ts
create mode 100644 apps/app/src/trigger/tasks/onboarding/build-citations-heading.ts
create mode 100644 apps/app/src/trigger/tasks/onboarding/link-risks-and-vendors-to-work.spec.ts
create mode 100644 apps/app/src/trigger/tasks/onboarding/link-risks-and-vendors-to-work.ts
create mode 100644 apps/app/src/trigger/tasks/onboarding/prompts/risk-mitigation.spec.ts
create mode 100644 apps/app/src/trigger/tasks/onboarding/select-mitigation-citations.spec.ts
create mode 100644 apps/app/src/trigger/tasks/onboarding/select-mitigation-citations.ts
create mode 100644 packages/db/prisma/migrations/20260424171059_vendor_treatment_strategy/migration.sql
create mode 100644 packages/db/prisma/migrations/20260501205142_auto_link_run_persistence/migration.sql
create mode 100644 packages/db/prisma/migrations/20260505095610_strategy_descriptions_per_strategy/migration.sql
create mode 100644 packages/db/prisma/migrations/20260505140651_embedding_hash_dedup/migration.sql
diff --git a/.mcp.json b/.mcp.json
new file mode 100644
index 0000000000..32d5a74110
--- /dev/null
+++ b/.mcp.json
@@ -0,0 +1,8 @@
+{
+ "mcpServers": {
+ "trigger": {
+ "command": "bunx",
+ "args": ["trigger.dev@latest", "mcp"]
+ }
+ }
+}
diff --git a/apps/api/prisma/client.js b/apps/api/prisma/client.js
index 47ab4f329a..f0ef56f567 100644
--- a/apps/api/prisma/client.js
+++ b/apps/api/prisma/client.js
@@ -2,8 +2,66 @@
Object.defineProperty(exports, "__esModule", { value: true });
exports.db = void 0;
const client_1 = require("@prisma/client");
+const adapter_pg_1 = require("@prisma/adapter-pg");
const globalForPrisma = global;
-exports.db = globalForPrisma.prisma || new client_1.PrismaClient();
+const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']);
+function stripSslMode(connectionString) {
+ const url = new URL(connectionString);
+ url.searchParams.delete('sslmode');
+ return url.toString();
+}
+function isLocalhostUrl(connectionString) {
+ try {
+ const { hostname } = new URL(connectionString);
+ // Strip square brackets from IPv6 host form (e.g. [::1] → ::1)
+ const stripped = hostname.replace(/^\[/, '').replace(/\]$/, '');
+ return LOCAL_HOSTNAMES.has(stripped);
+ }
+ catch {
+ // Malformed URL — be conservative and treat as remote so we don't
+ // accidentally disable TLS verification.
+ return false;
+ }
+}
+function createPrismaClient() {
+ const rawUrl = process.env.DATABASE_URL;
+ const isLocalhost = isLocalhostUrl(rawUrl);
+ // Strategy:
+ // - Localhost: TLS off (typical dev Postgres has no cert).
+ // - Remote with NODE_EXTRA_CA_CERTS set: verified TLS using that bundle
+ // (e.g. Docker with the RDS CA bundle baked in).
+ // - Remote in explicit opt-out mode (PRISMA_ALLOW_INSECURE_TLS=1):
+ // unverified TLS — used by Trigger.dev / Vercel envs that connect via
+ // a tunneled proxy whose cert can't be pinned. Must be set deliberately;
+ // the previous default ("just turn off verification") silently exposed
+ // prod connections to MITM. (Cubic finding #1 on PR #2671.)
+ // - Remote with neither: throw at boot — surface the misconfig instead of
+ // silently downgrading.
+ const hasCABundle = !!process.env.NODE_EXTRA_CA_CERTS;
+ const allowInsecure = process.env.PRISMA_ALLOW_INSECURE_TLS === '1';
+ let ssl;
+ if (isLocalhost) {
+ ssl = undefined;
+ }
+ else if (hasCABundle) {
+ ssl = true;
+ }
+ else if (allowInsecure) {
+ ssl = { rejectUnauthorized: false };
+ }
+ else {
+ throw new Error('Refusing to connect to a non-local Postgres without TLS verification. Set NODE_EXTRA_CA_CERTS to a CA bundle, or set PRISMA_ALLOW_INSECURE_TLS=1 if you intentionally want unverified TLS.');
+ }
+ // Strip sslmode from the connection string to avoid conflicts with the explicit ssl option
+ const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl;
+ const adapter = new adapter_pg_1.PrismaPg({ connectionString: url, ssl });
+ return new client_1.PrismaClient({
+ adapter,
+ transactionOptions: {
+ timeout: 60000,
+ },
+ });
+}
+exports.db = globalForPrisma.prisma || createPrismaClient();
if (process.env.NODE_ENV !== 'production')
globalForPrisma.prisma = exports.db;
-//# sourceMappingURL=client.js.map
\ No newline at end of file
diff --git a/apps/api/prisma/client.ts b/apps/api/prisma/client.ts
index 1bd8069f56..1793bbac3a 100644
--- a/apps/api/prisma/client.ts
+++ b/apps/api/prisma/client.ts
@@ -3,19 +3,55 @@ import { PrismaPg } from '@prisma/adapter-pg';
const globalForPrisma = global as unknown as { prisma: PrismaClient };
+const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']);
+
function stripSslMode(connectionString: string): string {
const url = new URL(connectionString);
url.searchParams.delete('sslmode');
return url.toString();
}
+function isLocalhostUrl(connectionString: string): boolean {
+ try {
+ const { hostname } = new URL(connectionString);
+ // Strip square brackets from IPv6 host form (e.g. [::1] → ::1)
+ const stripped = hostname.replace(/^\[/, '').replace(/\]$/, '');
+ return LOCAL_HOSTNAMES.has(stripped);
+ } catch {
+ // Malformed URL — be conservative and treat as remote so we don't
+ // accidentally disable TLS verification.
+ return false;
+ }
+}
+
function createPrismaClient(): PrismaClient {
const rawUrl = process.env.DATABASE_URL!;
- const isLocalhost = /localhost|127\.0\.0\.1|::1/.test(rawUrl);
- // Use verified SSL when NODE_EXTRA_CA_CERTS is set (Docker with RDS CA bundle),
- // otherwise fall back to unverified SSL (Trigger.dev, Vercel, other environments).
+ const isLocalhost = isLocalhostUrl(rawUrl);
+ // Strategy:
+ // - Localhost: TLS off (typical dev Postgres has no cert).
+ // - Remote with NODE_EXTRA_CA_CERTS set: verified TLS using that bundle
+ // (e.g. Docker with the RDS CA bundle baked in).
+ // - Remote in explicit opt-out mode (PRISMA_ALLOW_INSECURE_TLS=1):
+ // unverified TLS — used by Trigger.dev / Vercel envs that connect via
+ // a tunneled proxy whose cert can't be pinned. Must be set deliberately;
+ // the previous default ("just turn off verification") silently exposed
+ // prod connections to MITM. (Cubic finding #1 on PR #2671.)
+ // - Remote with neither: throw at boot — surface the misconfig instead of
+ // silently downgrading.
const hasCABundle = !!process.env.NODE_EXTRA_CA_CERTS;
- const ssl = isLocalhost ? undefined : hasCABundle ? true : { rejectUnauthorized: false };
+ const allowInsecure = process.env.PRISMA_ALLOW_INSECURE_TLS === '1';
+ let ssl: undefined | true | { rejectUnauthorized: false };
+ if (isLocalhost) {
+ ssl = undefined;
+ } else if (hasCABundle) {
+ ssl = true;
+ } else if (allowInsecure) {
+ ssl = { rejectUnauthorized: false };
+ } else {
+ throw new Error(
+ 'Refusing to connect to a non-local Postgres without TLS verification. Set NODE_EXTRA_CA_CERTS to a CA bundle, or set PRISMA_ALLOW_INSECURE_TLS=1 if you intentionally want unverified TLS.',
+ );
+ }
// Strip sslmode from the connection string to avoid conflicts with the explicit ssl option
const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl;
const adapter = new PrismaPg({ connectionString: url, ssl });
diff --git a/apps/api/prisma/index.js b/apps/api/prisma/index.js
index a818c99668..b6014e58b0 100644
--- a/apps/api/prisma/index.js
+++ b/apps/api/prisma/index.js
@@ -18,4 +18,3 @@ exports.db = void 0;
__exportStar(require("@prisma/client"), exports);
var client_1 = require("./client");
Object.defineProperty(exports, "db", { enumerable: true, get: function () { return client_1.db; } });
-//# sourceMappingURL=index.js.map
\ No newline at end of file
diff --git a/apps/api/src/auth/permission.guard.ts b/apps/api/src/auth/permission.guard.ts
index fee0efac3b..033922d499 100644
--- a/apps/api/src/auth/permission.guard.ts
+++ b/apps/api/src/auth/permission.guard.ts
@@ -187,12 +187,13 @@ export class PermissionGuard implements CanActivate {
// the schema rejects every request with `[body] Invalid input`, the
// catch in canActivate turns that into a generic "Unable to verify
// permissions" 403, and EVERY cookie-authenticated request returns 403.
- // Reproduced repo-side via `bun run zod-repro.mjs`. Discovered on
- // ENG-221 and the same fix applies here.
- const result = await auth.api.hasPermission({
- headers,
- body: { permissions, permission: undefined },
- });
+ //
+ // Spell the body out via a separate variable so TypeScript's excess-
+ // property check (only applied to fresh object literals) doesn't
+ // reject the extra `permission` key — the runtime accepts the wider
+ // shape per the union schema.
+ const body = { permissions, permission: undefined };
+ const result = await auth.api.hasPermission({ headers, body });
return result.success === true;
}
diff --git a/apps/api/src/policies/policies.controller.ts b/apps/api/src/policies/policies.controller.ts
index 105260e843..67a4587c40 100644
--- a/apps/api/src/policies/policies.controller.ts
+++ b/apps/api/src/policies/policies.controller.ts
@@ -1319,7 +1319,7 @@ Keep responses helpful and focused on the policy editing task.`;
];
const result = streamText({
- model: openai('gpt-5.1'),
+ model: openai('gpt-5.5'),
system: systemPrompt,
messages: convertToModelMessages(messages),
});
diff --git a/apps/api/src/risks/risks.service.ts b/apps/api/src/risks/risks.service.ts
index 37c920d4ec..e4d29cff92 100644
--- a/apps/api/src/risks/risks.service.ts
+++ b/apps/api/src/risks/risks.service.ts
@@ -8,6 +8,7 @@ import { db, Prisma } from '@db';
import { CreateRiskDto } from './dto/create-risk.dto';
import { GetRisksQueryDto } from './dto/get-risks-query.dto';
import { UpdateRiskDto } from './dto/update-risk.dto';
+import { resolveStrategyDescriptionUpdate } from './strategy-descriptions';
export interface PaginatedRisksResult {
data: Prisma.RiskGetPayload<{
@@ -94,6 +95,10 @@ export class RisksService {
},
},
},
+ // Linked task statuses are needed by the table to compute the
+ // current (interpolated) severity score so the badge reflects
+ // treatment progress, not just inherent risk.
+ tasks: { select: { id: true, status: true } },
},
}),
db.risk.count({ where }),
@@ -128,6 +133,14 @@ export class RisksService {
user: true,
},
},
+ tasks: {
+ select: {
+ id: true,
+ title: true,
+ status: true,
+ controls: { select: { id: true, name: true } },
+ },
+ },
},
});
@@ -182,8 +195,8 @@ export class RisksService {
updateRiskDto: UpdateRiskDto,
) {
try {
- // First check if the risk exists in the organization
- await this.findById(id, organizationId);
+ // Need the existing row to resolve strategy/description swaps below.
+ const existing = await this.findById(id, organizationId);
if (updateRiskDto.assigneeId) {
await this.validateAssigneeNotPlatformAdmin(
@@ -192,9 +205,18 @@ export class RisksService {
);
}
+ // Keep per-strategy descriptions independent in the strategyDescriptions
+ // JSON map: a Mitigate plan, an Accept rationale, and a Transfer
+ // rationale all live alongside each other, swapped in/out of the active
+ // `treatmentStrategyDescription` field as the user changes strategy.
+ const resolvedStrategyFields = resolveStrategyDescriptionUpdate(
+ existing,
+ updateRiskDto,
+ );
+
const updatedRisk = await db.risk.update({
where: { id },
- data: updateRiskDto,
+ data: { ...updateRiskDto, ...resolvedStrategyFields },
});
this.logger.log(`Updated risk: ${updatedRisk.title} (${id})`);
diff --git a/apps/api/src/risks/strategy-descriptions.spec.ts b/apps/api/src/risks/strategy-descriptions.spec.ts
new file mode 100644
index 0000000000..6da074d226
--- /dev/null
+++ b/apps/api/src/risks/strategy-descriptions.spec.ts
@@ -0,0 +1,124 @@
+import { describe, expect, it, jest } from '@jest/globals';
+
+// Mock @db before importing the helper so we don't pull in the real Prisma
+// client (which requires DATABASE_URL at module load).
+jest.mock('@db', () => ({
+ RiskTreatmentType: {
+ accept: 'accept',
+ avoid: 'avoid',
+ mitigate: 'mitigate',
+ transfer: 'transfer',
+ },
+}));
+
+import { RiskTreatmentType } from '@db';
+import { resolveStrategyDescriptionUpdate } from './strategy-descriptions';
+
+const baseExisting = {
+ treatmentStrategy: RiskTreatmentType.mitigate,
+ treatmentStrategyDescription: 'Current mitigate plan',
+ strategyDescriptions: {} as unknown,
+};
+
+describe('resolveStrategyDescriptionUpdate', () => {
+ it('returns empty when neither strategy nor description is changing', () => {
+ expect(resolveStrategyDescriptionUpdate(baseExisting, {})).toEqual({});
+ expect(
+ resolveStrategyDescriptionUpdate(baseExisting, { assigneeId: 'mbr_1' } as never),
+ ).toEqual({});
+ });
+
+ it('on strategy change: saves current description into the OLD slot', () => {
+ const result = resolveStrategyDescriptionUpdate(baseExisting, {
+ treatmentStrategy: RiskTreatmentType.accept,
+ });
+ expect(result.treatmentStrategy).toBe(RiskTreatmentType.accept);
+ expect(result.strategyDescriptions).toEqual({
+ mitigate: 'Current mitigate plan',
+ });
+ // Active text becomes empty (no Accept rationale saved yet)
+ expect(result.treatmentStrategyDescription).toBeNull();
+ });
+
+ it('on strategy change: loads NEW strategy slot into active text when present', () => {
+ const result = resolveStrategyDescriptionUpdate(
+ {
+ ...baseExisting,
+ strategyDescriptions: {
+ accept: 'We accept this risk because of cost-benefit analysis.',
+ },
+ },
+ { treatmentStrategy: RiskTreatmentType.accept },
+ );
+ expect(result.treatmentStrategyDescription).toBe(
+ 'We accept this risk because of cost-benefit analysis.',
+ );
+ // Mitigate plan still preserved in the map
+ expect(result.strategyDescriptions).toEqual({
+ mitigate: 'Current mitigate plan',
+ accept: 'We accept this risk because of cost-benefit analysis.',
+ });
+ });
+
+ it('on description change without strategy change: mirrors into active strategy slot', () => {
+ const result = resolveStrategyDescriptionUpdate(baseExisting, {
+ treatmentStrategyDescription: 'Updated mitigate plan',
+ });
+ expect(result.treatmentStrategy).toBeUndefined();
+ expect(result.treatmentStrategyDescription).toBe('Updated mitigate plan');
+ expect(result.strategyDescriptions).toEqual({
+ mitigate: 'Updated mitigate plan',
+ });
+ });
+
+ it('on both change: explicit description wins as the new active text', () => {
+ const result = resolveStrategyDescriptionUpdate(
+ {
+ ...baseExisting,
+ strategyDescriptions: {
+ accept: 'Old accept rationale',
+ },
+ },
+ {
+ treatmentStrategy: RiskTreatmentType.accept,
+ treatmentStrategyDescription: 'Brand-new accept rationale',
+ },
+ );
+ expect(result.treatmentStrategy).toBe(RiskTreatmentType.accept);
+ expect(result.treatmentStrategyDescription).toBe('Brand-new accept rationale');
+ expect(result.strategyDescriptions).toEqual({
+ mitigate: 'Current mitigate plan',
+ accept: 'Brand-new accept rationale',
+ });
+ });
+
+ it('clears the slot when description is set to empty', () => {
+ const result = resolveStrategyDescriptionUpdate(
+ {
+ ...baseExisting,
+ strategyDescriptions: { mitigate: 'old text' },
+ },
+ { treatmentStrategyDescription: '' },
+ );
+ expect(result.strategyDescriptions).toEqual({});
+ });
+
+ it('handles malformed strategyDescriptions gracefully', () => {
+ const result = resolveStrategyDescriptionUpdate(
+ { ...baseExisting, strategyDescriptions: 'not an object' as unknown },
+ { treatmentStrategy: RiskTreatmentType.accept },
+ );
+ // Falls back to empty map; saves current Mitigate text
+ expect(result.strategyDescriptions).toEqual({
+ mitigate: 'Current mitigate plan',
+ });
+ });
+
+ it('does not save an empty old description into the slot', () => {
+ const result = resolveStrategyDescriptionUpdate(
+ { ...baseExisting, treatmentStrategyDescription: '' },
+ { treatmentStrategy: RiskTreatmentType.accept },
+ );
+ expect(result.strategyDescriptions).toEqual({});
+ });
+});
diff --git a/apps/api/src/risks/strategy-descriptions.ts b/apps/api/src/risks/strategy-descriptions.ts
new file mode 100644
index 0000000000..1511392286
--- /dev/null
+++ b/apps/api/src/risks/strategy-descriptions.ts
@@ -0,0 +1,91 @@
+import { RiskTreatmentType } from '@db';
+
+interface EntityWithStrategy {
+ treatmentStrategy: RiskTreatmentType;
+ treatmentStrategyDescription: string | null;
+ strategyDescriptions: unknown;
+}
+
+interface UpdateInput {
+ treatmentStrategy?: RiskTreatmentType;
+ treatmentStrategyDescription?: string | null;
+}
+
+export interface ResolvedStrategyUpdate {
+ treatmentStrategy?: RiskTreatmentType;
+ treatmentStrategyDescription?: string | null;
+ strategyDescriptions?: Record;
+}
+
+/**
+ * Computes the data fields that need to be persisted when an update touches
+ * `treatmentStrategy` and/or `treatmentStrategyDescription`. The logic keeps
+ * each strategy's description independent in the `strategyDescriptions` JSON
+ * column so a Mitigate plan, an Accept rationale, and a Transfer rationale
+ * can coexist on the same risk/vendor.
+ *
+ * Cases:
+ * - Strategy changes → save the current description into the OLD strategy
+ * slot, then load the NEW strategy's saved text (if any) back into the
+ * active `treatmentStrategyDescription`.
+ * - Description changes (no strategy change) → mirror it into the active
+ * strategy's slot in `strategyDescriptions`.
+ * - Both change → strategy swap runs first; explicit description in the
+ * update wins as the new active text and is mirrored into the new
+ * strategy's slot.
+ */
+export function resolveStrategyDescriptionUpdate(
+ existing: EntityWithStrategy,
+ update: UpdateInput,
+): ResolvedStrategyUpdate {
+ if (
+ update.treatmentStrategy === undefined &&
+ update.treatmentStrategyDescription === undefined
+ ) {
+ return {};
+ }
+
+ const map = parseStrategyMap(existing.strategyDescriptions);
+ const oldStrategy = existing.treatmentStrategy;
+ const newStrategy = update.treatmentStrategy ?? oldStrategy;
+ const isStrategyChange =
+ update.treatmentStrategy !== undefined && update.treatmentStrategy !== oldStrategy;
+
+ const result: ResolvedStrategyUpdate = {};
+
+ if (isStrategyChange) {
+ if ((existing.treatmentStrategyDescription ?? '').length > 0) {
+ map[oldStrategy] = existing.treatmentStrategyDescription as string;
+ } else {
+ delete map[oldStrategy];
+ }
+ result.treatmentStrategy = newStrategy;
+ if (update.treatmentStrategyDescription === undefined) {
+ result.treatmentStrategyDescription = map[newStrategy] ?? null;
+ }
+ }
+
+ if (update.treatmentStrategyDescription !== undefined) {
+ const next = update.treatmentStrategyDescription ?? '';
+ if (next.length > 0) {
+ map[newStrategy] = next;
+ } else {
+ delete map[newStrategy];
+ }
+ result.treatmentStrategyDescription = update.treatmentStrategyDescription;
+ }
+
+ result.strategyDescriptions = map;
+ return result;
+}
+
+function parseStrategyMap(raw: unknown): Record {
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
+ const out: Record = {};
+ for (const [k, v] of Object.entries(raw as Record)) {
+ if (typeof v === 'string' && v.length > 0) out[k] = v;
+ }
+ return out;
+ }
+ return {};
+}
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 23abf82887..fd664a6e54 100644
--- a/apps/api/src/trigger/vendor/vendor-risk-assessment-task.ts
+++ b/apps/api/src/trigger/vendor/vendor-risk-assessment-task.ts
@@ -1,20 +1,17 @@
import {
db,
- Impact,
- Likelihood,
TaskItemPriority,
TaskItemStatus,
VendorStatus,
type TaskItemEntityType,
} from '@db';
-import { openai } from '@ai-sdk/openai';
import type { Prisma } from '@db';
import type { Task } from '@trigger.dev/sdk';
import { logger, metadata, queue, schemaTask, tags } from '@trigger.dev/sdk';
-import { generateObject } from 'ai';
import { z } from 'zod';
import { resolveTaskCreatorAndAssignee } from './vendor-risk-assessment/assignee';
+import { extractInherentRisk } from './vendor-risk-assessment/assessment-output';
import { VENDOR_RISK_ASSESSMENT_TASK_ID } from './vendor-risk-assessment/constants';
import {
buildRiskAssessmentDescription,
@@ -174,124 +171,6 @@ function parseRiskAssessmentJson(value: string): Prisma.InputJsonValue {
return parsed;
}
-const riskLevelSchema = z
- .object({
- riskLevel: z.string().optional(),
- })
- .passthrough();
-
-function extractRiskLevel(value: Prisma.InputJsonValue): string | null {
- const parsed = riskLevelSchema.safeParse(value);
- if (!parsed.success) {
- return null;
- }
- return parsed.data.riskLevel ?? null;
-}
-
-/**
- * Risk level categories that map to database enums:
- * - critical → Likelihood.very_likely / Impact.severe (highest)
- * - high → Likelihood.likely / Impact.major
- * - medium → Likelihood.possible / Impact.moderate
- * - low → Likelihood.unlikely / Impact.minor
- * - very_low → Likelihood.very_unlikely / Impact.insignificant (lowest)
- */
-type NormalizedRiskLevel = 'critical' | 'high' | 'medium' | 'low' | 'very_low';
-
-const normalizedRiskLevelSchema = z.object({
- riskLevel: z
- .enum(['critical', 'high', 'medium', 'low', 'very_low'])
- .describe(
- 'The normalized risk level - must be exactly one of these values',
- ),
-});
-
-/**
- * Use AI to normalize any risk level string to one of our exact enum values.
- * Uses gpt-4o-mini (fast and cheap) with structured output to ensure valid values.
- */
-async function normalizeRiskLevel(
- rawRiskLevel: string | null | undefined,
-): Promise {
- if (!rawRiskLevel?.trim()) {
- return null;
- }
-
- try {
- const result = await generateObject({
- model: openai('gpt-5.2'),
- schema: normalizedRiskLevelSchema,
- prompt: `Classify this vendor security risk level into exactly one of these 5 categories.
-
-Risk level from assessment: "${rawRiskLevel}"
-
-Categories (highest to lowest risk):
-- critical: Highest risk (severe, extreme, very high, critical concerns)
-- high: Significant risk (high, major issues)
-- medium: Moderate risk (medium, moderate, average)
-- low: Low risk (low, minimal, minor)
-- very_low: Minimal risk (very low, negligible, none)
-
-Rules:
-- Return exactly one of: critical, high, medium, low, very_low
-- If ambiguous (e.g., "Low to Moderate"), pick the HIGHER risk to be conservative`,
- });
-
- logger.info('Normalized risk level', {
- rawRiskLevel,
- normalizedRiskLevel: result.object.riskLevel,
- });
-
- return result.object.riskLevel;
- } catch (error) {
- logger.warn('Failed to normalize risk level, defaulting to medium', {
- rawRiskLevel,
- error: error instanceof Error ? error.message : String(error),
- });
- return 'medium';
- }
-}
-
-function mapRiskLevelToLikelihood(
- normalizedLevel: NormalizedRiskLevel | null,
-): Likelihood {
- switch (normalizedLevel) {
- case 'critical':
- return Likelihood.very_likely;
- case 'high':
- return Likelihood.likely;
- case 'medium':
- return Likelihood.possible;
- case 'low':
- return Likelihood.unlikely;
- case 'very_low':
- return Likelihood.very_unlikely;
- default:
- // Default to medium (safer than lowest)
- return Likelihood.possible;
- }
-}
-
-function mapRiskLevelToImpact(
- normalizedLevel: NormalizedRiskLevel | null,
-): Impact {
- switch (normalizedLevel) {
- case 'critical':
- return Impact.severe;
- case 'high':
- return Impact.major;
- case 'medium':
- return Impact.moderate;
- case 'low':
- return Impact.minor;
- case 'very_low':
- return Impact.insignificant;
- default:
- // Default to medium (safer than lowest)
- return Impact.moderate;
- }
-}
-
/**
* Valid compliance badge types for trust portal
*/
@@ -857,7 +736,9 @@ export const vendorRiskAssessmentTask: Task<
verifiedCertifications: verifiedCount,
links: linkCount,
hasAssessment: Boolean(result.securityAssessment),
- riskLevel: result.riskLevel ?? 'none',
+ likelihood: result.likelihood ?? 'none',
+ impact: result.impact ?? 'none',
+ hasRationale: Boolean(result.rationale),
});
// Report each finding individually with delays so the UI
@@ -1043,22 +924,23 @@ export const vendorRiskAssessmentTask: Task<
});
// Extract risk level and badges
- logger.info('🎯 Normalizing risk level', {
+ logger.info('🎯 Extracting inherent risk from assessment payload', {
vendor: payload.vendorName,
});
- const rawRiskLevel = extractRiskLevel(data);
- const normalizedRiskLvl = await normalizeRiskLevel(rawRiskLevel);
- const inherentProbability = mapRiskLevelToLikelihood(normalizedRiskLvl);
- const inherentImpact = mapRiskLevelToImpact(normalizedRiskLvl);
- const residualProbability = mapRiskLevelToLikelihood(normalizedRiskLvl);
- const residualImpact = mapRiskLevelToImpact(normalizedRiskLvl);
+ const inherentRisk = extractInherentRisk(data);
+ if (!inherentRisk) {
+ logger.warn(
+ '⚠️ Assessment payload missing likelihood/impact — preserving existing vendor scores',
+ { vendor: payload.vendorName },
+ );
+ }
const complianceBadges = extractComplianceBadges(data);
const logoUrl = generateLogoUrl(vendor.website);
logger.info('📊 Risk level and badges extracted', {
vendor: payload.vendorName,
- rawRiskLevel,
- normalizedRiskLevel: normalizedRiskLvl,
+ inherentLikelihood: inherentRisk?.likelihood ?? null,
+ inherentImpact: inherentRisk?.impact ?? null,
hasBadges: Boolean(complianceBadges),
badgeCount: Array.isArray(complianceBadges)
? complianceBadges.length
@@ -1078,14 +960,23 @@ export const vendorRiskAssessmentTask: Task<
),
});
- // Update vendor with core data (keep status in_progress — news may still be loading)
+ // Update vendor with core data (keep status in_progress — news may still be loading).
+ // Only write risk fields when the assessment payload produced a valid
+ // inherentRisk — otherwise preserve whatever the vendor row already has
+ // (e.g. pre-ENG-221 globalVendors.riskAssessmentData that lacks
+ // likelihood/impact). Residual defaults to inherent until a human
+ // mitigates. Preserved behavior.
await db.vendor.update({
where: { id: vendor.id },
data: {
- inherentProbability,
- inherentImpact,
- residualProbability,
- residualImpact,
+ ...(inherentRisk
+ ? {
+ inherentProbability: inherentRisk.likelihood,
+ inherentImpact: inherentRisk.impact,
+ residualProbability: inherentRisk.likelihood,
+ residualImpact: inherentRisk.impact,
+ }
+ : {}),
...(complianceBadges ? { complianceBadges } : {}),
...(logoUrl ? { logoUrl } : {}),
},
diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment/agent-schema.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment/agent-schema.ts
index f9f1f9541e..fcf888b62f 100644
--- a/apps/api/src/trigger/vendor/vendor-risk-assessment/agent-schema.ts
+++ b/apps/api/src/trigger/vendor/vendor-risk-assessment/agent-schema.ts
@@ -11,6 +11,26 @@ const dateStringOrEmptySchema = z
.nullable();
export const vendorRiskAssessmentAgentSchema = z.object({
+ /**
+ * ENG-221: replaces risk_level. Likelihood and impact are scored
+ * independently so vendors can land on any cell of the 5x5 matrix
+ * instead of pooling on the diagonal.
+ */
+ likelihood: z
+ .enum(['very_unlikely', 'unlikely', 'possible', 'likely', 'very_likely'])
+ .optional()
+ .nullable(),
+ impact: z
+ .enum(['insignificant', 'minor', 'moderate', 'major', 'severe'])
+ .optional()
+ .nullable(),
+ rationale: z.string().optional().nullable(),
+ /**
+ * Legacy single-dimension score retained as optional so stored payloads
+ * from before ENG-221 still parse. New assessments should set
+ * `likelihood` + `impact` + `rationale` instead.
+ */
+ // TODO(ENG-221 follow-up): remove once globalVendors.riskAssessmentData backfill ships.
risk_level: z.string().optional().nullable(),
security_assessment: z.string().optional().nullable(),
last_researched_at: dateStringOrEmptySchema,
diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment/agent-types.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment/agent-types.ts
index b934550b98..b95060eeb0 100644
--- a/apps/api/src/trigger/vendor/vendor-risk-assessment/agent-types.ts
+++ b/apps/api/src/trigger/vendor/vendor-risk-assessment/agent-types.ts
@@ -31,11 +31,43 @@ export type VendorRiskAssessmentNewsItem = {
sentiment?: VendorRiskAssessmentNewsSentiment | null;
};
+/**
+ * Likelihood enum string values — mirrors the Prisma `Likelihood` enum.
+ * Kept as a literal union here so this module stays independent from
+ * `@db` / `@prisma/client`.
+ */
+export type VendorRiskAssessmentLikelihood =
+ | 'very_unlikely'
+ | 'unlikely'
+ | 'possible'
+ | 'likely'
+ | 'very_likely';
+
+/**
+ * Impact enum string values — mirrors the Prisma `Impact` enum.
+ */
+export type VendorRiskAssessmentImpact =
+ | 'insignificant'
+ | 'minor'
+ | 'moderate'
+ | 'major'
+ | 'severe';
+
export type VendorRiskAssessmentDataV1 = {
kind: 'vendorRiskAssessmentV1';
vendorName?: string | null;
vendorWebsite?: string | null;
lastResearchedAt?: string | null;
+ /**
+ * ENG-221: two independent dimensions. Preferred over the legacy
+ * single-bucket `riskLevel`. New assessments always set these; legacy
+ * payloads may only have `riskLevel`.
+ */
+ likelihood?: VendorRiskAssessmentLikelihood | null;
+ impact?: VendorRiskAssessmentImpact | null;
+ rationale?: string | null;
+ /** Legacy single-bucket score. Retained for pre-ENG-221 payloads. */
+ // TODO(ENG-221 follow-up): remove once globalVendors.riskAssessmentData backfill ships.
riskLevel?: string | null;
securityAssessment?: string | null;
certifications?: VendorRiskAssessmentCertification[] | null;
diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment/assessment-output.spec.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment/assessment-output.spec.ts
new file mode 100644
index 0000000000..417c49030d
--- /dev/null
+++ b/apps/api/src/trigger/vendor/vendor-risk-assessment/assessment-output.spec.ts
@@ -0,0 +1,99 @@
+import { Impact, Likelihood } from '@prisma/client';
+
+import {
+ assessmentOutputSchema,
+ extractInherentRisk,
+} from './assessment-output';
+
+describe('assessmentOutputSchema', () => {
+ it('accepts independent likelihood and impact combinations across the full matrix', () => {
+ const offDiagonal = assessmentOutputSchema.safeParse({
+ likelihood: 'very_likely',
+ impact: 'insignificant',
+ rationale: 'Motivated adversary but public-only data.',
+ });
+ expect(offDiagonal.success).toBe(true);
+ if (offDiagonal.success) {
+ expect(offDiagonal.data.likelihood).toBe(Likelihood.very_likely);
+ expect(offDiagonal.data.impact).toBe(Impact.insignificant);
+ }
+ });
+
+ it('accepts every enum value on each dimension', () => {
+ const likelihoods: Likelihood[] = [
+ Likelihood.very_unlikely,
+ Likelihood.unlikely,
+ Likelihood.possible,
+ Likelihood.likely,
+ Likelihood.very_likely,
+ ];
+ const impacts: Impact[] = [
+ Impact.insignificant,
+ Impact.minor,
+ Impact.moderate,
+ Impact.major,
+ Impact.severe,
+ ];
+ for (const likelihood of likelihoods) {
+ for (const impact of impacts) {
+ const result = assessmentOutputSchema.safeParse({
+ likelihood,
+ impact,
+ rationale: 'test rationale long enough.',
+ });
+ expect(result.success).toBe(true);
+ }
+ }
+ });
+
+ it('rejects a missing dimension', () => {
+ const missingImpact = assessmentOutputSchema.safeParse({
+ likelihood: 'possible',
+ rationale: 'missing impact on purpose.',
+ });
+ expect(missingImpact.success).toBe(false);
+ });
+
+ it('rejects out-of-enum values', () => {
+ const result = assessmentOutputSchema.safeParse({
+ likelihood: 'sometimes',
+ impact: 'catastrophic',
+ rationale: 'garbage in.',
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('rejects too-short rationale', () => {
+ const result = assessmentOutputSchema.safeParse({
+ likelihood: 'possible',
+ impact: 'moderate',
+ rationale: 'short',
+ });
+ expect(result.success).toBe(false);
+ });
+});
+
+describe('extractInherentRisk', () => {
+ it('threads likelihood + impact from a clean assessment payload', () => {
+ const payload = {
+ likelihood: 'very_likely',
+ impact: 'minor',
+ rationale: 'High adversary motivation, low blast radius due to scope.',
+ };
+ const extracted = extractInherentRisk(payload);
+ expect(extracted).toEqual({
+ likelihood: Likelihood.very_likely,
+ impact: Impact.minor,
+ });
+ });
+
+ it('returns null when the payload is missing dimensions', () => {
+ const extracted = extractInherentRisk({ riskLevel: 'high' });
+ expect(extracted).toBeNull();
+ });
+
+ it('returns null when the payload is garbage', () => {
+ const extracted = extractInherentRisk({ foo: 'bar' });
+ expect(extracted).toBeNull();
+ });
+});
diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment/assessment-output.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment/assessment-output.ts
new file mode 100644
index 0000000000..da51a689c6
--- /dev/null
+++ b/apps/api/src/trigger/vendor/vendor-risk-assessment/assessment-output.ts
@@ -0,0 +1,39 @@
+// Import enums + types from `@prisma/client` directly (not `@db`) so this
+// module has no runtime dependency on the PrismaClient singleton. That lets
+// the spec import it without needing to mock `@db`.
+import { Impact, Likelihood, type Prisma } from '@prisma/client';
+import { z } from 'zod';
+
+/**
+ * Output of the vendor AI risk assessment — two independent dimensions.
+ *
+ * Replaces the previous single-level → diagonal-cell mapping, which pooled
+ * every vendor into 5 of 25 possible cells (5 of 10 score buckets).
+ *
+ * `likelihood` = probability of a security incident originating from or
+ * involving this vendor (adversary motivation + exposure + data handled).
+ * `impact` = blast radius if the vendor is compromised (regulatory
+ * exposure, customer-data sensitivity, operational criticality).
+ */
+export const assessmentOutputSchema = z.object({
+ likelihood: z.nativeEnum(Likelihood),
+ impact: z.nativeEnum(Impact),
+ rationale: z.string().min(20),
+});
+
+export type AssessmentOutput = z.infer;
+
+/**
+ * Extract the inherent likelihood + impact from a vendor risk assessment payload.
+ * Returns null if the payload doesn't conform to `assessmentOutputSchema`
+ * (e.g. old-format payloads from globalVendors created before ENG-221).
+ */
+export function extractInherentRisk(
+ payload: Prisma.InputJsonValue,
+): { likelihood: Likelihood; impact: Impact } | null {
+ const parsed = assessmentOutputSchema.safeParse(payload);
+ if (!parsed.success) {
+ return null;
+ }
+ return { likelihood: parsed.data.likelihood, impact: parsed.data.impact };
+}
diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment/description.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment/description.ts
index d19f6aae86..906f9131d1 100644
--- a/apps/api/src/trigger/vendor/vendor-risk-assessment/description.ts
+++ b/apps/api/src/trigger/vendor/vendor-risk-assessment/description.ts
@@ -18,6 +18,9 @@ export function buildRiskAssessmentDescription(params: {
vendorName,
vendorWebsite,
lastResearchedAt: null,
+ likelihood: null,
+ impact: null,
+ rationale: null,
riskLevel: null,
securityAssessment: null,
certifications: null,
diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent-core.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent-core.ts
index 93683039e3..c48d2e131b 100644
--- a/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent-core.ts
+++ b/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent-core.ts
@@ -207,7 +207,10 @@ export async function firecrawlResearchCore(params: {
verifiedAgentCertCount: certifications.filter(
(c) => c.status === 'verified',
).length,
- agentRiskLevel: parsed.data.risk_level ?? null,
+ agentLikelihood: parsed.data.likelihood ?? null,
+ agentImpact: parsed.data.impact ?? null,
+ agentRationaleLength: parsed.data.rationale?.length ?? 0,
+ agentRiskLevelLegacy: parsed.data.risk_level ?? null,
});
const deepScrapeSourceUrl = pickDeepScrapeSourceUrl({
@@ -287,6 +290,9 @@ export async function firecrawlResearchCore(params: {
lastResearchedAt:
normalizeIso(parsed.data.last_researched_at ?? null) ??
new Date().toISOString(),
+ likelihood: parsed.data.likelihood ?? null,
+ impact: parsed.data.impact ?? null,
+ rationale: parsed.data.rationale ?? null,
riskLevel: parsed.data.risk_level ?? null,
securityAssessment: parsed.data.security_assessment ?? null,
certifications:
diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent-prompt.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent-prompt.ts
index 4cd4059b50..947298b700 100644
--- a/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent-prompt.ts
+++ b/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent-prompt.ts
@@ -48,7 +48,9 @@ Only return a certification when the page explicitly names a framework as curren
- links.privacy_policy_url, links.terms_of_service_url, links.security_page_url, links.soc2_report_url — return only when confirmed; otherwise empty.
- certifications — may be an empty array. Do NOT pad it.
- security_assessment — one paragraph summarising what you observed. If the trust portal was SPA-only and you could not read content, say so explicitly ("Trust portal at appears to be a JavaScript SPA; deep-scrape will extract content").
-- risk_level — your best estimate among critical/high/medium/low/very_low based on what you found.
+- likelihood — probability of a security incident originating from or involving this vendor. Score INDEPENDENTLY of impact based on adversary motivation, exposure surface, and the kind of data handled. Choose exactly one of: very_unlikely, unlikely, possible, likely, very_likely.
+- impact — blast radius if this vendor is compromised. Score INDEPENDENTLY of likelihood based on regulatory exposure, customer-data sensitivity, and operational criticality. Choose exactly one of: insignificant, minor, moderate, major, severe.
+- rationale — two to four sentences justifying BOTH scores. Name the data handled and the adversary motivation. Do not merge the two dimensions into a single "risk" verdict.
Focus on ${vendorWebsite} and its trust/security/compliance paths. Only cite URLs on ${vendorDomain}, its subdomains, or a recognised third-party portal hosting this vendor's trust page.`;
}
diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent-schema-json.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent-schema-json.ts
index 310ee19892..3efc452c34 100644
--- a/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent-schema-json.ts
+++ b/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent-schema-json.ts
@@ -10,10 +10,28 @@
export const firecrawlAgentJsonSchema = {
type: 'object',
properties: {
- risk_level: {
+ likelihood: {
type: 'string',
+ enum: [
+ 'very_unlikely',
+ 'unlikely',
+ 'possible',
+ 'likely',
+ 'very_likely',
+ ],
description:
- 'Overall vendor risk level: critical, high, medium, low, or very_low',
+ 'Probability of an incident originating from or involving this vendor. Score independently of impact.',
+ },
+ impact: {
+ type: 'string',
+ enum: ['insignificant', 'minor', 'moderate', 'major', 'severe'],
+ description:
+ 'Blast radius if the vendor is compromised. Score independently of likelihood.',
+ },
+ rationale: {
+ type: 'string',
+ description:
+ 'Two to four sentences justifying both scores. Name the data handled and the adversary motivation.',
},
security_assessment: {
type: 'string',
@@ -91,5 +109,5 @@ export const firecrawlAgentJsonSchema = {
},
},
},
- required: ['security_assessment'],
+ required: ['security_assessment', 'likelihood', 'impact', 'rationale'],
} as const;
diff --git a/apps/api/src/vendors/dto/update-vendor.dto.spec.ts b/apps/api/src/vendors/dto/update-vendor.dto.spec.ts
index 53641b820f..b0f91c13c0 100644
--- a/apps/api/src/vendors/dto/update-vendor.dto.spec.ts
+++ b/apps/api/src/vendors/dto/update-vendor.dto.spec.ts
@@ -136,6 +136,56 @@ describe('UpdateVendorDto', () => {
expect(errors[0].property).toBe('status');
});
+ // ── treatment strategy fields (ENG-221) ───────────────────────────
+ it('should accept valid treatmentStrategy enum values', async () => {
+ for (const strategy of ['accept', 'avoid', 'mitigate', 'transfer']) {
+ const dto = toDto({ treatmentStrategy: strategy });
+ const errors = await validate(dto, {
+ whitelist: true,
+ forbidNonWhitelisted: true,
+ });
+ expect(errors).toHaveLength(0);
+ }
+ });
+
+ it('should reject invalid treatmentStrategy enum value', async () => {
+ const dto = toDto({ treatmentStrategy: 'ignore' });
+ const errors = await validate(dto, {
+ whitelist: true,
+ forbidNonWhitelisted: true,
+ });
+ expect(errors.length).toBeGreaterThan(0);
+ expect(errors[0].property).toBe('treatmentStrategy');
+ });
+
+ it('should accept treatmentStrategyDescription as a string', async () => {
+ const dto = toDto({
+ treatmentStrategy: 'mitigate',
+ treatmentStrategyDescription:
+ 'We isolated the vendor to a dedicated VPC.',
+ });
+ const errors = await validate(dto, {
+ whitelist: true,
+ forbidNonWhitelisted: true,
+ });
+ expect(errors).toHaveLength(0);
+ expect(dto.treatmentStrategyDescription).toBe(
+ 'We isolated the vendor to a dedicated VPC.',
+ );
+ });
+
+ it('should reject treatmentStrategyDescription longer than 20,000 chars', async () => {
+ const dto = toDto({
+ treatmentStrategyDescription: 'x'.repeat(20_001),
+ });
+ const errors = await validate(dto, {
+ whitelist: true,
+ forbidNonWhitelisted: true,
+ });
+ expect(errors.length).toBeGreaterThan(0);
+ expect(errors[0].property).toBe('treatmentStrategyDescription');
+ });
+
// ── forbidNonWhitelisted ──────────────────────────────────────────
it('should reject unknown properties', async () => {
const dto = toDto({ name: 'Acronis', unknownField: 'value' });
diff --git a/apps/api/src/vendors/dto/update-vendor.dto.ts b/apps/api/src/vendors/dto/update-vendor.dto.ts
index c7186b3f70..2c4f2efe63 100644
--- a/apps/api/src/vendors/dto/update-vendor.dto.ts
+++ b/apps/api/src/vendors/dto/update-vendor.dto.ts
@@ -6,9 +6,16 @@ import {
IsEnum,
IsUrl,
IsBoolean,
+ MaxLength,
} from 'class-validator';
import { Transform } from 'class-transformer';
-import { VendorCategory, VendorStatus, Likelihood, Impact } from '@db';
+import {
+ VendorCategory,
+ VendorStatus,
+ Likelihood,
+ Impact,
+ RiskTreatmentType,
+} from '@db';
/**
* DTO for PATCH /vendors/:id
@@ -67,6 +74,25 @@ export class UpdateVendorDto {
@IsEnum(Impact)
residualImpact?: Impact;
+ @ApiPropertyOptional({
+ description: 'Risk treatment strategy',
+ enum: RiskTreatmentType,
+ default: RiskTreatmentType.accept,
+ example: RiskTreatmentType.mitigate,
+ })
+ @IsOptional()
+ @IsEnum(RiskTreatmentType)
+ treatmentStrategy?: RiskTreatmentType;
+
+ @ApiPropertyOptional({
+ description: 'Description of the treatment strategy',
+ example: 'We isolated the vendor to a dedicated VPC.',
+ })
+ @IsOptional()
+ @IsString()
+ @MaxLength(20_000)
+ treatmentStrategyDescription?: string | null;
+
@ApiPropertyOptional({ description: 'Vendor website URL' })
@IsOptional()
@IsUrl()
diff --git a/apps/api/src/vendors/vendors.service.ts b/apps/api/src/vendors/vendors.service.ts
index 47c49b0d17..712e1c2e6a 100644
--- a/apps/api/src/vendors/vendors.service.ts
+++ b/apps/api/src/vendors/vendors.service.ts
@@ -11,6 +11,7 @@ import { tasks } from '@trigger.dev/sdk';
import { Prisma } from '@db';
import type { TriggerVendorRiskAssessmentVendorDto } from './dto/trigger-vendor-risk-assessment.dto';
import { resolveTaskCreatorAndAssignee } from '../trigger/vendor/vendor-risk-assessment/assignee';
+import { resolveStrategyDescriptionUpdate } from '../risks/strategy-descriptions';
const normalizeWebsite = (
website: string | null | undefined,
@@ -142,6 +143,14 @@ export class VendorsService {
},
},
},
+ tasks: {
+ select: {
+ id: true,
+ title: true,
+ status: true,
+ controls: { select: { id: true, name: true } },
+ },
+ },
},
});
@@ -626,9 +635,16 @@ export class VendorsService {
);
}
+ // Keep per-strategy descriptions independent across treatment
+ // strategies — see `apps/api/src/risks/strategy-descriptions.ts`.
+ const resolvedStrategyFields = resolveStrategyDescriptionUpdate(
+ existing,
+ updateVendorDto,
+ );
+
const updatedVendor = await db.vendor.update({
where: { id },
- data: updateVendorDto,
+ data: { ...updateVendorDto, ...resolvedStrategyFields },
});
this.logger.log(`Updated vendor: ${updatedVendor.name} (${id})`);
diff --git a/apps/app/package.json b/apps/app/package.json
index f39fce8fd3..ac2bc13498 100644
--- a/apps/app/package.json
+++ b/apps/app/package.json
@@ -79,6 +79,7 @@
"@uiw/react-json-view": "^2.0.0-alpha.40",
"@uploadthing/react": "^7.3.0",
"@upstash/ratelimit": "^2.0.5",
+ "@upstash/vector": "^1.2.2",
"@vercel/analytics": "^1.5.0",
"@vercel/sandbox": "^0.0.21",
"@vercel/sdk": "^1.7.1",
diff --git a/apps/app/prisma/index.ts b/apps/app/prisma/index.ts
index b329db54e3..f661a0b1d3 100644
--- a/apps/app/prisma/index.ts
+++ b/apps/app/prisma/index.ts
@@ -1 +1,65 @@
-export * from '@prisma/client';
+// `export *` from `@prisma/client` (CommonJS) makes Turbopack emit a warning
+// on every compile because it can't statically resolve the export list.
+// Listing the runtime values explicitly + `export type *` for types gives the
+// same public surface with a clean compile. Keep enum names alphabetized so
+// additions are obvious in diffs.
+export type * from '@prisma/client';
+export {
+ Prisma,
+ PrismaClient,
+ AttachmentEntityType,
+ AttachmentType,
+ AuditLogEntityType,
+ BrowserAutomationEvaluationStatus,
+ BrowserAutomationRunStatus,
+ CommentEntityType,
+ Departments,
+ DevicePlatform,
+ EvidenceAutomationEvaluationStatus,
+ EvidenceAutomationRunStatus,
+ EvidenceAutomationTrigger,
+ EvidenceFormType,
+ FindingArea,
+ FindingSeverity,
+ FindingStatus,
+ FindingType,
+ FrameworkStatus,
+ FrameworkSyncOperationKind,
+ Frequency,
+ Impact,
+ IntegrationConnectionStatus,
+ IntegrationFindingSeverity,
+ IntegrationFindingStatus,
+ IntegrationRunJobType,
+ IntegrationRunStatus,
+ IntegrationSyncLogStatus,
+ KnowledgeBaseDocumentProcessingStatus,
+ Likelihood,
+ PhaseCompletionType,
+ PolicyDisplayFormat,
+ PolicyStatus,
+ PolicyVisibility,
+ QuestionnaireAnswerStatus,
+ QuestionnaireStatus,
+ RiskCategory,
+ RiskStatus,
+ RiskTreatmentType,
+ Role,
+ SOAAnswerStatus,
+ SOADocumentStatus,
+ TaskAutomationStatus,
+ TaskFrequency,
+ TaskItemEntityType,
+ TaskItemPriority,
+ TaskItemStatus,
+ TaskStatus,
+ TimelinePhaseStatus,
+ TimelineStatus,
+ TrustAccessGrantStatus,
+ TrustAccessRequestStatus,
+ TrustFramework,
+ TrustNDAStatus,
+ TrustStatus,
+ VendorCategory,
+ VendorStatus,
+} from '@prisma/client';
diff --git a/apps/app/prisma/server.ts b/apps/app/prisma/server.ts
index 54d1c4b9c9..b04e8bc13d 100644
--- a/apps/app/prisma/server.ts
+++ b/apps/app/prisma/server.ts
@@ -1,2 +1,65 @@
-export * from '@prisma/client';
+// `export *` from `@prisma/client` (CommonJS) makes Turbopack warn on every
+// compile. Listing runtime values explicitly + `export type *` for types
+// gives the same public surface with a clean compile. Keep enums in sync
+// with `apps/app/prisma/index.ts`.
+export type * from '@prisma/client';
+export {
+ Prisma,
+ PrismaClient,
+ AttachmentEntityType,
+ AttachmentType,
+ AuditLogEntityType,
+ BrowserAutomationEvaluationStatus,
+ BrowserAutomationRunStatus,
+ CommentEntityType,
+ Departments,
+ DevicePlatform,
+ EvidenceAutomationEvaluationStatus,
+ EvidenceAutomationRunStatus,
+ EvidenceAutomationTrigger,
+ EvidenceFormType,
+ FindingArea,
+ FindingSeverity,
+ FindingStatus,
+ FindingType,
+ FrameworkStatus,
+ FrameworkSyncOperationKind,
+ Frequency,
+ Impact,
+ IntegrationConnectionStatus,
+ IntegrationFindingSeverity,
+ IntegrationFindingStatus,
+ IntegrationRunJobType,
+ IntegrationRunStatus,
+ IntegrationSyncLogStatus,
+ KnowledgeBaseDocumentProcessingStatus,
+ Likelihood,
+ PhaseCompletionType,
+ PolicyDisplayFormat,
+ PolicyStatus,
+ PolicyVisibility,
+ QuestionnaireAnswerStatus,
+ QuestionnaireStatus,
+ RiskCategory,
+ RiskStatus,
+ RiskTreatmentType,
+ Role,
+ SOAAnswerStatus,
+ SOADocumentStatus,
+ TaskAutomationStatus,
+ TaskFrequency,
+ TaskItemEntityType,
+ TaskItemPriority,
+ TaskItemStatus,
+ TaskStatus,
+ TimelinePhaseStatus,
+ TimelineStatus,
+ TrustAccessGrantStatus,
+ TrustAccessRequestStatus,
+ TrustFramework,
+ TrustNDAStatus,
+ TrustStatus,
+ VendorCategory,
+ VendorStatus,
+} from '@prisma/client';
export { db } from './client';
diff --git a/apps/app/src/app/(app)/[orgId]/risk/(overview)/RisksTable.test.tsx b/apps/app/src/app/(app)/[orgId]/risk/(overview)/RisksTable.test.tsx
index 22c95d34d2..c8be570601 100644
--- a/apps/app/src/app/(app)/[orgId]/risk/(overview)/RisksTable.test.tsx
+++ b/apps/app/src/app/(app)/[orgId]/risk/(overview)/RisksTable.test.tsx
@@ -70,6 +70,34 @@ vi.mock('@/hooks/use-risks', () => ({
// Mock @db
vi.mock('@db', () => ({
Risk: {},
+ // Enums used by suggested-residual / risk-score helpers. Values mirror
+ // the real Prisma enum string values.
+ Likelihood: {
+ very_unlikely: 'very_unlikely',
+ unlikely: 'unlikely',
+ possible: 'possible',
+ likely: 'likely',
+ very_likely: 'very_likely',
+ },
+ Impact: {
+ insignificant: 'insignificant',
+ minor: 'minor',
+ moderate: 'moderate',
+ major: 'major',
+ severe: 'severe',
+ },
+ RiskTreatmentType: {
+ accept: 'accept',
+ avoid: 'avoid',
+ mitigate: 'mitigate',
+ transfer: 'transfer',
+ },
+ TaskStatus: {
+ todo: 'todo',
+ in_progress: 'in_progress',
+ done: 'done',
+ not_relevant: 'not_relevant',
+ },
}));
// Mock onboarding hooks
@@ -116,6 +144,11 @@ vi.mock('@trycompai/design-system', () => ({
InputGroup: ({ children }: any) =>
{children}
,
InputGroupAddon: ({ children }: any) => {children},
InputGroupInput: (props: any) => ,
+ Select: ({ children }: any) =>
{children}
,
+ SelectContent: ({ children }: any) =>
{children}
,
+ SelectItem: ({ children, value }: any) => ,
+ SelectTrigger: ({ children }: any) =>
,
@@ -202,11 +235,27 @@ describe('RisksTable permission gating', () => {
expect(screen.getByText('RISK')).toBeInTheDocument();
expect(screen.getByText('SEVERITY')).toBeInTheDocument();
+ expect(screen.getByText('RISK SCORE')).toBeInTheDocument();
expect(screen.getByText('STATUS')).toBeInTheDocument();
expect(screen.getByText('OWNER')).toBeInTheDocument();
expect(screen.getByText('UPDATED')).toBeInTheDocument();
});
+ it('renders SEVERITY label + RISK SCORE number, both from current state', () => {
+ setMockPermissions({});
+
+ render();
+
+ // Fixture: possible × moderate, mitigate, no linked tasks.
+ // inherent: 3 × 3 = 9 raw → ceil(9/2.5) = 4
+ // coverage gate (no tasks) → target = inherent → current = 4
+ // → severity label "Low" (score 4 → low band) + numeric "4/10".
+ // The severity filter dropdown also contains "Low" as an option, so
+ // we expect at least one (row + dropdown option).
+ expect(screen.getAllByText('Low').length).toBeGreaterThanOrEqual(1);
+ expect(screen.getByText('4/10')).toBeInTheDocument();
+ });
+
it('renders search bar regardless of permissions', () => {
setMockPermissions({});
diff --git a/apps/app/src/app/(app)/[orgId]/risk/(overview)/RisksTable.tsx b/apps/app/src/app/(app)/[orgId]/risk/(overview)/RisksTable.tsx
index 31f3c5f948..42520e71f9 100644
--- a/apps/app/src/app/(app)/[orgId]/risk/(overview)/RisksTable.tsx
+++ b/apps/app/src/app/(app)/[orgId]/risk/(overview)/RisksTable.tsx
@@ -1,5 +1,6 @@
'use client';
+import { RiskScoreBadge } from '@/components/risks/RiskScoreBadge';
import { usePermissions } from '@/hooks/use-permissions';
import {
useRiskActions,
@@ -9,6 +10,13 @@ import {
type RisksQueryParams,
} from '@/hooks/use-risks';
import { getSortingStateParser } from '@/lib/parsers';
+import { getRiskLevelFromScore, getRiskScore, LEVEL_LABEL } from '@/lib/risk-score';
+import {
+ interpolatedResidualScore,
+ previewResidual,
+ suggestedResidual,
+} from '@/lib/suggested-residual';
+import { TaskStatus } from '@db';
import type { Member, User } from '@db';
import { Risk as RiskType } from '@db';
import {
@@ -34,6 +42,11 @@ import {
InputGroup,
InputGroupAddon,
InputGroupInput,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
Spinner,
Stack,
Table,
@@ -66,33 +79,47 @@ const ACTIVE_STATUSES: Array<'pending' | 'processing' | 'created' | 'assessing'>
'assessing',
];
-function getSeverityBadge(likelihood: string, impact: string) {
- // Calculate severity based on likelihood and impact
- const likelihoodScore: Record = {
- very_unlikely: 1,
- unlikely: 2,
- possible: 3,
- likely: 4,
- very_likely: 5,
- };
- const impactScore: Record = {
- insignificant: 1,
- minor: 2,
- moderate: 3,
- major: 4,
- severe: 5,
- };
+/**
+ * The risk's current severity score (1-10), interpolated by linked-task
+ * completion the same way the Treatment Plan hero does it. Returns the
+ * inherent score when there's no linked work or the strategy doesn't
+ * project a reduction. Falls back to inherent on malformed input.
+ */
+function currentSeverityScore(risk: {
+ likelihood: ApiRisk['likelihood'];
+ impact: ApiRisk['impact'];
+ treatmentStrategy: ApiRisk['treatmentStrategy'];
+ tasks?: Array<{ status: TaskStatus }>;
+}): number {
+ const inherent = getRiskScore(risk.likelihood, risk.impact);
+ const tasks = risk.tasks ?? [];
+ const target = previewResidual({
+ inherentLikelihood: risk.likelihood,
+ inherentImpact: risk.impact,
+ strategy: risk.treatmentStrategy,
+ hasLinkedWork: tasks.length > 0,
+ });
+ const targetScore = getRiskScore(target.likelihood, target.impact).score;
+ const completion = suggestedResidual({
+ likelihood: risk.likelihood,
+ impact: risk.impact,
+ strategy: risk.treatmentStrategy,
+ tasks,
+ }).completion;
+ return interpolatedResidualScore({
+ inherentScore: inherent.score,
+ targetScore,
+ completion,
+ });
+}
- const score = (likelihoodScore[likelihood] || 1) * (impactScore[impact] || 1);
- if (score >= 15) {
- return High;
- }
- if (score >= 8) {
- return Medium;
- }
- return Low;
-}
+const STATUS_LABEL: Record = {
+ open: 'Open',
+ pending: 'Pending',
+ closed: 'Closed',
+ archived: 'Archived',
+};
function getStatusBadge(status: string) {
switch (status) {
@@ -146,24 +173,48 @@ export const RisksTable = ({
// Read current search params from URL
const [title, setTitle] = useQueryState('title', parseAsString.withDefault(''));
+ const [statusFilter, setStatusFilter] = useQueryState(
+ 'status',
+ parseAsString.withDefault(''),
+ );
+ const [assigneeFilter, setAssigneeFilter] = useQueryState(
+ 'assignee',
+ parseAsString.withDefault(''),
+ );
+ // Severity is computed from the current treatment-aware score, so it's
+ // filtered client-side after fetch (the API can't query a derived value).
+ const [severityFilter, setSeverityFilter] = useQueryState(
+ 'severity',
+ parseAsString.withDefault(''),
+ );
const [sort, setSort] = useQueryState(
'sort',
getSortingStateParser().withDefault([{ id: 'title', desc: false }]),
);
- // Build query params for the API
+ // Build query params for the API. Status and assignee are server-side;
+ // severity is computed from the current treatment-aware score (which the
+ // API can't query directly), so when severity is filtered we fetch
+ // the org's full risk set in one page and paginate after the filter.
+ // Without this, severity filtering would only see whatever happened to
+ // be on the current server page — causing wrong/missing results and an
+ // incorrect total count. (Cubic finding #27 on PR #2671.)
+ const FILTER_ALL_PAGE_SIZE = 1000;
+ const fetchAllForSeverity = Boolean(severityFilter);
const queryParams = useMemo(() => {
const currentSort = sort[0];
return {
- page,
- perPage,
+ page: fetchAllForSeverity ? 1 : page,
+ perPage: fetchAllForSeverity ? FILTER_ALL_PAGE_SIZE : perPage,
...(title && { title }),
+ ...(statusFilter && { status: statusFilter }),
+ ...(assigneeFilter && { assigneeId: assigneeFilter }),
...(currentSort && {
sort: currentSort.id,
sortDirection: currentSort.desc ? 'desc' as const : 'asc' as const,
}),
};
- }, [page, perPage, title, sort]);
+ }, [page, perPage, title, statusFilter, assigneeFilter, sort, fetchAllForSeverity]);
// Use the useRisks hook with query params
const { data: risksData, mutate: mutateRisks } = useRisks({
@@ -173,12 +224,34 @@ export const RisksTable = ({
keepPreviousData: true,
});
- const risks = useMemo(() => {
+ // Full result set from the API — already narrowed by status/assignee/title.
+ const fullList = useMemo(() => {
const apiData = risksData?.data?.data;
return Array.isArray(apiData) ? apiData : initialRisks;
}, [risksData, initialRisks]);
- const pageCount = risksData?.data?.pageCount ?? initialPageCount;
+ // After server filtering, apply the (derived) severity filter and then
+ // re-paginate client-side so totals reflect the filtered result.
+ const filteredList = useMemo(() => {
+ if (!severityFilter) return fullList;
+ return fullList.filter((risk) => {
+ const score = currentSeverityScore(risk);
+ return getRiskLevelFromScore(score) === severityFilter;
+ });
+ }, [fullList, severityFilter]);
+
+ const risks = useMemo(() => {
+ if (!fetchAllForSeverity) return filteredList;
+ const start = (page - 1) * perPage;
+ return filteredList.slice(start, start + perPage);
+ }, [filteredList, fetchAllForSeverity, page, perPage]);
+
+ const pageCount = useMemo(() => {
+ if (fetchAllForSeverity) {
+ return Math.max(1, Math.ceil(filteredList.length / perPage));
+ }
+ return risksData?.data?.pageCount ?? initialPageCount;
+ }, [fetchAllForSeverity, filteredList.length, perPage, risksData, initialPageCount]);
// Check if all risks are done assessing
const allRisksDoneAssessing = useMemo(() => {
@@ -380,18 +453,107 @@ export const RisksTable = ({
return (
- {/* Search Bar */}
-
-
-
-
-
- setTitle(e.target.value || null)}
- />
-
+ {/* Search + Filters. Severity is client-side (derived score),
+ Status and Owner are server-side via the risks API. Each
+ filter is URL-backed so links are shareable. */}
+
+
+ Ghost cell shows the residual suggested by this entity's treatment plan.
+
+
+ )}
+ {preliminary && (
+
+
+ Preliminary — assessment still running
+
+
+ )}
+
+
+
+ >
+ );
+
+ // When titleInfo is present, we render the title manually so we can inject an info icon.
+ // The DS Section's `title` prop only accepts `string`, so when titleInfo is set we omit
+ // `title` and render our own heading row. When no titleInfo, fall back to the normal prop.
+ if (titleInfo) {
+ return (
+
+
+ );
+}
diff --git a/apps/app/src/components/risks/treatment-plan/AutoLinkSuggestions.parts.tsx b/apps/app/src/components/risks/treatment-plan/AutoLinkSuggestions.parts.tsx
new file mode 100644
index 0000000000..fd5e730a67
--- /dev/null
+++ b/apps/app/src/components/risks/treatment-plan/AutoLinkSuggestions.parts.tsx
@@ -0,0 +1,459 @@
+'use client';
+
+import { Button } from '@trycompai/design-system';
+import { MagicWandFilled } from '@trycompai/design-system/icons';
+import { useRealtimeRun } from '@trigger.dev/react-hooks';
+import { useEffect, useMemo, useRef } from 'react';
+import { ControlsSection, TasksSection } from './AutoLinkSuggestions.sections';
+import {
+ isControlDerived,
+ type SuggestedControl,
+ type SuggestedTask,
+} from './AutoLinkSuggestions.types';
+
+export function EmptyState({
+ canUpdate,
+ submitting,
+ onSuggest,
+ onStartFromScratch,
+ variant = 'default',
+}: {
+ canUpdate: boolean;
+ submitting: boolean;
+ onSuggest: () => void;
+ /** Required when variant === 'kickoff' — dismisses the kickoff so the user
+ * can write the plan manually. Ignored for other variants. */
+ onStartFromScratch?: () => void;
+ /**
+ * `'kickoff'` — the wide centered panel ("Let AI kick this off") shown when
+ * plan and tasks are both empty. Has a primary "Draft plan & suggest
+ * links" button plus a "Start from scratch" escape hatch.
+ * `'kickoff-with-plan'` — same wide panel, copy adapted for the case where
+ * a plan already exists ("AI will only suggest tasks/controls; your plan
+ * stays as-is unless you regenerate").
+ * `'default'` — the smaller per-column empty CTA shown when the user has a
+ * plan but no linked tasks yet.
+ */
+ variant?: 'default' | 'kickoff' | 'kickoff-with-plan';
+}) {
+ // Adapt kickoff copy based on whether a plan already exists. The same
+ // wide-panel layout applies in both cases.
+ const isKickoff = variant === 'kickoff' || variant === 'kickoff-with-plan';
+ if (isKickoff) {
+ const hasPlan = variant === 'kickoff-with-plan';
+ const title = hasPlan ? 'Let AI suggest tasks and controls' : 'Let AI kick this off';
+ const description = hasPlan
+ ? "Based on your treatment plan, AI can scan your library and suggest the tasks and controls most likely to drive this risk down. You'll review everything before anything is linked."
+ : "Based on the strategy above, AI can draft a treatment plan and suggest the tasks and controls most likely to drive this risk down. You'll review everything before anything is saved or linked.";
+ const primaryLabel = hasPlan ? 'Suggest tasks & controls' : 'Draft plan & suggest links';
+ const escapeLabel = hasPlan ? 'Edit plan manually' : 'Start from scratch';
+
+ return (
+
+ );
+}
diff --git a/apps/app/src/components/risks/treatment-plan/RiskMatrix5x5.tsx b/apps/app/src/components/risks/treatment-plan/RiskMatrix5x5.tsx
new file mode 100644
index 0000000000..8243f24321
--- /dev/null
+++ b/apps/app/src/components/risks/treatment-plan/RiskMatrix5x5.tsx
@@ -0,0 +1,244 @@
+'use client';
+
+import { Impact, Likelihood } from '@db';
+import { Text } from '@trycompai/design-system';
+import { Checkmark } from '@trycompai/design-system/icons';
+
+const LIKELIHOOD_ORDER: Likelihood[] = [
+ Likelihood.very_unlikely,
+ Likelihood.unlikely,
+ Likelihood.possible,
+ Likelihood.likely,
+ Likelihood.very_likely,
+];
+
+const IMPACT_ORDER: Impact[] = [
+ Impact.insignificant,
+ Impact.minor,
+ Impact.moderate,
+ Impact.major,
+ Impact.severe,
+];
+
+const CELL_SIZE = 44;
+
+// Cell colors mirror the 5 score-band segments used by RiskScale at the
+// bottom of the hero, so the matrix, the headline numeral color, and the
+// bottom scale all agree on what counts as Low / Medium / High / etc.
+//
+// Score for cell (L_idx, I_idx) = ceil((L_idx+1)(I_idx+1) / 2.5), then bucket
+// by the same thresholds as `getRiskLevelFromScore`:
+// 1-2 → very-low, 3-4 → low, 5-6 → medium, 7-8 → high, 9-10 → very-high
+const CELL_BACKGROUND_BY_SCORE_BAND: Record<
+ 'very-low' | 'low' | 'medium' | 'high' | 'very-high',
+ string
+> = {
+ 'very-low': 'color-mix(in oklab, var(--success) 35%, transparent)',
+ low: 'color-mix(in oklab, var(--success) 25%, var(--warning) 35%)',
+ medium: 'color-mix(in oklab, var(--warning) 35%, transparent)',
+ high: 'color-mix(in oklab, var(--warning) 25%, var(--destructive) 35%)',
+ 'very-high': 'color-mix(in oklab, var(--destructive) 35%, transparent)',
+};
+
+function cellBackground(likelihoodIdx: number, impactIdx: number): string {
+ const raw = (likelihoodIdx + 1) * (impactIdx + 1);
+ const score = Math.max(1, Math.ceil(raw / 2.5));
+ if (score >= 9) return CELL_BACKGROUND_BY_SCORE_BAND['very-high'];
+ if (score >= 7) return CELL_BACKGROUND_BY_SCORE_BAND.high;
+ if (score >= 5) return CELL_BACKGROUND_BY_SCORE_BAND.medium;
+ if (score >= 3) return CELL_BACKGROUND_BY_SCORE_BAND.low;
+ return CELL_BACKGROUND_BY_SCORE_BAND['very-low'];
+}
+
+interface RiskMatrix5x5Props {
+ inherentLikelihood: Likelihood;
+ inherentImpact: Impact;
+ residualLikelihood: Likelihood;
+ residualImpact: Impact;
+ /**
+ * 0..1 — completion of the linked treatment work. The "Now" marker is
+ * rendered at a position interpolated between the inherent cell and the
+ * residual (target) cell by this fraction. Default 0 means the Now
+ * marker sits on the inherent cell (no progress yet).
+ */
+ completion?: number;
+ /** When true, render a small "Preliminary — assessment still running" subtitle below the matrix. */
+ preliminary?: boolean;
+}
+
+const GAP = 2;
+
+export function RiskMatrix5x5({
+ inherentLikelihood,
+ inherentImpact,
+ residualLikelihood,
+ residualImpact,
+ completion,
+ preliminary,
+}: RiskMatrix5x5Props) {
+ const inherentL = LIKELIHOOD_ORDER.indexOf(inherentLikelihood);
+ const inherentI = IMPACT_ORDER.indexOf(inherentImpact);
+ const residualL = LIKELIHOOD_ORDER.indexOf(residualLikelihood);
+ const residualI = IMPACT_ORDER.indexOf(residualImpact);
+ const c = Math.min(1, Math.max(0, completion ?? 0));
+ // Fractional "Now" position — interpolated between inherent and target by
+ // task completion. Snaps to inherent when completion=0 and to target when
+ // completion=1; lands somewhere in-between for partial progress.
+ const nowLFloat = inherentL + (residualL - inherentL) * c;
+ const nowIFloat = inherentI + (residualI - inherentI) * c;
+ // "Goal reached" — Now interpolated all the way to Target AND the strategy
+ // actually projects a reduction (so target ≠ inherent). When true we show
+ // a single celebratory marker instead of two stacked dots that hide each
+ // other. Accept (target === inherent) is excluded so an Accept-from-day-
+ // one risk doesn't claim a "completed" reduction.
+ const isAtTarget =
+ c >= 1 && (inherentL !== residualL || inherentI !== residualI);
+ // Pixel offset within the grid (top-left = (0,0)). Rows render top-to-
+ // bottom in descending likelihood order, so the y-axis is flipped (4 - L).
+ const stepPx = CELL_SIZE + GAP;
+ const nowOffsetX = nowIFloat * stepPx + CELL_SIZE / 2;
+ const nowOffsetY = (4 - nowLFloat) * stepPx + CELL_SIZE / 2;
+
+ const cells: React.ReactNode[] = [];
+ // Render rows top-to-bottom: highest likelihood first (row 4 → 0)
+ for (let row = 4; row >= 0; row--) {
+ for (let col = 0; col < 5; col++) {
+ const isResidual = row === residualL && col === residualI;
+ cells.push(
+
+ {isResidual && !isAtTarget && (
+
+ )}
+
+ );
+ }
+ }
+
+ return (
+
+
+
+ 5×5 Risk Matrix
+
+
+
+
+
+ Now
+
+
+
+
+
+ Target
+
+
+
+
+
+
+
+ Likelihood →
+
+
+ {cells}
+ {/* "Now" marker — rendered as an absolute overlay so it can sit
+ between cells when partial completion lands its position
+ off-grid. When the user reaches 100% completion (Now lands
+ on Target), we swap to a single primary-colored marker with
+ a checkmark — otherwise the red Now would just hide the
+ green Target and the user wouldn't see the win. */}
+ {isAtTarget ? (
+
+
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/apps/app/src/components/risks/treatment-plan/ScoreExplainer.tsx b/apps/app/src/components/risks/treatment-plan/ScoreExplainer.tsx
new file mode 100644
index 0000000000..d0dbc485a3
--- /dev/null
+++ b/apps/app/src/components/risks/treatment-plan/ScoreExplainer.tsx
@@ -0,0 +1,141 @@
+'use client';
+
+/**
+ * Concise but specific explanation of how the treatment-impact score is
+ * computed. Uses real GRC vocabulary and shows the actual formulas a CISO
+ * would expect to see (5x5 matrix, ceil(raw/2.5) normalization, linear
+ * interpolation by task completion).
+ *
+ * Does NOT publish the exact step-down counts per strategy — those live in
+ * `lib/suggested-residual.ts` and may evolve as we calibrate against
+ * customer feedback. Strategy effects are described qualitatively (which
+ * axes move, why) without naming the step magnitudes.
+ */
+export function ScoreExplainer() {
+ return (
+
+
How this score is calculated
+
+
+ We rate likelihood (Very Unlikely → Very Likely) and impact
+ (Insignificant → Severe) on a standard 5×5 matrix, each axis indexed
+ 1 to 5.
+
+ Risk levels (Negligible · Low · Medium · High · Critical) map from
+ bands of the raw score.
+
+
+
+ Each strategy projects a residual along defined axes:
+
+
+ Mitigate —
+ linked controls and tasks reduce both likelihood and
+ impact. The target re-runs the matrix math on the reduced inputs
+ and re-normalizes to 1–10.
+
+
+ Transfer —
+ insurance or contractual indemnity shifts financial impact but
+ doesn't change the probability of an event. The target reduces
+ impact only; likelihood stays at inherent.
+
+
+ Accept —
+ residual equals inherent. No reduction; rationale is documented
+ on the plan.
+
+
+ Avoid —
+ the activity that produces the risk is discontinued, so once
+ execution is in place the residual pins to the floor (likelihood
+ and impact both at their lowest).
+
+
+
+
+
+ Strategies that require operational evidence (Mitigate, Transfer,
+ Avoid) only project a target reduction when at least one task is
+ linked to the risk. Without linked work, the target collapses back
+ to inherent — the strategy alone isn't audit evidence. Accept is
+ unaffected (its target is inherent by definition).
+
+
+
+ For Mitigate, the displayed score interpolates linearly between
+ inherent and target by task completion:
+
+ At 0% complete the score equals inherent; at 100% it equals the
+ target. Non-Mitigate strategies are treated as fully executed by
+ definition (the strategy itself is the action), so their
+ current and target are the same.
+
+
+
+ The matrix structure and treatment categories align with these
+ standards; the specific 1–10 normalization and step-down magnitudes
+ are Comp AI's calibration.
+
+ );
+}
diff --git a/apps/app/src/components/risks/treatment-plan/TreatmentHero.tsx b/apps/app/src/components/risks/treatment-plan/TreatmentHero.tsx
new file mode 100644
index 0000000000..11d5c50321
--- /dev/null
+++ b/apps/app/src/components/risks/treatment-plan/TreatmentHero.tsx
@@ -0,0 +1,341 @@
+'use client';
+
+import { getRiskLevelFromScore, getRiskScore, type RiskLevel } from '@/lib/risk-score';
+import {
+ interpolatedResidualScore,
+ previewResidual,
+ suggestedResidual,
+} from '@/lib/suggested-residual';
+import { Impact, Likelihood, RiskTreatmentType, TaskStatus } from '@db';
+import { Popover as BasePopover } from '@base-ui/react/popover';
+import { Card, CardContent } from '@trycompai/design-system';
+import { ArrowRight, Information } from '@trycompai/design-system/icons';
+import { RiskMatrix5x5 } from './RiskMatrix5x5';
+import { RiskScale } from './RiskScale';
+import { ScoreExplainer } from './ScoreExplainer';
+
+const LEVEL_LABEL: Record = {
+ 'very-low': 'LOW',
+ low: 'LOW',
+ medium: 'MEDIUM',
+ high: 'HIGH',
+ 'very-high': 'CRITICAL',
+};
+
+// Solid (full-opacity) versions of the RiskScale bar's segment hues — so the
+// big numeral, the strong tag in the narrative, and the matching segment
+// below the hero all read as the same color band. The bar mixes these tokens
+// with `transparent` to look soft; the numeral wants the opaque mix at the
+// same hue ratio.
+const LEVEL_COLOR: Record = {
+ 'very-low': 'var(--success)',
+ low: 'color-mix(in oklab, var(--success) 50%, var(--warning))',
+ medium: 'var(--warning)',
+ high: 'color-mix(in oklab, var(--warning) 50%, var(--destructive))',
+ 'very-high': 'var(--destructive)',
+};
+
+const LIKELIHOOD_DISPLAY: Record = {
+ very_unlikely: 'Very Unlikely',
+ unlikely: 'Unlikely',
+ possible: 'Possible',
+ likely: 'Likely',
+ very_likely: 'Very Likely',
+};
+
+const IMPACT_DISPLAY: Record = {
+ insignificant: 'Insignificant',
+ minor: 'Minor',
+ moderate: 'Moderate',
+ major: 'Major',
+ severe: 'Severe',
+};
+
+interface TreatmentHeroProps {
+ inherentLikelihood: Likelihood;
+ inherentImpact: Impact;
+ /**
+ * @deprecated The hero now derives residual from `strategy` + `tasks` via
+ * `previewResidual`, so the math reflects the user's current strategy
+ * selection live. These props are retained for callers that haven't
+ * migrated yet but are unused.
+ */
+ residualLikelihood?: Likelihood;
+ residualImpact?: Impact;
+ strategy: RiskTreatmentType;
+ tasks: { status: TaskStatus }[];
+ /**
+ * When true, the narrative reflects "no plan in place yet" instead of the
+ * standard strategy-specific copy. Used for the Mitigate-fully-empty case
+ * where columns 02 and 03 are merged into a single empty-state CTA.
+ */
+ isEmpty?: boolean;
+}
+
+const STRATEGY_NARRATIVE: Record = {
+ mitigate: 'assuming linked tasks complete on schedule.',
+ accept: 'because we are accepting this risk as-is.',
+ transfer: 'because the impact is transferred via insurance or contract.',
+ avoid: 'because we are eliminating the activity that causes this risk.',
+};
+
+const STRATEGY_THIRD_STAT: Record<
+ RiskTreatmentType,
+ { label: string; value: string }
+> = {
+ mitigate: { label: 'Task Completion', value: '' }, // value computed live
+ accept: { label: 'Strategy', value: 'Accepted as-is' },
+ transfer: { label: 'Strategy', value: 'Impact transferred' },
+ avoid: { label: 'Strategy', value: 'Activity eliminated' },
+};
+
+export function TreatmentHero({
+ inherentLikelihood,
+ inherentImpact,
+ strategy,
+ tasks,
+ isEmpty,
+}: TreatmentHeroProps) {
+ const inherent = getRiskScore(inherentLikelihood, inherentImpact);
+ // Target = the residual the strategy would produce at full execution.
+ // Headline numerals are always inherent → target — the forecast you'd land
+ // on if every linked task ships. The numerals don't move based on partial
+ // task completion (that was confusing: the narrative already says
+ // "assuming linked tasks complete on schedule"). Instead, real-time
+ // progress shows up as a smaller "Currently X/10" line under the headline
+ // when the user is part-way through the plan.
+ // Coverage gate: don't claim a target reduction without linked work.
+ // Mitigate / Transfer require operational evidence (a control or task
+ // documenting the arrangement) before we project any reduction. Accept
+ // returns inherent regardless (no work to do), so the gate is a no-op
+ // there.
+ const hasLinkedWork = tasks.length > 0;
+ const target = previewResidual({
+ inherentLikelihood,
+ inherentImpact,
+ strategy,
+ hasLinkedWork,
+ });
+ const targetScore = getRiskScore(target.likelihood, target.impact);
+ const isGatedByCoverage =
+ !hasLinkedWork && strategy !== RiskTreatmentType.accept;
+
+ // Mitigate is the only strategy with a meaningful in-progress concept.
+ // Non-Mitigate strategies are "always at full completion" — the strategy
+ // itself is the action.
+ const completion =
+ strategy === RiskTreatmentType.mitigate
+ ? suggestedResidual({
+ likelihood: inherentLikelihood,
+ impact: inherentImpact,
+ strategy,
+ tasks,
+ }).completion
+ : 1;
+ const completionPct = Math.round(completion * 100);
+
+ // Current interpolated score — shown as a smaller subline under the
+ // headline when partway through (Mitigate, 0 < completion < 1).
+ const currentScore = interpolatedResidualScore({
+ inherentScore: inherent.score,
+ targetScore: targetScore.score,
+ completion,
+ });
+ const currentLevel = getRiskLevelFromScore(currentScore);
+ const showCurrentSubline =
+ strategy === RiskTreatmentType.mitigate && completion > 0 && completion < 1;
+
+ const delta = inherent.score - targetScore.score;
+ const inherentLevel = getRiskLevelFromScore(inherent.score);
+ const residualLevel = getRiskLevelFromScore(targetScore.score);
+
+ const thirdStat = STRATEGY_THIRD_STAT[strategy];
+ const thirdStatValue =
+ strategy === RiskTreatmentType.mitigate ? `${completionPct}%` : thirdStat.value;
+
+ return (
+
+
+
+ {/* LEFT: narrative */}
+
+
+
+ Risk Reduction — Treatment Impact
+
+
+
+
+ How is this calculated?
+
+
+ {/* Subtle backdrop blur — focuses attention on the
+ explainer without making it feel modal. */}
+
+
+
+
+
+
+
+
+
+ Currently{' '}
+
+ {currentScore}/10
+ {' '}
+ — {completionPct}% of plan complete
+
+ )}
+
+ The plan moves this risk from{' '}
+
+ {LEVEL_LABEL[inherentLevel]}
+ {' '}
+ to{' '}
+
+ {LEVEL_LABEL[residualLevel]}
+ {' '}
+ —{' '}
+ {delta === 0 ? (
+ no change
+ ) : (
+ <>
+ a{' '}
+
+ {delta > 0 ? '−' : '+'}
+ {Math.abs(delta)}
+ {' '}
+ point swing
+ >
+ )}{' '}
+ {/* `isEmpty` (Mitigate + no plan + no linked work) is a
+ strict subset of `isGatedByCoverage` (no linked work,
+ strategy != Accept). It's the more specific case, so
+ it must be checked first — otherwise the gated-by-
+ coverage branch shadows it and the user never sees
+ the "no plan in place yet" copy. (Cubic finding on
+ PR #2671.) */}
+ {isEmpty
+ ? 'since no mitigation plan is in place yet.'
+ : isGatedByCoverage
+ ? strategy === RiskTreatmentType.transfer
+ ? 'until a task documenting the transfer arrangement is linked.'
+ : 'until tasks supporting the strategy are linked.'
+ : STRATEGY_NARRATIVE[strategy]}
+
+
+
+
+
+
+
+
+ {/* RIGHT: matrix — Target stays at the strategy's full-completion
+ target cell. Now interpolates from inherent → target by task
+ completion so partial progress is visually reflected. */}
+
+
; queries are role-scoped to disambiguate
+ // from the hero's "Strategy" stat label.
+ expect(screen.getByRole('heading', { name: 'Strategy' })).toBeInTheDocument();
+ expect(screen.getByRole('heading', { name: 'Rationale' })).toBeInTheDocument();
+ expect(screen.queryByRole('heading', { name: 'Linked work' })).toBeNull();
+ });
+
+ it('renders the hero numerals using strategy-derived residual', () => {
+ // Mitigate with linked work shows the full-completion target.
+ // inherent: likely × major = 4×4 = 16 raw → ceil(16/2.5) = 7
+ // target: possible × moderate = 3×3 = 9 raw → ceil(9/2.5) = 4
+ const entity: TreatmentPlanEntity = {
+ ...baseEntity,
+ tasks: [{ id: 't1', title: 'Task 1', status: TaskStatus.todo, controls: [] }],
+ };
+ render();
+ const headline = screen.getByLabelText(/From 7 to 4 out of 10/i);
+ expect(headline).toBeInTheDocument();
+ expect(headline.textContent).toContain('7');
+ expect(headline.textContent).toContain('4');
+ });
+
+ it('coverage gate: Mitigate with no linked work shows inherent as the target', () => {
+ // No linked tasks → target collapses to inherent regardless of strategy.
+ // The headline shows 7 → 7 ("no change") with the explanatory copy
+ // pointing the user at linking work.
+ render();
+ const headline = screen.getByLabelText(/From 7 to 7 out of 10/i);
+ expect(headline).toBeInTheDocument();
+ expect(
+ screen.getByText(/until tasks supporting the strategy are linked/i),
+ ).toBeInTheDocument();
+ });
+
+ it('calls onUpdateStrategy when strategy card is clicked', async () => {
+ const onUpdateStrategy = vi.fn().mockResolvedValue(undefined);
+ render();
+ fireEvent.click(screen.getByRole('radio', { name: 'Mitigate' }));
+ await waitFor(() => {
+ expect(onUpdateStrategy).toHaveBeenCalledWith(RiskTreatmentType.mitigate);
+ });
+ });
+
+ it('disables strategy buttons and Save when canUpdate is false', () => {
+ render();
+ expect(screen.getByRole('radio', { name: 'Mitigate' })).toBeDisabled();
+ expect(screen.getByRole('button', { name: /^Save$/i })).toBeDisabled();
+ });
+
+ it('switches the regenerate label based on description presence', () => {
+ const { rerender } = render();
+ expect(
+ screen.getByRole('button', { name: /Generate treatment plan/i }),
+ ).toBeInTheDocument();
+
+ rerender(
+ ,
+ );
+ expect(screen.getByRole('button', { name: /Regenerate with AI/i })).toBeInTheDocument();
+ });
+
+ it('shows task completion percent in the hero stats', () => {
+ const entity: TreatmentPlanEntity = {
+ ...baseEntity,
+ treatmentStrategy: RiskTreatmentType.mitigate,
+ tasks: [
+ { id: 't1', title: 'Task 1', status: TaskStatus.done, controls: [] },
+ { id: 't2', title: 'Task 2', status: TaskStatus.done, controls: [] },
+ ],
+ };
+ render();
+ expect(screen.getByText('100%')).toBeInTheDocument();
+ });
+});
diff --git a/apps/app/src/components/risks/treatment-plan/TreatmentPlanTab.tsx b/apps/app/src/components/risks/treatment-plan/TreatmentPlanTab.tsx
new file mode 100644
index 0000000000..f3ba69ea74
--- /dev/null
+++ b/apps/app/src/components/risks/treatment-plan/TreatmentPlanTab.tsx
@@ -0,0 +1,310 @@
+'use client';
+
+import { cn } from '@/lib/utils';
+import { Impact, Likelihood, RiskTreatmentType, TaskStatus } from '@db';
+import { useEffect, useState } from 'react';
+import { AutoLinkSuggestions } from './AutoLinkSuggestions';
+import { DescriptionEditor } from './DescriptionEditor';
+import { LinkedWork } from './LinkedWork';
+import { StrategyPicker } from './StrategyPicker';
+import { TreatmentHero } from './TreatmentHero';
+
+export interface TreatmentPlanEntity {
+ id: string;
+ inherentLikelihood: Likelihood;
+ inherentImpact: Impact;
+ residualLikelihood: Likelihood;
+ residualImpact: Impact;
+ treatmentStrategy: RiskTreatmentType;
+ treatmentStrategyDescription: string | null;
+ /**
+ * Per-strategy saved descriptions. Lets the UI swap the displayed
+ * description instantly when the user picks a different strategy,
+ * without waiting for the API to round-trip and the SWR cache to
+ * revalidate. Server keeps these in sync; see
+ * `apps/api/src/risks/strategy-descriptions.ts`.
+ */
+ strategyDescriptions?: Partial> | null;
+ tasks: {
+ id: string;
+ title: string;
+ status: TaskStatus;
+ controls: { id: string; name: string }[];
+ }[];
+}
+
+interface TreatmentPlanTabProps {
+ orgId: string;
+ entity: TreatmentPlanEntity;
+ canUpdate: boolean;
+ onUpdateStrategy: (strategy: RiskTreatmentType) => Promise;
+ onUpdateDescription: (description: string) => Promise;
+ onRegenerate: () => Promise;
+ regenerating: boolean;
+ /**
+ * Active trigger.dev run handle for an in-flight regeneration. The
+ * description editor subscribes via `useRealtimeRun` to render live
+ * progress until the run terminates.
+ */
+ regenRun?: { runId: string; publicAccessToken: string } | null;
+ /** Called when the regeneration run terminates (success or failure). */
+ onRegenSettled?: (result: { success: boolean; reason?: string }) => void;
+ /**
+ * Triggers the AI scan in suggestionsOnly mode and returns a realtime handle.
+ * The component reads `run.output.suggestions` once status === COMPLETED.
+ * When omitted, the legacy fall-through renders only LinkedWork.
+ */
+ onSuggest?: () => Promise<{ runId: string; publicAccessToken: string }>;
+ /**
+ * Persists the user-confirmed selection.
+ * `replace: true` for re-assess (sync semantics — connect ONLY these).
+ * `replace: false` for fresh suggest (additive).
+ */
+ onApply?: (params: { taskIds: string[]; replace: boolean }) => Promise;
+ /** Optional unlink callback for individual rows in the linked-state list. */
+ onUnlinkTask?: (taskId: string) => Promise;
+ /**
+ * Resume an in-flight or completed-but-unreviewed AI scan. Returns null when
+ * no run is persisted server-side. Used to recover progress after a reload.
+ */
+ onResumeAutoLink?: () => Promise<{ runId: string; publicAccessToken: string } | null>;
+ /** Clears the persisted runId server-side (called from Discard). */
+ onDiscardAutoLinkRun?: () => Promise;
+ /**
+ * @deprecated Use `onSuggest` + `onApply` instead. The previous immediate-
+ * apply auto-link flow is replaced by review-before-apply.
+ */
+ onAutoLink?: () => Promise<{ runId: string; publicAccessToken: string }>;
+ /**
+ * @deprecated Use `onSuggest` + `onApply({ replace: true })` instead.
+ */
+ onRelink?: () => Promise<{ runId: string; publicAccessToken: string }>;
+}
+
+export function TreatmentPlanTab({
+ orgId,
+ entity,
+ canUpdate,
+ onUpdateStrategy,
+ onUpdateDescription,
+ onRegenerate,
+ regenerating,
+ onSuggest,
+ onApply,
+ onUnlinkTask,
+ onResumeAutoLink,
+ onDiscardAutoLinkRun,
+ regenRun,
+ onRegenSettled,
+}: TreatmentPlanTabProps) {
+ const [strategy, setStrategy] = useState(entity.treatmentStrategy);
+
+ useEffect(() => {
+ setStrategy(entity.treatmentStrategy);
+ }, [entity.treatmentStrategy]);
+
+ const handleStrategyChange = async (next: RiskTreatmentType) => {
+ setStrategy(next);
+ try {
+ await onUpdateStrategy(next);
+ } catch {
+ setStrategy(entity.treatmentStrategy);
+ }
+ };
+
+ // When the local strategy state matches the persisted strategy on the
+ // entity, use the active `treatmentStrategyDescription` (most up-to-date).
+ // When it differs (the user just clicked a different strategy and the
+ // SWR cache hasn't revalidated yet), fall back to the saved text for
+ // that strategy from `strategyDescriptions`. Without this, switching
+ // strategies briefly shows the previous strategy's content under the
+ // new strategy's heading.
+ const description =
+ strategy === entity.treatmentStrategy
+ ? (entity.treatmentStrategyDescription ?? '')
+ : (entity.strategyDescriptions?.[strategy] ?? '');
+ const isMitigate = strategy === RiskTreatmentType.mitigate;
+ const hasPlan = description.trim().length > 0;
+ const hasLinkedWork = entity.tasks.length > 0;
+ // Heuristic: tasks were auto-linked during onboarding but the AI plan
+ // hasn't filled in yet → mitigation is in flight (or just queued). Show
+ // a generating placeholder so the user knows the system is working
+ // rather than seeing an unexplained empty editor. Polling refreshes the
+ // entity; when description fills in, this condition naturally fails.
+ const isAutoMitigationInFlight =
+ isMitigate && hasLinkedWork && !hasPlan && !regenRun && !regenerating;
+
+ // While Mitigate has no linked work, render only the kick-off CTA panel —
+ // the user picks "Draft plan & suggest links" / "Suggest tasks & controls"
+ // or escapes to the editor via "Start from scratch" / "Edit plan manually".
+ // The editor only appears once linked work exists OR the user dismissed.
+ const [emptyDismissed, setEmptyDismissed] = useState(false);
+ useEffect(() => {
+ if (hasLinkedWork) setEmptyDismissed(false);
+ }, [hasLinkedWork]);
+
+ const showKickoff = isMitigate && !hasLinkedWork && !emptyDismissed;
+ const kickoffVariant: 'kickoff' | 'kickoff-with-plan' = hasPlan
+ ? 'kickoff-with-plan'
+ : 'kickoff';
+ // Linked Work is part of the audit trail regardless of strategy — Accept,
+ // Transfer, and Avoid all benefit from showing what evidence is connected
+ // to the risk (per ENG-221). The kickoff CTA above stays Mitigate-only
+ // because AI suggestion is a Mitigate-shaped flow; for other strategies
+ // users link manually via the Tasks UI.
+ const showLinkedWorkColumn = hasLinkedWork;
+
+ // For non-Mitigate strategies the "plan" is just rationale.
+ const planTitle = isMitigate ? 'Treatment plan' : 'Rationale';
+ const planSubtitle = isMitigate
+ ? 'A concrete plan for the strategy above.'
+ : 'Document why this strategy is right for this risk.';
+
+ return (
+
+
+
+
+ {/* 01 · Strategy */}
+
+
+
+
+
+ {/* 02 · Kickoff CTA when linked work is empty (covers both "fully
+ empty" and "plan exists, no links" cases) — editor stays hidden
+ until linked work appears OR the user dismisses. */}
+
+ Linked tasks were just attached during onboarding; the plan should appear in a moment.
+ You can keep navigating — we'll refresh this view automatically.
+