Rename vendors to third parties

Renames the user-facing 'vendor' concept to 'third party' across the
entire codebase. The shared common_third_parties reference table is
unchanged.

Migration. Renames the vendor_category enum, the vendors and
vendor_<entity> tables (contacts, services, compliance_reports,
business_associate_agreements, data_privacy_agreements,
risk_assessments) and their vendor_id columns, the asset_vendors /
data_vendors / processing_activity_vendors junction tables,
generated_documents.vendors_document_id, the webhook_event_type
'vendor:<verb>' values, and the snapshots_type 'VENDORS' value.

Backend. Renames coredata models and SQL queries, probo services,
GraphQL / MCP API surface, console / trust / webhook resolvers and
types, the CLI (prb vendor* -> prb third-party*; pkg/cmd/vendormgmt
-> pkg/cmd/thirdpartymgmt), the document generator, vetting agent
prompts, and the common-third-parties-import command.

Frontend, packages, n8n, e2e. Renames apps/console pages, components,
hooks, routes, dialogs, and tabs; the shared @probo/vendors package
(now @probo/third-parties); the @probo/ui Vendors atoms (now
ThirdParties, VendorLogo -> ThirdPartyLogo); the n8n community node
actions/vendor folder (now actions/thirdParty); and the e2e Go test
suite (console and MCP). Filesystem and URL paths use kebab-case
(third-parties), GraphQL fields and TypeScript identifiers use
camelCase (thirdParty / thirdParties), Go types use PascalCase
(ThirdParty), and human-facing text uses 'third party' with a space.

Co-authored-by: Bryan Frimin <bryan@getprobo.com>
Signed-off-by: Bryan Frimin <bryan@getprobo.com>
Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-05-13 16:15:33 +02:00
parent 9eed0d71c8
commit eecbe4c46c
281 changed files with 8491 additions and 8425 deletions

View File

