Add vendor publish to document system

Replace the old snapshot-based system for vendors with the publish
document system, mirroring the prior processing activity / DPIA / TIA
migration. Includes the GraphQL mutation, MCP tool, CLI command, n8n
operation, frontend publish dialog, e2e tests, and a prosemirror
register template covering vendor profile fields plus per-vendor
sections for services, contacts, risk assessments, compliance reports,
BAA and DPA agreements.

The vendor register lives as a generated DocumentTypeRegister document
on the organization, reused across publishes (the major version bumps
on every republish). Approvers can be passed in to create a draft
pending approval; otherwise the version is published immediately. The
frontend Vendors page exposes a Publish button and a Document link
button when the document exists, and pre-fills the previous default
approvers.

Remove snapshot mode entirely from vendors and their sub-entities: drop
snapshotId/sourceId from GraphQL Vendor type and VendorFilter; remove
SnapshotsTypeVendors from the snapshot registry and delete
Vendors.Snapshot, VendorSnapshotter interface and all
*.InsertVendorSnapshots methods on contacts, services, risk
assessments, compliance reports, BAA and DPA. Drop the snapshot routes
and banner from the frontend. The snapshot_id columns remain in the
database but are now filtered out with snapshot_id IS NULL.

Add Get/Upsert/Clear GeneratedDocumentID methods on Vendor backed by a
new vendors_document_id column on generated_documents, matching the
ProcessingActivity/Finding/Obligation pattern.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-04-28 18:54:09 +02:00
parent 5629c8ccc0
commit c026f67bd9
40 changed files with 3144 additions and 792 deletions

View File

