Add async third-party vetting

Queue vetting on third_parties with PENDING, PROCESSING,
COMPLETED, and FAILED states. Expose enqueue and status through
GraphQL, MCP, CLI, and n8n, validate vet requests, tune the
worker via config, and poll the detail page while vetting runs.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-05-27 13:34:49 +02:00
parent 1a71d15bc5
commit 6e7c96732f
59 changed files with 3251 additions and 520 deletions

View File

@@ -26,13 +26,16 @@ import {
TabLink,
Tabs,
} from "@probo/ui";
import { useEffect, useRef } from "react";
import {
ConnectionHandler,
type PreloadedQuery,
useFragment,
usePreloadedQuery,
useRelayEnvironment,
} from "react-relay";
import { Outlet } from "react-router";
import { fetchQuery } from "relay-runtime";
import type { ThirdPartyComplianceTabFragment$key } from "#/__generated__/core/ThirdPartyComplianceTabFragment.graphql";
import type { ThirdPartyGraphNodeQuery } from "#/__generated__/core/ThirdPartyGraphNodeQuery.graphql";
@@ -43,7 +46,7 @@ import {
} from "#/hooks/graph/ThirdPartyGraph";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { ImportAssessmentDialog } from "./dialogs/ImportAssessmentDialog";
import { VettingDialog } from "./dialogs/VettingDialog";
import { measuresFragment } from "./measures/ThirdPartyMeasuresPage";
import { complianceReportsFragment } from "./tabs/ThirdPartyComplianceTab";
@@ -54,9 +57,34 @@ type Props = {
};
export default function ThirdPartyDetailPage(props: Props) {
const environment = useRelayEnvironment();
const { node: thirdParty } = usePreloadedQuery(thirdPartyNodeQuery, props.queryRef);
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const thirdPartyIdRef = useRef(thirdParty.id);
useEffect(() => {
thirdPartyIdRef.current = thirdParty.id;
}, [thirdParty.id]);
const isVetting = thirdParty.vettingStatus === "PENDING" || thirdParty.vettingStatus === "PROCESSING";
useEffect(() => {
if (!isVetting) return;
const interval = setInterval(() => {
if (document.hidden) return;
fetchQuery<ThirdPartyGraphNodeQuery>(
environment,
thirdPartyNodeQuery,
{ thirdPartyId: thirdPartyIdRef.current },
{ fetchPolicy: "network-only" },
).subscribe({});
}, 5000);
return () => clearInterval(interval);
}, [isVetting, environment]);
const deleteThirdParty = useDeleteThirdParty(
thirdParty,
@@ -74,8 +102,24 @@ export default function ThirdPartyDetailPage(props: Props) {
const baseThirdPartyUrl
= `/organizations/${organizationId}/third-parties/${thirdParty.id}`;
const isVettingFailed = thirdParty.vettingStatus === "FAILED";
return (
<div className="space-y-6">
{isVetting && (
<div className="flex items-center gap-3 rounded-lg bg-warning px-4 py-3 text-sm text-txt-warning">
<div
aria-hidden
className="size-4 shrink-0 animate-spin rounded-full border-2 border-border-warning/30 border-t-border-warning"
/>
{__("Vetting is in progress. Results will appear once the analysis is complete.")}
</div>
)}
{isVettingFailed && (
<div className="rounded-lg bg-danger px-4 py-3 text-sm text-txt-danger">
{__("Vetting failed. You can start vetting again.")}
</div>
)}
<Breadcrumb
items={[
{
@@ -104,12 +148,15 @@ export default function ThirdPartyDetailPage(props: Props) {
</div>
</div>
<div className="flex gap-2 items-center">
{thirdParty.canAssess && (
<ImportAssessmentDialog thirdPartyId={thirdParty.id}>
{thirdParty.canVet && !isVetting && (
<VettingDialog
thirdPartyId={thirdParty.id}
websiteUrl={thirdParty.websiteUrl}
>
<Button icon={IconPageTextLine} variant="secondary">
{__("Assessment From Website")}
{__("Start Vetting")}
</Button>
</ImportAssessmentDialog>
</VettingDialog>
)}
{thirdParty.canDelete && (
<ActionDropdown variant="secondary">

View File

@@ -12,6 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { formatError, type GraphQLError } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Button,
@@ -20,25 +21,28 @@ import {
DialogFooter,
Field,
useDialogRef,
useToast,
} from "@probo/ui";
import type { ReactNode } from "react";
import { useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import { z } from "zod";
import type { VettingDialogMutation } from "#/__generated__/core/VettingDialogMutation.graphql";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
const schema = z.object({
url: z.string().url(),
});
const importAssessmentMutation = graphql`
mutation ImportAssessmentDialogMutation($input: AssessThirdPartyInput!) {
assessThirdParty(input: $input) {
const vetMutation = graphql`
mutation VettingDialogMutation($input: VetThirdPartyInput!) {
vetThirdParty(input: $input) {
thirdParty {
id
name
websiteUrl
vettingStatus
...useThirdPartyFormFragment
...ThirdPartyComplianceTabFragment
...ThirdPartyRiskAssessmentTabFragment
@@ -47,42 +51,64 @@ const importAssessmentMutation = graphql`
}
`;
type Props = {
interface VettingDialogProps {
thirdPartyId: string;
websiteUrl?: string | null;
children: ReactNode;
};
}
export function ImportAssessmentDialog({ thirdPartyId, children }: Props) {
export function VettingDialog({ thirdPartyId, websiteUrl, children }: VettingDialogProps) {
const { __ } = useTranslate();
const { toast } = useToast();
const dialogRef = useDialogRef();
const { register, handleSubmit, reset, formState } = useFormWithSchema(
schema,
{
defaultValues: {
url: "",
url: websiteUrl ?? "",
},
},
);
const [assess, isAssessing] = useMutationWithToasts(
importAssessmentMutation,
{
successMessage: __("Third party assessed successfully."),
errorMessage: __("Failed to assess third party"),
},
);
const [vet, isVetting] = useMutation<VettingDialogMutation>(vetMutation);
const onSubmit = async (data: z.infer<typeof schema>) => {
await assess({
const onSubmit = (data: z.infer<typeof schema>) => {
vet({
variables: {
input: {
id: thirdPartyId,
websiteUrl: data.url,
},
},
onSuccess: () => {
onCompleted(_, errors) {
if (errors?.length) {
toast({
title: __("Error"),
description: formatError(
__("Failed to start vetting."),
errors as GraphQLError[],
),
variant: "error",
});
return;
}
toast({
title: __("Success"),
description: __("The third party is being vetted in the background."),
variant: "success",
});
dialogRef.current?.close();
reset();
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to start vetting."),
error as GraphQLError,
),
variant: "error",
});
},
});
};
@@ -90,22 +116,22 @@ export function ImportAssessmentDialog({ thirdPartyId, children }: Props) {
<Dialog
ref={dialogRef}
trigger={children}
title={__("Assessment from website")}
title={__("Start Vetting")}
className="max-w-lg"
>
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
<DialogContent padded>
<Field
required
label={__("URL")}
label={__("Website URL")}
type="text"
{...register("url")}
error={formState.errors.url?.message}
/>
</DialogContent>
<DialogFooter>
<Button type="submit" disabled={isAssessing}>
{__("Assess")}
<Button type="submit" disabled={isVetting}>
{__("Start Vetting")}
</Button>
</DialogFooter>
</form>

View File

@@ -214,7 +214,9 @@ function AssessmentRow(props: AssessmentRowProps) {
{__("Notes")}
:
</div>
<p className="text-txt-secondary">{assessment.notes}</p>
<p className="text-sm text-txt-secondary whitespace-pre-wrap">
{assessment.notes}
</p>
</div>
</Td>
</Tr>