@@ -18,7 +18,7 @@ import { useFragment } from "react-relay";
import { graphql } from "relay-runtime";
import { z } from "zod";
import type { useVendorFormFragment$key } from "#/__generated__/core/useVendorFormFragment.graphql";
import type { useThirdPartyFormFragment$key } from "#/__generated__/core/useThirdPartyFormFragment.graphql";
import { useFormWithSchema } from "../useFormWithSchema";
import { useMutationWithToasts } from "../useMutationWithToasts";
@@ -43,8 +43,8 @@ const schema = z.object({
securityOwnerId: z.string().nullish(),
});
const vendorFormFragment = graphql`
fragment useVendorFormFragment on Vendor {
const thirdPartyFormFragment = graphql`
fragment useThirdPartyFormFragment on ThirdParty {
id
name
description
@@ -70,46 +70,46 @@ const vendorFormFragment = graphql`
}
`;
const vendorUpdateQuery = graphql`
mutation useVendorFormMutation($input: UpdateVendorInput!) {
updateVendor(input: $input) {
vendor {
...useVendorFormFragment
const thirdPartyUpdateQuery = graphql`
mutation useThirdPartyFormMutation($input: UpdateThirdPartyInput!) {
updateThirdParty(input: $input) {
thirdParty {
...useThirdPartyFormFragment
}
}
}
`;
export function useVendorForm(vendorKey: useVendorFormFragment$key) {
const vendor = useFragment(vendorFormFragment, vendorKey);
export function useThirdPartyForm(thirdPartyKey: useThirdPartyFormFragment$key) {
const thirdParty = useFragment(thirdPartyFormFragment, thirdPartyKey);
const { __ } = useTranslate();
const [mutate] = useMutationWithToasts(vendorUpdateQuery, {
successMessage: __("Vendor updated successfully."),
errorMessage: __("Failed to update vendor"),
const [mutate] = useMutationWithToasts(thirdPartyUpdateQuery, {
successMessage: __("Third party updated successfully."),
errorMessage: __("Failed to update third party"),
});
const defaultValues = useMemo(
() => ({
name: vendor.name,
description: vendor.description || null,
category: vendor.category || null,
statusPageUrl: vendor.statusPageUrl || null,
termsOfServiceUrl: vendor.termsOfServiceUrl || null,
privacyPolicyUrl: vendor.privacyPolicyUrl || null,
serviceLevelAgreementUrl: vendor.serviceLevelAgreementUrl || null,
dataProcessingAgreementUrl: vendor.dataProcessingAgreementUrl || null,
websiteUrl: vendor.websiteUrl || null,
legalName: vendor.legalName || null,
headquarterAddress: vendor.headquarterAddress || null,
certifications: [...(vendor.certifications ?? [])],
countries: [...(vendor.countries ?? [])],
securityPageUrl: vendor.securityPageUrl || null,
trustPageUrl: vendor.trustPageUrl || null,
businessOwnerId: vendor.businessOwner?.id,
securityOwnerId: vendor.securityOwner?.id,
name: thirdParty.name,
description: thirdParty.description || null,
category: thirdParty.category || null,
statusPageUrl: thirdParty.statusPageUrl || null,
termsOfServiceUrl: thirdParty.termsOfServiceUrl || null,
privacyPolicyUrl: thirdParty.privacyPolicyUrl || null,
serviceLevelAgreementUrl: thirdParty.serviceLevelAgreementUrl || null,
dataProcessingAgreementUrl: thirdParty.dataProcessingAgreementUrl || null,
websiteUrl: thirdParty.websiteUrl || null,
legalName: thirdParty.legalName || null,
headquarterAddress: thirdParty.headquarterAddress || null,
certifications: [...(thirdParty.certifications ?? [])],
countries: [...(thirdParty.countries ?? [])],
securityPageUrl: thirdParty.securityPageUrl || null,
trustPageUrl: thirdParty.trustPageUrl || null,
businessOwnerId: thirdParty.businessOwner?.id,
securityOwnerId: thirdParty.securityOwner?.id,
}),
[vendor],
[thirdParty],
);
const form = useFormWithSchema(schema, {
@@ -120,7 +120,7 @@ export function useVendorForm(vendorKey: useVendorFormFragment$key) {
return mutate({
variables: {
input: {
id: vendor.id,
id: thirdParty.id,
...data,
description: data.description || null,
statusPageUrl: data.statusPageUrl || null,

View File

@@ -58,7 +58,7 @@ export const assetNodeQuery = graphql`
id
fullName
}
vendors(first: 50) {
thirdParties(first: 50) {
edges {
node {
id
@@ -94,7 +94,7 @@ export const createAssetMutation = graphql`
id
fullName
}
vendors(first: 50) {
thirdParties(first: 50) {
edges {
node {
id
@@ -125,7 +125,7 @@ export const updateAssetMutation = graphql`
id
fullName
}
vendors(first: 50) {
thirdParties(first: 50) {
edges {
node {
id
@@ -196,7 +196,7 @@ export const useCreateAsset = (connectionId: string) => {
assetType: AssetType;
ownerId: string;
organizationId: string;
vendorIds?: string[];
thirdPartyIds?: string[];
dataTypesStored: string;
}) => {
if (!input.name?.trim()) {
@@ -223,7 +223,7 @@ export const useCreateAsset = (connectionId: string) => {
dataTypesStored: input.dataTypesStored || "",
ownerId: input.ownerId,
organizationId: input.organizationId,
vendorIds: input.vendorIds || [],
thirdPartyIds: input.thirdPartyIds || [],
},
connections: [connectionId],
},
@@ -244,7 +244,7 @@ export const useUpdateAsset = () => {
assetType?: AssetType;
dataTypesStored?: string;
ownerId?: string;
vendorIds?: string[];
thirdPartyIds?: string[];
}) => {
if (!input.id) {
return alert(__("Failed to update asset: asset ID is required"));

View File

@@ -49,7 +49,7 @@ export const datumNodeQuery = graphql`
id
fullName
}
vendors(first: 50) {
thirdParties(first: 50) {
edges {
node {
id
@@ -86,7 +86,7 @@ export const createDatumMutation = graphql`
id
fullName
}
vendors(first: 50) {
thirdParties(first: 50) {
edges {
node {
id
@@ -115,7 +115,7 @@ export const updateDatumMutation = graphql`
id
fullName
}
vendors(first: 50) {
thirdParties(first: 50) {
edges {
node {
id
@@ -186,7 +186,7 @@ export const useCreateDatum = (connectionId: string) => {
dataClassification: string;
ownerId: string;
organizationId: string;
vendorIds?: string[];
thirdPartyIds?: string[];
}) => {
if (!input.name?.trim()) {
return alert(__("Failed to create data: name is required"));
@@ -217,7 +217,7 @@ export const useUpdateDatum = () => {
name?: string;
dataClassification?: string;
ownerId?: string;
vendorIds?: string[];
thirdPartyIds?: string[];
}) => {
if (!input.id) {
return alert(__("Failed to update data: missing id"));

View File

@@ -97,7 +97,7 @@ export const processingActivityNodeQuery = graphql`
id
fullName
}
vendors(first: 50) {
thirdParties(first: 50) {
edges {
node {
id
@@ -189,7 +189,7 @@ export const createProcessingActivityMutation = graphql`
id
fullName
}
vendors(first: 50) {
thirdParties(first: 50) {
edges {
node {
id
@@ -236,7 +236,7 @@ export const updateProcessingActivityMutation = graphql`
id
fullName
}
vendors(first: 50) {
thirdParties(first: 50) {
edges {
node {
id
@@ -322,7 +322,7 @@ export const useCreateProcessingActivity = (connectionId?: string) => {
nextReviewDate?: string;
role: string;
dataProtectionOfficerId?: string;
vendorIds?: string[];
thirdPartyIds?: string[];
}) => {
if (!input.organizationId) {
return alert(
@@ -359,7 +359,7 @@ export const useCreateProcessingActivity = (connectionId?: string) => {
nextReviewDate: input.nextReviewDate,
role: input.role,
dataProtectionOfficerId: input.dataProtectionOfficerId,
vendorIds: input.vendorIds,
thirdPartyIds: input.thirdPartyIds,
},
connections: connectionId ? [connectionId] : [],
},
@@ -393,7 +393,7 @@ export const useUpdateProcessingActivity = () => {
nextReviewDate?: string | null;
role?: string;
dataProtectionOfficerId?: string | null;
vendorIds?: string[];
thirdPartyIds?: string[];
}) => {
if (!input.id) {
return alert(__("Failed to update processing activity: ID is required"));

View File

@@ -0,0 +1,246 @@
// Copyright (c) 2025-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 { promisifyMutation, sprintf } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { useConfirm } from "@probo/ui";
import { useMemo } from "react";
import { useLazyLoadQuery, useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import type { ThirdPartyGraphCreateMutation } from "#/__generated__/core/ThirdPartyGraphCreateMutation.graphql";
import type { ThirdPartyGraphDeleteMutation } from "#/__generated__/core/ThirdPartyGraphDeleteMutation.graphql";
import type { ThirdPartyGraphSelectQuery } from "#/__generated__/core/ThirdPartyGraphSelectQuery.graphql";
import { useMutationWithToasts } from "../useMutationWithToasts";
/* eslint-disable relay/unused-fields, relay/must-colocate-fragment-spreads */
const createThirdPartyMutation = graphql`
mutation ThirdPartyGraphCreateMutation(
$input: CreateThirdPartyInput!
$connections: [ID!]!
) {
createThirdParty(input: $input) {
thirdPartyEdge @prependEdge(connections: $connections) {
node {
id
name
description
websiteUrl
createdAt
updatedAt
canUpdate: permission(action: "core:thirdParty:update")
canDelete: permission(action: "core:thirdParty:delete")
}
}
}
}
`;
export function useCreateThirdPartyMutation() {
const { __ } = useTranslate();
return useMutationWithToasts<ThirdPartyGraphCreateMutation>(
createThirdPartyMutation,
{
successMessage: __("Third party created successfully."),
errorMessage: __("Failed to create third party"),
},
);
}
const deleteThirdPartyMutation = graphql`
mutation ThirdPartyGraphDeleteMutation(
$input: DeleteThirdPartyInput!
$connections: [ID!]!
) {
deleteThirdParty(input: $input) {
deletedThirdPartyId @deleteEdge(connections: $connections)
}
}
`;
export const useDeleteThirdParty = (
thirdParty: { id?: string; name?: string },
connectionId: string,
) => {
const [mutate] = useMutation<ThirdPartyGraphDeleteMutation>(deleteThirdPartyMutation);
const confirm = useConfirm();
const { __ } = useTranslate();
return () => {
if (!thirdParty.id || !thirdParty.name) {
return alert(__("Failed to delete third party: missing id or name"));
}
confirm(
() =>
promisifyMutation(mutate)({
variables: {
input: {
thirdPartyId: thirdParty.id!,
},
connections: [connectionId],
},
}),
{
message: sprintf(
__(
"This will permanently delete thirdParty \"%s\". This action cannot be undone.",
),
thirdParty.name,
),
},
);
};
};
export const thirdPartyConnectionKey = "ThirdPartiesPage_thirdParties";
export const thirdPartiesQuery = graphql`
query ThirdPartyGraphListQuery($organizationId: ID!) {
node(id: $organizationId) {
... on Organization {
id
canCreateThirdParty: permission(action: "core:thirdParty:create")
canPublishThirdParty: permission(action: "core:thirdParty:publish")
thirdPartiesDocument {
id
currentPublishedMajor
currentPublishedMinor
defaultApprovers {
id
}
}
...ThirdPartyGraphPaginatedFragment
}
}
}
`;
export const paginatedThirdPartiesFragment = graphql`
fragment ThirdPartyGraphPaginatedFragment on Organization
@refetchable(queryName: "ThirdPartiesListQuery")
@argumentDefinitions(
first: { type: "Int", defaultValue: 50 }
order: { type: "ThirdPartyOrder", defaultValue: null }
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
) {
thirdParties(
first: $first
after: $after
last: $last
before: $before
orderBy: $order
) @connection(key: "ThirdPartiesListQuery_thirdParties") {
__id
edges {
node {
id
name
websiteUrl
updatedAt
riskAssessments(
first: 1
orderBy: { direction: DESC, field: CREATED_AT }
) {
edges {
node {
id
createdAt
expiresAt
dataSensitivity
businessImpact
}
}
}
canUpdate: permission(action: "core:thirdParty:update")
canDelete: permission(action: "core:thirdParty:delete")
}
}
}
}
`;
export const thirdPartyNodeQuery = graphql`
query ThirdPartyGraphNodeQuery($thirdPartyId: ID!) {
node(id: $thirdPartyId) {
id
... on ThirdParty {
name
websiteUrl
canAssess: permission(action: "core:thirdParty:assess")
canUpdate: permission(action: "core:thirdParty:update")
canDelete: permission(action: "core:thirdParty:delete")
canUploadComplianceReport: permission(
action: "core:thirdParty-compliance-report:upload"
)
canCreateRiskAssessment: permission(
action: "core:thirdParty-risk-assessment:create"
)
canCreateContact: permission(action: "core:thirdParty-contact:create")
canCreateService: permission(action: "core:thirdParty-service:create")
canUploadBAA: permission(
action: "core:thirdParty-business-associate-agreement:upload"
)
canUploadDPA: permission(
action: "core:thirdParty-data-privacy-agreement:upload"
)
...useThirdPartyFormFragment
...ThirdPartyComplianceTabFragment
...ThirdPartyContactsTabFragment
...ThirdPartyServicesTabFragment
...ThirdPartyRiskAssessmentTabFragment
...ThirdPartyOverviewTabBusinessAssociateAgreementFragment
...ThirdPartyOverviewTabDataPrivacyAgreementFragment
}
}
viewer {
id
}
}
`;
export const thirdPartiesSelectQuery = graphql`
query ThirdPartyGraphSelectQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
... on Organization {
thirdParties(first: 100, orderBy: { direction: ASC, field: NAME }) {
edges {
node {
id
name
websiteUrl
}
}
}
}
}
}
`;
export function useThirdParties(organizationId: string) {
const data = useLazyLoadQuery<ThirdPartyGraphSelectQuery>(
thirdPartiesSelectQuery,
{
organizationId: organizationId,
},
{ fetchPolicy: "network-only" },
);
return useMemo(() => {
return data.organization?.thirdParties?.edges.map(edge => edge.node) ?? [];
}, [data]);
}

View File

@@ -1,246 +0,0 @@
// Copyright (c) 2025-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 { promisifyMutation, sprintf } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { useConfirm } from "@probo/ui";
import { useMemo } from "react";
import { useLazyLoadQuery, useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import type { VendorGraphCreateMutation } from "#/__generated__/core/VendorGraphCreateMutation.graphql";
import type { VendorGraphDeleteMutation } from "#/__generated__/core/VendorGraphDeleteMutation.graphql";
import type { VendorGraphSelectQuery } from "#/__generated__/core/VendorGraphSelectQuery.graphql";
import { useMutationWithToasts } from "../useMutationWithToasts";
/* eslint-disable relay/unused-fields, relay/must-colocate-fragment-spreads */
const createVendorMutation = graphql`
mutation VendorGraphCreateMutation(
$input: CreateVendorInput!
$connections: [ID!]!
) {
createVendor(input: $input) {
vendorEdge @prependEdge(connections: $connections) {
node {
id
name
description
websiteUrl
createdAt
updatedAt
canUpdate: permission(action: "core:vendor:update")
canDelete: permission(action: "core:vendor:delete")
}
}
}
}
`;
export function useCreateVendorMutation() {
const { __ } = useTranslate();
return useMutationWithToasts<VendorGraphCreateMutation>(
createVendorMutation,
{
successMessage: __("Vendor created successfully."),
errorMessage: __("Failed to create vendor"),
},
);
}
const deleteVendorMutation = graphql`
mutation VendorGraphDeleteMutation(
$input: DeleteVendorInput!
$connections: [ID!]!
) {
deleteVendor(input: $input) {
deletedVendorId @deleteEdge(connections: $connections)
}
}
`;
export const useDeleteVendor = (
vendor: { id?: string; name?: string },
connectionId: string,
) => {
const [mutate] = useMutation<VendorGraphDeleteMutation>(deleteVendorMutation);
const confirm = useConfirm();
const { __ } = useTranslate();
return () => {
if (!vendor.id || !vendor.name) {
return alert(__("Failed to delete vendor: missing id or name"));
}
confirm(
() =>
promisifyMutation(mutate)({
variables: {
input: {
vendorId: vendor.id!,
},
connections: [connectionId],
},
}),
{
message: sprintf(
__(
"This will permanently delete vendor \"%s\". This action cannot be undone.",
),
vendor.name,
),
},
);
};
};
export const vendorConnectionKey = "VendorsPage_vendors";
export const vendorsQuery = graphql`
query VendorGraphListQuery($organizationId: ID!) {
node(id: $organizationId) {
... on Organization {
id
canCreateVendor: permission(action: "core:vendor:create")
canPublishVendor: permission(action: "core:vendor:publish")
vendorsDocument {
id
currentPublishedMajor
currentPublishedMinor
defaultApprovers {
id
}
}
...VendorGraphPaginatedFragment
}
}
}
`;
export const paginatedVendorsFragment = graphql`
fragment VendorGraphPaginatedFragment on Organization
@refetchable(queryName: "VendorsListQuery")
@argumentDefinitions(
first: { type: "Int", defaultValue: 50 }
order: { type: "VendorOrder", defaultValue: null }
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
) {
vendors(
first: $first
after: $after
last: $last
before: $before
orderBy: $order
) @connection(key: "VendorsListQuery_vendors") {
__id
edges {
node {
id
name
websiteUrl
updatedAt
riskAssessments(
first: 1
orderBy: { direction: DESC, field: CREATED_AT }
) {
edges {
node {
id
createdAt
expiresAt
dataSensitivity
businessImpact
}
}
}
canUpdate: permission(action: "core:vendor:update")
canDelete: permission(action: "core:vendor:delete")
}
}
}
}
`;
export const vendorNodeQuery = graphql`
query VendorGraphNodeQuery($vendorId: ID!) {
node(id: $vendorId) {
id
... on Vendor {
name
websiteUrl
canAssess: permission(action: "core:vendor:assess")
canUpdate: permission(action: "core:vendor:update")
canDelete: permission(action: "core:vendor:delete")
canUploadComplianceReport: permission(
action: "core:vendor-compliance-report:upload"
)
canCreateRiskAssessment: permission(
action: "core:vendor-risk-assessment:create"
)
canCreateContact: permission(action: "core:vendor-contact:create")
canCreateService: permission(action: "core:vendor-service:create")
canUploadBAA: permission(
action: "core:vendor-business-associate-agreement:upload"
)
canUploadDPA: permission(
action: "core:vendor-data-privacy-agreement:upload"
)
...useVendorFormFragment
...VendorComplianceTabFragment
...VendorContactsTabFragment
...VendorServicesTabFragment
...VendorRiskAssessmentTabFragment
...VendorOverviewTabBusinessAssociateAgreementFragment
...VendorOverviewTabDataPrivacyAgreementFragment
}
}
viewer {
id
}
}
`;
export const vendorsSelectQuery = graphql`
query VendorGraphSelectQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
... on Organization {
vendors(first: 100, orderBy: { direction: ASC, field: NAME }) {
edges {
node {
id
name
websiteUrl
}
}
}
}
}
}
`;
export function useVendors(organizationId: string) {
const data = useLazyLoadQuery<VendorGraphSelectQuery>(
vendorsSelectQuery,
{
organizationId: organizationId,
},
{ fetchPolicy: "network-only" },
);
return useMemo(() => {
return data.organization?.vendors?.edges.map(edge => edge.node) ?? [];
}, [data]);
}