@@ -109,12 +109,21 @@ export const useDeleteVendor = (
export const vendorConnectionKey = "VendorsPage_vendors";
export const vendorsQuery = graphql`
query VendorGraphListQuery($organizationId: ID!, $snapshotId: ID) {
query VendorGraphListQuery($organizationId: ID!) {
node(id: $organizationId) {
... on Organization {
id
canCreateVendor: permission(action: "core:vendor:create")
...VendorGraphPaginatedFragment @arguments(snapshotId: $snapshotId)
canPublishVendor: permission(action: "core:vendor:publish")
vendorsDocument {
id
currentPublishedMajor
currentPublishedMinor
defaultApprovers {
id
}
}
...VendorGraphPaginatedFragment
}
}
}
@@ -129,7 +138,6 @@ export const paginatedVendorsFragment = graphql`
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
snapshotId: { type: "ID", defaultValue: null }
) {
vendors(
first: $first
@@ -137,13 +145,11 @@ export const paginatedVendorsFragment = graphql`
last: $last
before: $before
orderBy: $order
filter: { snapshotId: $snapshotId }
) @connection(key: "VendorsListQuery_vendors", filters: ["filter"]) {
) @connection(key: "VendorsListQuery_vendors") {
__id
edges {
node {
id
snapshotId
name
websiteUrl
updatedAt
@@ -174,7 +180,6 @@ export const vendorNodeQuery = graphql`
node(id: $vendorId) {
id
... on Vendor {
snapshotId
name
websiteUrl
canAssess: permission(action: "core:vendor:assess")

View File

@@ -12,7 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { faviconUrl, validateSnapshotConsistency } from "@probo/helpers";
import { faviconUrl } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
ActionDropdown,
@@ -31,11 +31,10 @@ import {
useFragment,
usePreloadedQuery,
} from "react-relay";
import { Outlet, useParams } from "react-router";
import { Outlet } from "react-router";
import type { VendorComplianceTabFragment$key } from "#/__generated__/core/VendorComplianceTabFragment.graphql";
import type { VendorGraphNodeQuery } from "#/__generated__/core/VendorGraphNodeQuery.graphql";
import { SnapshotBanner } from "#/components/SnapshotBanner";
import {
useDeleteVendor,
vendorConnectionKey,
@@ -54,10 +53,7 @@ export default function VendorDetailPage(props: Props) {
const { node: vendor } = usePreloadedQuery(vendorNodeQuery, props.queryRef);
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
validateSnapshotConsistency(vendor, snapshotId);
const deleteVendor = useDeleteVendor(
vendor,
ConnectionHandler.getConnectionID(organizationId, vendorConnectionKey),
@@ -68,19 +64,13 @@ export default function VendorDetailPage(props: Props) {
vendor as VendorComplianceTabFragment$key,
).complianceReports.edges.length;
const vendorsUrl
= isSnapshotMode && snapshotId
? `/organizations/${organizationId}/snapshots/${snapshotId}/vendors`
: `/organizations/${organizationId}/vendors`;
const vendorsUrl = `/organizations/${organizationId}/vendors`;
const baseVendorUrl
= isSnapshotMode && snapshotId
? `/organizations/${organizationId}/snapshots/${snapshotId}/vendors/${vendor.id}`
: `/organizations/${organizationId}/vendors/${vendor.id}`;
= `/organizations/${organizationId}/vendors/${vendor.id}`;
return (
<div className="space-y-6">
{snapshotId && <SnapshotBanner snapshotId={snapshotId} />}
<Breadcrumb
items={[
{
@@ -103,28 +93,26 @@ export default function VendorDetailPage(props: Props) {
)}
<div className="text-2xl">{vendor.name}</div>
</div>
{!isSnapshotMode && (
<div className="flex gap-2 items-center">
{vendor.canAssess && (
<ImportAssessmentDialog vendorId={vendor.id}>
<Button icon={IconPageTextLine} variant="secondary">
{__("Assessment From Website")}
</Button>
</ImportAssessmentDialog>
)}
{vendor.canDelete && (
<ActionDropdown variant="secondary">
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={deleteVendor}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
)}
</div>
)}
<div className="flex gap-2 items-center">
{vendor.canAssess && (
<ImportAssessmentDialog vendorId={vendor.id}>
<Button icon={IconPageTextLine} variant="secondary">
{__("Assessment From Website")}
</Button>
</ImportAssessmentDialog>
)}
{vendor.canDelete && (
<ActionDropdown variant="secondary">
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={deleteVendor}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
)}
</div>
</div>
<Tabs>

View File

@@ -20,8 +20,10 @@ import {
Avatar,
Button,
DropdownItem,
IconPageTextLine,
IconPlusLarge,
IconTrashCan,
IconUpload,
PageHeader,
RiskBadge,
Tbody,
@@ -35,14 +37,13 @@ import {
usePaginationFragment,
usePreloadedQuery,
} from "react-relay";
import { useParams } from "react-router";
import { useNavigate } from "react-router";
import type { VendorGraphListQuery } from "#/__generated__/core/VendorGraphListQuery.graphql";
import type {
VendorGraphPaginatedFragment$data,
VendorGraphPaginatedFragment$key,
} from "#/__generated__/core/VendorGraphPaginatedFragment.graphql";
import { SnapshotBanner } from "#/components/SnapshotBanner";
import { SortableTable, SortableTh } from "#/components/SortableTable";
import {
paginatedVendorsFragment,
@@ -53,6 +54,7 @@ import { useOrganizationId } from "#/hooks/useOrganizationId";
import type { NodeOf } from "#/types";
import { CreateVendorDialog } from "./dialogs/CreateVendorDialog";
import { PublishVendorListDialog } from "./dialogs/PublishVendorListDialog";
type Vendor = NodeOf<VendorGraphPaginatedFragment$data["vendors"]>;
@@ -63,8 +65,7 @@ type Props = {
export default function VendorsPage(props: Props) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
const navigate = useNavigate();
const data = usePreloadedQuery(vendorsQuery, props.queryRef);
// eslint-disable-next-line relay/generated-typescript-types
@@ -79,26 +80,54 @@ export default function VendorsPage(props: Props) {
usePageTitle(__("Vendors"));
const hasAnyAction
= !isSnapshotMode
&& vendors.some(({ canUpdate, canDelete }) => canUpdate || canDelete);
= vendors.some(({ canUpdate, canDelete }) => canUpdate || canDelete);
const vendorsDocument = data.node?.vendorsDocument;
const defaultApproverIds
= vendorsDocument?.defaultApprovers?.map(a => a.id) ?? [];
return (
<div className="space-y-6">
{snapshotId && <SnapshotBanner snapshotId={snapshotId} />}
<PageHeader
title={__("Vendors")}
description={__(
"Vendors are third-party services that your company uses. Add them to keep track of their risk and compliance status.",
)}
>
{!isSnapshotMode && data.node.canCreateVendor && (
<CreateVendorDialog
connection={connectionId}
organizationId={organizationId}
>
<Button icon={IconPlusLarge}>{__("Add vendor")}</Button>
</CreateVendorDialog>
)}
<div className="flex gap-2">
{vendorsDocument && (
<Button
variant="secondary"
icon={IconPageTextLine}
onClick={() => void navigate(
`/organizations/${organizationId}/documents/${vendorsDocument.id}`,
)}
>
{__("Document")}
</Button>
)}
{data.node.canPublishVendor && (
<PublishVendorListDialog
organizationId={organizationId}
defaultApproverIds={defaultApproverIds}
onPublished={documentId => void navigate(
`/organizations/${organizationId}/documents/${documentId}`,
)}
>
<Button variant="secondary" icon={IconUpload}>
{__("Publish")}
</Button>
</PublishVendorListDialog>
)}
{data.node.canCreateVendor && (
<CreateVendorDialog
connection={connectionId}
organizationId={organizationId}
>
<Button icon={IconPlusLarge}>{__("Add vendor")}</Button>
</CreateVendorDialog>
)}
</div>
</PageHeader>
<SortableTable {...pagination}>
<Thead>
@@ -137,16 +166,11 @@ function VendorRow({
connectionId: string;
hasAnyAction: boolean;
}) {
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
const { __ } = useTranslate();
const latestAssessment = vendor.riskAssessments?.edges[0]?.node;
const deleteVendor = useDeleteVendor(vendor, connectionId);
const vendorUrl
= isSnapshotMode && snapshotId
? `/organizations/${organizationId}/snapshots/${snapshotId}/vendors/${vendor.id}/overview`
: `/organizations/${organizationId}/vendors/${vendor.id}/overview`;
const vendorUrl = `/organizations/${organizationId}/vendors/${vendor.id}/overview`;
return (
<>

View File

@@ -0,0 +1,160 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// 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,
Dialog,
DialogContent,
DialogFooter,
IconSend,
IconUpload,
useDialogRef,
useToast,
} from "@probo/ui";
import type { ReactNode } from "react";
import { useMemo } from "react";
import { useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import { z } from "zod";
import type { PublishVendorListDialogMutation } from "#/__generated__/core/PublishVendorListDialogMutation.graphql";
import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
const publishMutation = graphql`
mutation PublishVendorListDialogMutation(
$input: PublishVendorListInput!
) {
publishVendorList(input: $input) {
documentEdge {
node {
id
}
}
}
}
`;
type Props = {
children: ReactNode;
organizationId: string;
defaultApproverIds?: string[];
onPublished?: (documentId: string) => void;
};
export function PublishVendorListDialog({
children,
organizationId,
defaultApproverIds,
onPublished,
}: Props) {
const { __ } = useTranslate();
const { toast } = useToast();
const dialogRef = useDialogRef();
const schema = useMemo(() =>
z.object({
approverIds: z.array(z.string()),
}), []);
const {
control,
handleSubmit,
reset,
watch,
} = useFormWithSchema(schema, {
defaultValues: {
approverIds: defaultApproverIds ?? [],
},
});
const [publish, isPublishing]
= useMutation<PublishVendorListDialogMutation>(publishMutation);
const approverIds = watch("approverIds");
const hasApprovers = approverIds.length > 0;
const onSubmit = (data: z.infer<typeof schema>) => {
publish({
variables: {
input: {
organizationId,
approverIds: data.approverIds.length > 0 ? data.approverIds : undefined,
},
},
onCompleted(response) {
const documentId = response.publishVendorList?.documentEdge?.node?.id;
if (documentId) {
toast({
title: __("Success"),
description: hasApprovers
? __("Approval requested successfully.")
: __("Vendors published successfully."),
variant: "success",
});
dialogRef.current?.close();
reset();
onPublished?.(documentId);
}
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to publish vendors"),
error as GraphQLError,
),
variant: "error",
});
},
});
};
return (
<Dialog
className="max-w-xl"
ref={dialogRef}
trigger={children}
title={__("Publish Vendors")}
>
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
<DialogContent padded>
<div className="space-y-4">
<p className="text-sm text-txt-secondary">
{__("Select approvers to request approval before publishing, or publish directly without approvers.")}
</p>
<PeopleMultiSelectField
name="approverIds"
label={__("Approvers")}
control={control}
organizationId={organizationId}
placeholder={__("Add approvers...")}
/>
</div>
</DialogContent>
<DialogFooter>
<Button
type="submit"
icon={hasApprovers ? IconSend : IconUpload}
disabled={isPublishing}
>
{hasApprovers ? __("Request approval") : __("Publish")}
</Button>
</DialogFooter>
</form>
</Dialog>
);
}

View File

@@ -34,20 +34,6 @@ export const vendorRoutes = [
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery<VendorGraphListQuery>(coreEnvironment, vendorsQuery, {
organizationId: organizationId,
snapshotId: null,
}),
),
Component: withQueryRef(
lazy(() => import("#/pages/organizations/vendors/VendorsPage")),
),
},
{
path: "snapshots/:snapshotId/vendors",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId, snapshotId }) =>
loadQuery<VendorGraphListQuery>(coreEnvironment, vendorsQuery, {
organizationId: organizationId,
snapshotId,
}),
),
Component: withQueryRef(
@@ -113,63 +99,4 @@ export const vendorRoutes = [
},
],
},
{
path: "snapshots/:snapshotId/vendors/:vendorId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ vendorId }) =>
loadQuery<VendorGraphNodeQuery>(coreEnvironment, vendorNodeQuery, {
vendorId: vendorId,
}),
),
Component: withQueryRef(
lazy(() => import("../pages/organizations/vendors/VendorDetailPage")),
),
children: [
{
path: "overview",
Fallback: LinkCardSkeleton,
Component: lazy(
() => import("../pages/organizations/vendors/tabs/VendorOverviewTab"),
),
},
{
path: "certifications",
Fallback: LinkCardSkeleton,
Component: lazy(
() =>
import("../pages/organizations/vendors/tabs/VendorCertificationsTab"),
),
},
{
path: "compliance",
Fallback: LinkCardSkeleton,
Component: lazy(
() =>
import("../pages/organizations/vendors/tabs/VendorComplianceTab"),
),
},
{
path: "risks",
Fallback: LinkCardSkeleton,
Component: lazy(
() =>
import("../pages/organizations/vendors/tabs/VendorRiskAssessmentTab"),
),
},
{
path: "contacts",
Fallback: LinkCardSkeleton,
Component: lazy(
() => import("../pages/organizations/vendors/tabs/VendorContactsTab"),
),
},
{
path: "services",
Fallback: LinkCardSkeleton,
Component: lazy(
() => import("../pages/organizations/vendors/tabs/VendorServicesTab"),
),
},
],
},
] satisfies AppRoute[];