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

@@ -21,7 +21,7 @@
"@probo/relay": "^1.0.0",
"@probo/routes": "^1.0.0",
"@probo/ui": "1.0.0",
"@probo/vendors": "0.0.1",
"@probo/third-parties": "0.0.1",
"@tanstack/react-query": "^5.76.1",
"clsx": "^2.1.1",
"react": "^19.1.0",

View File

@@ -48,7 +48,7 @@ import { useOrganizationId } from "#/hooks/useOrganizationId";
import { EditableTable } from "../table/EditableTable";
import { PeopleCell } from "../table/PeopleCell";
import { VendorsCell } from "../table/VendorsCell";
import { ThirdPartiesCell } from "../table/ThirdPartiesCell";
type Props = {
connectionId: string;
@@ -65,7 +65,7 @@ const schema = z.object({
amount: z.coerce.number().min(1, "Amount is required"),
assetType: z.enum(["PHYSICAL", "VIRTUAL"]),
ownerId: z.string().trim().min(1, "Owner is required"),
vendorIds: z.array(z.string()).optional(),
thirdPartyIds: z.array(z.string()).optional(),
dataTypesStored: z.string().trim().min(1, "Data types stored is required"),
organizationId: z.string().trim().min(1, "Organization is required"),
});
@@ -75,7 +75,7 @@ const defaultValue = {
amount: 0,
assetType: "VIRTUAL",
ownerId: "",
vendorIds: [],
thirdPartyIds: [],
dataTypesStored: "",
organizationId: "",
} satisfies z.infer<typeof schema>;
@@ -99,7 +99,7 @@ export function AssetsTable(props: Props) {
__("Data Types stored"),
__("Amount"),
__("Owner"),
__("Vendors"),
__("Third parties"),
]}
schema={schema}
updateMutation={updateAssetMutation}
@@ -154,10 +154,10 @@ export function AssetsTable(props: Props) {
defaultValue={item?.owner}
organizationId={organizationId}
/>
<VendorsCell
name="vendorIds"
<ThirdPartiesCell
name="thirdPartyIds"
organizationId={organizationId}
defaultValue={item?.vendors?.edges?.map(edge => edge.node) ?? []}
defaultValue={item?.thirdParties?.edges?.map(edge => edge.node) ?? []}
/>
</>
)}

View File

@@ -50,7 +50,7 @@ export function ReadOnlyAssetsTable(props: Props) {
<Th>{__("Type")}</Th>
<Th>{__("Amount")}</Th>
<Th>{__("Owner")}</Th>
<Th>{__("Vendors")}</Th>
<Th>{__("Third parties")}</Th>
</Tr>
</Thead>
<Tbody>
@@ -65,7 +65,7 @@ export function ReadOnlyAssetsTable(props: Props) {
function AssetRow({ entry }: { entry: AssetEntry }) {
const organizationId = useOrganizationId();
const { __ } = useTranslate();
const vendors = entry.vendors?.edges.map(edge => edge.node) ?? [];
const thirdParties = entry.thirdParties?.edges.map(edge => edge.node) ?? [];
return (
<Tr to={`/organizations/${organizationId}/assets/${entry.id}`}>
@@ -78,27 +78,27 @@ function AssetRow({ entry }: { entry: AssetEntry }) {
<Td>{entry.amount}</Td>
<Td>{entry.owner?.fullName ?? __("Unassigned")}</Td>
<Td>
{vendors.length > 0
{thirdParties.length > 0
? (
<div className="flex flex-wrap gap-1">
{vendors.slice(0, 3).map(vendor => (
{thirdParties.slice(0, 3).map(thirdParty => (
<Badge
key={vendor.id}
key={thirdParty.id}
variant="neutral"
className="flex items-center gap-1"
>
<Avatar
name={vendor.name}
src={faviconUrl(vendor.websiteUrl)}
name={thirdParty.name}
src={faviconUrl(thirdParty.websiteUrl)}
size="s"
/>
<span className="text-xs">{vendor.name}</span>
<span className="text-xs">{thirdParty.name}</span>
</Badge>
))}
{vendors.length > 3 && (
{thirdParties.length > 3 && (
<Badge variant="neutral" className="text-xs">
+
{vendors.length - 3}
{thirdParties.length - 3}
</Badge>
)}
</div>

View File

@@ -18,9 +18,9 @@ import { Avatar, Badge, Button, Field, IconCrossLargeX, Option, Select } from "@
import { type ComponentProps, Suspense, useState } from "react";
import { type Control, Controller, type FieldValues, type Path } from "react-hook-form";
import { useVendors } from "#/hooks/graph/VendorGraph";
import { useThirdParties } from "#/hooks/graph/ThirdPartyGraph";
type Vendor = {
type ThirdParty = {
id: string;
name: string;
websiteUrl: string | null | undefined;
@@ -32,13 +32,13 @@ type Props<T extends FieldValues = FieldValues> = {
name: string;
label?: string;
error?: string;
selectedVendors?: Vendor[];
selectedThirdParties?: ThirdParty[];
} & ComponentProps<typeof Field>;
export function VendorsMultiSelectField<T extends FieldValues = FieldValues>({
export function ThirdPartiesMultiSelectField<T extends FieldValues = FieldValues>({
organizationId,
control,
selectedVendors = [],
selectedThirdParties = [],
...props
}: Props<T>) {
return (
@@ -46,31 +46,31 @@ export function VendorsMultiSelectField<T extends FieldValues = FieldValues>({
<Suspense
fallback={<Select variant="editor" disabled placeholder="Loading..." />}
>
<VendorsMultiSelectWithQuery
<ThirdPartiesMultiSelectWithQuery
organizationId={organizationId}
control={control}
name={props.name}
disabled={props.disabled}
selectedVendors={selectedVendors}
selectedThirdParties={selectedThirdParties}
/>
</Suspense>
</Field>
);
}
function VendorsMultiSelectWithQuery<T extends FieldValues = FieldValues>(
props: Pick<Props<T>, "organizationId" | "control" | "name" | "disabled" | "selectedVendors">,
function ThirdPartiesMultiSelectWithQuery<T extends FieldValues = FieldValues>(
props: Pick<Props<T>, "organizationId" | "control" | "name" | "disabled" | "selectedThirdParties">,
) {
const { __ } = useTranslate();
const { name, organizationId, control, selectedVendors = [] } = props;
const vendors = useVendors(organizationId);
const { name, organizationId, control, selectedThirdParties = [] } = props;
const thirdParties = useThirdParties(organizationId);
const [isOpen, setIsOpen] = useState(false);
const allVendors = [...vendors];
const allThirdParties = [...thirdParties];
if (props.disabled) {
selectedVendors.forEach((selectedVendor) => {
if (!allVendors.find(v => v.id === selectedVendor.id)) {
allVendors.push(selectedVendor);
selectedThirdParties.forEach((selectedThirdParty) => {
if (!allThirdParties.find(v => v.id === selectedThirdParty.id)) {
allThirdParties.push(selectedThirdParty);
}
});
}
@@ -81,49 +81,49 @@ function VendorsMultiSelectWithQuery<T extends FieldValues = FieldValues>(
control={control}
name={name as Path<T>}
render={({ field }) => {
const selectedVendorIds = (Array.isArray(field.value) ? field.value : []) as string[];
const selectedThirdPartyIds = (Array.isArray(field.value) ? field.value : []) as string[];
const selectedVendors = allVendors.filter(v => selectedVendorIds.includes(v.id));
const availableVendors = allVendors.filter(v => !selectedVendorIds.includes(v.id));
const selectedThirdParties = allThirdParties.filter(v => selectedThirdPartyIds.includes(v.id));
const availableThirdParties = allThirdParties.filter(v => !selectedThirdPartyIds.includes(v.id));
const handleAddVendor = (vendorId: string) => {
const newValue = [...selectedVendorIds, vendorId];
const handleAddThirdParty = (thirdPartyId: string) => {
const newValue = [...selectedThirdPartyIds, thirdPartyId];
field.onChange(newValue);
setIsOpen(false);
};
const handleRemoveVendor = (vendorId: string) => {
const newValue = selectedVendorIds.filter((id: string) => id !== vendorId);
const handleRemoveThirdParty = (thirdPartyId: string) => {
const newValue = selectedThirdPartyIds.filter((id: string) => id !== thirdPartyId);
field.onChange(newValue);
};
return (
<div className="space-y-2">
{availableVendors.length > 0 && !props.disabled && (
{availableThirdParties.length > 0 && !props.disabled && (
<Select
disabled={props.disabled}
id={name}
variant="editor"
placeholder={__("Add vendors...")}
onValueChange={handleAddVendor}
key={`${selectedVendorIds.length}-${vendors.length}`}
placeholder={__("Add third parties...")}
onValueChange={handleAddThirdParty}
key={`${selectedThirdPartyIds.length}-${thirdParties.length}`}
className="w-full"
value=""
open={isOpen}
onOpenChange={setIsOpen}
>
{availableVendors.map(vendor => (
<Option key={vendor.id} value={vendor.id} className="flex gap-2">
{availableThirdParties.map(thirdParty => (
<Option key={thirdParty.id} value={thirdParty.id} className="flex gap-2">
<Avatar
name={vendor.name}
src={faviconUrl(vendor.websiteUrl)}
name={thirdParty.name}
src={faviconUrl(thirdParty.websiteUrl)}
size="s"
/>
<div className="flex flex-col">
<span>{vendor.name}</span>
{vendor.websiteUrl && (
<span>{thirdParty.name}</span>
{thirdParty.websiteUrl && (
<span className="text-xs text-txt-secondary">
{vendor.websiteUrl}
{thirdParty.websiteUrl}
</span>
)}
</div>
@@ -132,21 +132,21 @@ function VendorsMultiSelectWithQuery<T extends FieldValues = FieldValues>(
</Select>
)}
{selectedVendors.length > 0 && (
{selectedThirdParties.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedVendors.map(vendor => (
<Badge key={vendor.id} variant="neutral" className="flex items-center gap-2">
{selectedThirdParties.map(thirdParty => (
<Badge key={thirdParty.id} variant="neutral" className="flex items-center gap-2">
<Avatar
name={vendor.name}
src={faviconUrl(vendor.websiteUrl)}
name={thirdParty.name}
src={faviconUrl(thirdParty.websiteUrl)}
size="s"
/>
<span>{vendor.name}</span>
<span>{thirdParty.name}</span>
{!props.disabled && (
<Button
variant="tertiary"
icon={IconCrossLargeX}
onClick={() => handleRemoveVendor(vendor.id)}
onClick={() => handleRemoveThirdParty(thirdParty.id)}
className="h-4 w-4 p-0 hover:bg-transparent"
/>
)}
@@ -155,9 +155,9 @@ function VendorsMultiSelectWithQuery<T extends FieldValues = FieldValues>(
</div>
)}
{selectedVendors.length === 0 && availableVendors.length === 0 && (
{selectedThirdParties.length === 0 && availableThirdParties.length === 0 && (
<div className="text-sm text-txt-secondary py-2">
{__("No vendors available")}
{__("No third parties available")}
</div>
)}
</div>

View File

@@ -15,11 +15,11 @@
import { faviconUrl } from "@probo/helpers";
import { Avatar, Badge, IconCrossLargeX } from "@probo/ui";
import type { VendorGraphSelectQuery } from "#/__generated__/core/VendorGraphSelectQuery.graphql";
import type { ThirdPartyGraphSelectQuery } from "#/__generated__/core/ThirdPartyGraphSelectQuery.graphql";
import { GraphQLCell } from "#/components/table/GraphQLCell";
import { vendorsSelectQuery } from "#/hooks/graph/VendorGraph";
import { thirdPartiesSelectQuery } from "#/hooks/graph/ThirdPartyGraph";
type Vendor = {
type ThirdParty = {
id: string;
name: string;
websiteUrl: string | null | undefined;
@@ -27,47 +27,47 @@ type Vendor = {
type Props = {
name: string;
defaultValue?: Vendor[];
defaultValue?: ThirdParty[];
organizationId: string;
};
const empty = [] as Vendor[];
const empty = [] as ThirdParty[];
export function VendorsCell(props: Props) {
export function ThirdPartiesCell(props: Props) {
return (
<GraphQLCell<VendorGraphSelectQuery, Vendor>
<GraphQLCell<ThirdPartyGraphSelectQuery, ThirdParty>
multiple
name={props.name}
query={vendorsSelectQuery}
query={thirdPartiesSelectQuery}
variables={{
organizationId: props.organizationId,
}}
items={data =>
data.organization?.vendors?.edges?.map(edge => edge.node) ?? []}
data.organization?.thirdParties?.edges?.map(edge => edge.node) ?? []}
itemRenderer={({ item, onRemove }) => (
<VendorBadge vendor={item} onRemove={onRemove} />
<ThirdPartyBadge thirdParty={item} onRemove={onRemove} />
)}
defaultValue={props.defaultValue ?? empty}
/>
);
}
function VendorBadge({
vendor,
function ThirdPartyBadge({
thirdParty,
onRemove,
}: {
vendor: Vendor;
onRemove?: (v: Vendor) => void;
thirdParty: ThirdParty;
onRemove?: (v: ThirdParty) => void;
}) {
return (
<Badge variant="neutral" className="flex items-center gap-1">
<Avatar name={vendor.name} src={faviconUrl(vendor.websiteUrl)} size="s" />
<Avatar name={thirdParty.name} src={faviconUrl(thirdParty.websiteUrl)} size="s" />
<span className="max-w-[100px] text-ellipsis overflow-hidden min-w-0 block">
{vendor.name}
{thirdParty.name}
</span>
{onRemove && (
<button
onClick={() => onRemove(vendor)}
onClick={() => onRemove(thirdParty)}
className="size-4 hover:text-txt-primary cursor-pointer"
type="button"
>

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]);
}

View File

@@ -50,7 +50,7 @@ const fragment = graphql`
canListRisks: permission(action: "core:risk:list")
canListFrameworks: permission(action: "core:framework:list")
canListMembers: permission(action: "iam:membership:list")
canListVendors: permission(action: "core:vendor:list")
canListThirdParties: permission(action: "core:thirdParty:list")
canListDocuments: permission(action: "core:document:list")
canListAssets: permission(action: "core:asset:list")
canListData: permission(action: "core:datum:list")
@@ -127,11 +127,11 @@ export function Sidebar(props: { fKey: SidebarFragment$key }) {
to={`${prefix}/people`}
/>
)}
{organization.canListVendors && (
{organization.canListThirdParties && (
<SidebarItem
label={__("Vendors")}
label={__("Third parties")}
icon={IconStore}
to={`${prefix}/vendors`}
to={`${prefix}/third-parties`}
/>
)}
{organization.canListDocuments && (

View File

@@ -28,9 +28,9 @@ import {
Input,
Option,
Select,
ThirdPartyLogo,
useDialogRef,
useToast,
VendorLogo,
} from "@probo/ui";
import { type ReactNode, useMemo, useState } from "react";
import { useMutation } from "react-relay";
@@ -454,7 +454,7 @@ export function AddAccessSourceDialog({
return (
<Card key={info.provider} padded className="flex items-center gap-3">
<VendorLogo vendor={info.provider} tint className="size-6 shrink-0" />
<ThirdPartyLogo thirdParty={info.provider} tint className="size-6 shrink-0" />
<div className="mr-auto">
<h3 className="font-medium">{info.displayName}</h3>
</div>

View File

@@ -34,7 +34,7 @@ import { z } from "zod";
import type { AssetGraphNodeQuery } from "#/__generated__/core/AssetGraphNodeQuery.graphql";
import { ControlledField } from "#/components/form/ControlledField";
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
import { VendorsMultiSelectField } from "#/components/form/VendorsMultiSelectField";
import { ThirdPartiesMultiSelectField } from "#/components/form/ThirdPartiesMultiSelectField";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useOrganizationId } from "#/hooks/useOrganizationId";
@@ -50,7 +50,7 @@ const updateAssetSchema = z.object({
assetType: z.enum(["PHYSICAL", "VIRTUAL"]),
dataTypesStored: z.string().min(1, "Data types stored is required"),
ownerId: z.string().min(1, "Owner is required"),
vendorIds: z.array(z.string()).optional(),
thirdPartyIds: z.array(z.string()).optional(),
});
type Props = {
@@ -72,8 +72,8 @@ export default function AssetDetailsPage(props: Props) {
);
const deleteAsset = useDeleteAsset(assetEntry, connectionId);
const vendors = assetEntry.vendors?.edges.map(edge => edge.node) ?? [];
const vendorIds = vendors.map(vendor => vendor.id);
const thirdParties = assetEntry.thirdParties?.edges.map(edge => edge.node) ?? [];
const thirdPartyIds = thirdParties.map(thirdParty => thirdParty.id);
const { control, formState, handleSubmit, register, reset }
= useFormWithSchema(updateAssetSchema, {
@@ -83,7 +83,7 @@ export default function AssetDetailsPage(props: Props) {
assetType: assetEntry.assetType || "VIRTUAL",
dataTypesStored: assetEntry.dataTypesStored || "",
ownerId: assetEntry.owner?.id || "",
vendorIds: vendorIds,
thirdPartyIds: thirdPartyIds,
},
});
@@ -176,12 +176,12 @@ export default function AssetDetailsPage(props: Props) {
disabled={!assetEntry.canUpdate}
/>
<VendorsMultiSelectField
<ThirdPartiesMultiSelectField
organizationId={organizationId}
control={control}
name="vendorIds"
selectedVendors={vendors}
label={__("Vendors")}
name="thirdPartyIds"
selectedThirdParties={thirdParties}
label={__("Third parties")}
disabled={!assetEntry.canUpdate}
/>

View File

@@ -78,7 +78,7 @@ const paginatedAssetsFragment = graphql`
fullName
}
# eslint-disable-next-line relay/unused-fields
vendors(first: 50) {
thirdParties(first: 50) {
edges {
node {
# eslint-disable-next-line relay/unused-fields

View File

@@ -27,7 +27,7 @@ import { z } from "zod";
import { ControlledField } from "#/components/form/ControlledField";
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
import { VendorsMultiSelectField } from "#/components/form/VendorsMultiSelectField";
import { ThirdPartiesMultiSelectField } from "#/components/form/ThirdPartiesMultiSelectField";
import { useCreateAsset } from "#/hooks/graph/AssetGraph";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
@@ -36,7 +36,7 @@ const schema = z.object({
amount: z.number().min(1, "Amount is required"),
assetType: z.enum(["PHYSICAL", "VIRTUAL"]),
ownerId: z.string().min(1, "Owner is required"),
vendorIds: z.array(z.string()).optional(),
thirdPartyIds: z.array(z.string()).optional(),
dataTypesStored: z.string().min(1, "Data types stored is required"),
});
@@ -59,7 +59,7 @@ export function CreateAssetDialog({
amount: 0,
assetType: "VIRTUAL",
ownerId: "",
vendorIds: [],
thirdPartyIds: [],
},
});
const ref = useDialogRef();
@@ -112,11 +112,11 @@ export function CreateAssetDialog({
name="ownerId"
label={__("Owner")}
/>
<VendorsMultiSelectField
<ThirdPartiesMultiSelectField
organizationId={organizationId}
control={control}
name="vendorIds"
label={__("Vendors")}
name="thirdPartyIds"
label={__("Third parties")}
/>
</DialogContent>
<DialogFooter>

View File

@@ -113,7 +113,7 @@ export function CompliancePageLayout(props: { queryRef: PreloadedQuery<Complianc
<IconFolder2 className="size-4" />
{__("Files")}
</TabLink>
<TabLink to={`/organizations/${organizationId}/compliance-page/vendors`}>
<TabLink to={`/organizations/${organizationId}/compliance-page/third-parties`}>
<IconStore className="size-4" />
{__("Subprocessors")}
</TabLink>

View File

@@ -62,9 +62,9 @@ export const compliancePageRoutes = [
Component: lazy(() => import("#/pages/organizations/compliance-page/files/CompliancePageFilesPageLoader")),
},
{
path: "vendors",
path: "third-parties",
Fallback: LinkCardSkeleton,
Component: lazy(() => import("#/pages/organizations/compliance-page/vendors/CompliancePageVendorsPageLoader")),
Component: lazy(() => import("#/pages/organizations/compliance-page/third-parties/CompliancePageThirdPartiesPageLoader")),
},
{
path: "access",

View File

@@ -15,27 +15,27 @@
import { useTranslate } from "@probo/i18n";
import { graphql, type PreloadedQuery, usePreloadedQuery } from "react-relay";
import type { CompliancePageVendorsPageQuery } from "#/__generated__/core/CompliancePageVendorsPageQuery.graphql";
import type { CompliancePageThirdPartiesPageQuery } from "#/__generated__/core/CompliancePageThirdPartiesPageQuery.graphql";
import { CompliancePageVendorList } from "./_components/CompliancePageVendorList";
import { CompliancePageThirdPartyList } from "./_components/CompliancePageThirdPartyList";
export const compliancePageVendorsPageQuery = graphql`
query CompliancePageVendorsPageQuery($organizationId: ID!) {
export const compliancePageThirdPartiesPageQuery = graphql`
query CompliancePageThirdPartiesPageQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
...CompliancePageVendorListFragment
...CompliancePageThirdPartyListFragment
}
}
`;
export function CompliancePageVendorsPage(props: {
queryRef: PreloadedQuery<CompliancePageVendorsPageQuery>;
export function CompliancePageThirdPartiesPage(props: {
queryRef: PreloadedQuery<CompliancePageThirdPartiesPageQuery>;
}) {
const { queryRef } = props;
const { __ } = useTranslate();
const { organization } = usePreloadedQuery<CompliancePageVendorsPageQuery>(
compliancePageVendorsPageQuery,
const { organization } = usePreloadedQuery<CompliancePageThirdPartiesPageQuery>(
compliancePageThirdPartiesPageQuery,
queryRef,
);
@@ -50,7 +50,7 @@ export function CompliancePageVendorsPage(props: {
</div>
</div>
<CompliancePageVendorList fragmentRef={organization} />
<CompliancePageThirdPartyList fragmentRef={organization} />
</div>
);
}

View File

@@ -15,16 +15,18 @@
import { useEffect } from "react";
import { useQueryLoader } from "react-relay";
import type { CompliancePageVendorsPageQuery } from "#/__generated__/core/CompliancePageVendorsPageQuery.graphql";
import type { CompliancePageThirdPartiesPageQuery } from "#/__generated__/core/CompliancePageThirdPartiesPageQuery.graphql";
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { CoreRelayProvider } from "#/providers/CoreRelayProvider";
import { CompliancePageVendorsPage, compliancePageVendorsPageQuery } from "./CompliancePageVendorsPage";
import { CompliancePageThirdPartiesPage, compliancePageThirdPartiesPageQuery } from "./CompliancePageThirdPartiesPage";
function CompliancePageVendorsPageQueryLoader() {
function CompliancePageThirdPartiesPageQueryLoader() {
const organizationId = useOrganizationId();
const [queryRef, loadQuery] = useQueryLoader<CompliancePageVendorsPageQuery>(compliancePageVendorsPageQuery);
const [queryRef, loadQuery] = useQueryLoader<CompliancePageThirdPartiesPageQuery>(
compliancePageThirdPartiesPageQuery,
);
useEffect(() => {
if (!queryRef) {
@@ -34,13 +36,13 @@ function CompliancePageVendorsPageQueryLoader() {
if (!queryRef) return <LinkCardSkeleton />;
return <CompliancePageVendorsPage queryRef={queryRef} />;
return <CompliancePageThirdPartiesPage queryRef={queryRef} />;
}
export default function CompliancePageVendorsPageLoader() {
export default function CompliancePageThirdPartiesPageLoader() {
return (
<CoreRelayProvider>
<CompliancePageVendorsPageQueryLoader />
<CompliancePageThirdPartiesPageQueryLoader />
</CoreRelayProvider>
);
}

View File

@@ -17,29 +17,29 @@ import { Table, Tbody, Td, Th, Thead, Tr } from "@probo/ui";
import { useFragment } from "react-relay";
import { graphql } from "relay-runtime";
import type { CompliancePageVendorListFragment$key } from "#/__generated__/core/CompliancePageVendorListFragment.graphql";
import type { CompliancePageThirdPartyListFragment$key } from "#/__generated__/core/CompliancePageThirdPartyListFragment.graphql";
import { CompliancePageVendorListItem } from "./CompliancePageVendorListItem";
import { CompliancePageThirdPartyListItem } from "./CompliancePageThirdPartyListItem";
const fragment = graphql`
fragment CompliancePageVendorListFragment on Organization {
vendors(first: 100) {
fragment CompliancePageThirdPartyListFragment on Organization {
thirdParties(first: 100) {
edges {
node {
id
...CompliancePageVendorListItem_vendorFragment
...CompliancePageThirdPartyListItem_thirdPartyFragment
}
}
}
}
`;
export function CompliancePageVendorList(props: { fragmentRef: CompliancePageVendorListFragment$key }) {
export function CompliancePageThirdPartyList(props: { fragmentRef: CompliancePageThirdPartyListFragment$key }) {
const { fragmentRef } = props;
const { __ } = useTranslate();
const { vendors } = useFragment<CompliancePageVendorListFragment$key>(fragment, fragmentRef);
const { thirdParties } = useFragment<CompliancePageThirdPartyListFragment$key>(fragment, fragmentRef);
return (
<div className="space-y-[10px]">
@@ -53,17 +53,17 @@ export function CompliancePageVendorList(props: { fragmentRef: CompliancePageVen
</Tr>
</Thead>
<Tbody>
{vendors.edges.length === 0 && (
{thirdParties.edges.length === 0 && (
<Tr>
<Td colSpan={4} className="text-center text-txt-secondary">
{__("No subprocessors available")}
</Td>
</Tr>
)}
{vendors.edges.map(({ node: vendor }) => (
<CompliancePageVendorListItem
key={vendor.id}
vendorFragmentRef={vendor}
{thirdParties.edges.map(({ node: thirdParty }) => (
<CompliancePageThirdPartyListItem
key={thirdParty.id}
thirdPartyFragmentRef={thirdParty}
/>
))}
</Tbody>

View File

@@ -0,0 +1,104 @@
// 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 { useTranslate } from "@probo/i18n";
import { Badge, Button, IconCheckmark1, IconCrossLargeX, Td, Tr } from "@probo/ui";
import { useFragment } from "react-relay";
import { graphql } from "relay-runtime";
import type { CompliancePageThirdPartyListItem_thirdPartyFragment$key } from "#/__generated__/core/CompliancePageThirdPartyListItem_thirdPartyFragment.graphql";
import type { CompliancePageThirdPartyListItemMutation } from "#/__generated__/core/CompliancePageThirdPartyListItemMutation.graphql";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import { useOrganizationId } from "#/hooks/useOrganizationId";
const thirdPartyFragment = graphql`
fragment CompliancePageThirdPartyListItem_thirdPartyFragment on ThirdParty {
id
category
name
showOnTrustCenter
canUpdate: permission(action: "core:thirdParty:update")
}
`;
const updateThirdPartyVisibilityMutation = graphql`
mutation CompliancePageThirdPartyListItemMutation($input: UpdateThirdPartyInput!) {
updateThirdParty(input: $input) {
thirdParty {
id
showOnTrustCenter
...CompliancePageThirdPartyListItem_thirdPartyFragment
}
}
}
`;
export function CompliancePageThirdPartyListItem(props: {
thirdPartyFragmentRef: CompliancePageThirdPartyListItem_thirdPartyFragment$key;
}) {
const { thirdPartyFragmentRef } = props;
const organizationId = useOrganizationId();
const { __ } = useTranslate();
const thirdParty = useFragment<CompliancePageThirdPartyListItem_thirdPartyFragment$key>(
thirdPartyFragment,
thirdPartyFragmentRef,
);
const [updateThirdPartyVisibility, isUpadtingThirdPartyVisibility] = useMutationWithToasts<
CompliancePageThirdPartyListItemMutation
>(
updateThirdPartyVisibilityMutation,
{
successMessage: __("Subprocessor visibility updated successfully."),
errorMessage: __("Failed to update subprocessor visibility"),
},
);
return (
<Tr to={`/organizations/${organizationId}/third-parties/${thirdParty.id}/overview`}>
<Td>
<div className="flex gap-4 items-center">{thirdParty.name}</div>
</Td>
<Td>
<Badge variant="neutral">{thirdParty.category}</Badge>
</Td>
<Td>
<Badge variant={thirdParty.showOnTrustCenter ? "success" : "danger"}>
{thirdParty.showOnTrustCenter ? __("Visible") : __("None")}
</Badge>
</Td>
<Td noLink width={100} className="text-end">
{thirdParty.canUpdate && (
<Button
variant="secondary"
onClick={() =>
void updateThirdPartyVisibility({
variables: {
input: {
id: thirdParty.id,
showOnTrustCenter: !thirdParty.showOnTrustCenter,
},
},
})}
icon={thirdParty.showOnTrustCenter ? IconCrossLargeX : IconCheckmark1}
disabled={isUpadtingThirdPartyVisibility}
>
{thirdParty.showOnTrustCenter ? __("Hide") : __("Show")}
</Button>
)}
</Td>
</Tr>
);
};

View File

@@ -1,104 +0,0 @@
// 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 { useTranslate } from "@probo/i18n";
import { Badge, Button, IconCheckmark1, IconCrossLargeX, Td, Tr } from "@probo/ui";
import { useFragment } from "react-relay";
import { graphql } from "relay-runtime";
import type { CompliancePageVendorListItem_vendorFragment$key } from "#/__generated__/core/CompliancePageVendorListItem_vendorFragment.graphql";
import type { CompliancePageVendorListItemMutation } from "#/__generated__/core/CompliancePageVendorListItemMutation.graphql";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import { useOrganizationId } from "#/hooks/useOrganizationId";
const vendorFragment = graphql`
fragment CompliancePageVendorListItem_vendorFragment on Vendor {
id
category
name
showOnTrustCenter
canUpdate: permission(action: "core:vendor:update")
}
`;
const updateVendorVisibilityMutation = graphql`
mutation CompliancePageVendorListItemMutation($input: UpdateVendorInput!) {
updateVendor(input: $input) {
vendor {
id
showOnTrustCenter
...CompliancePageVendorListItem_vendorFragment
}
}
}
`;
export function CompliancePageVendorListItem(props: {
vendorFragmentRef: CompliancePageVendorListItem_vendorFragment$key;
}) {
const { vendorFragmentRef } = props;
const organizationId = useOrganizationId();
const { __ } = useTranslate();
const vendor = useFragment<CompliancePageVendorListItem_vendorFragment$key>(
vendorFragment,
vendorFragmentRef,
);
const [updateVendorVisibility, isUpadtingVendorVisibility] = useMutationWithToasts<
CompliancePageVendorListItemMutation
>(
updateVendorVisibilityMutation,
{
successMessage: __("Subprocessor visibility updated successfully."),
errorMessage: __("Failed to update subprocessor visibility"),
},
);
return (
<Tr to={`/organizations/${organizationId}/vendors/${vendor.id}/overview`}>
<Td>
<div className="flex gap-4 items-center">{vendor.name}</div>
</Td>
<Td>
<Badge variant="neutral">{vendor.category}</Badge>
</Td>
<Td>
<Badge variant={vendor.showOnTrustCenter ? "success" : "danger"}>
{vendor.showOnTrustCenter ? __("Visible") : __("None")}
</Badge>
</Td>
<Td noLink width={100} className="text-end">
{vendor.canUpdate && (
<Button
variant="secondary"
onClick={() =>
void updateVendorVisibility({
variables: {
input: {
id: vendor.id,
showOnTrustCenter: !vendor.showOnTrustCenter,
},
},
})}
icon={vendor.showOnTrustCenter ? IconCrossLargeX : IconCheckmark1}
disabled={isUpadtingVendorVisibility}
>
{vendor.showOnTrustCenter ? __("Hide") : __("Show")}
</Button>
)}
</Td>
</Tr>
);
};

View File

@@ -81,7 +81,7 @@ const paginatedDataFragment = graphql`
owner {
fullName
}
vendors(first: 50) {
thirdParties(first: 50) {
edges {
node {
id
@@ -192,7 +192,7 @@ export default function DataPage(props: Props) {
<Th>{__("Name")}</Th>
<Th>{__("Classification")}</Th>
<Th>{__("Owner")}</Th>
<Th>{__("Vendors")}</Th>
<Th>{__("Third parties")}</Th>
{hasAnyAction && <Th></Th>}
</Tr>
</Thead>
@@ -223,7 +223,7 @@ function DataRow({
const organizationId = useOrganizationId();
const { __ } = useTranslate();
const deleteDatum = useDeleteDatum(entry, connectionId);
const vendors = entry.vendors?.edges.map(edge => edge.node) ?? [];
const thirdParties = entry.thirdParties?.edges.map(edge => edge.node) ?? [];
const detailUrl = `/organizations/${organizationId}/data/${entry.id}`;
return (
@@ -234,27 +234,27 @@ function DataRow({
</Td>
<Td>{entry.owner?.fullName ?? __("Unassigned")}</Td>
<Td>
{vendors.length > 0
{thirdParties.length > 0
? (
<div className="flex flex-wrap gap-1">
{vendors.slice(0, 3).map(vendor => (
{thirdParties.slice(0, 3).map(thirdParty => (
<Badge
key={vendor.id}
key={thirdParty.id}
variant="neutral"
className="flex items-center gap-1"
>
<Avatar
name={vendor.name}
src={faviconUrl(vendor.websiteUrl)}
name={thirdParty.name}
src={faviconUrl(thirdParty.websiteUrl)}
size="s"
/>
<span className="text-xs">{vendor.name}</span>
<span className="text-xs">{thirdParty.name}</span>
</Badge>
))}
{vendors.length > 3 && (
{thirdParties.length > 3 && (
<Badge variant="neutral" className="text-xs">
+
{vendors.length - 3}
{thirdParties.length - 3}
</Badge>
)}
</div>

View File

@@ -33,7 +33,7 @@ import { z } from "zod";
import type { DatumGraphNodeQuery } from "#/__generated__/core/DatumGraphNodeQuery.graphql";
import { ControlledField } from "#/components/form/ControlledField";
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
import { VendorsMultiSelectField } from "#/components/form/VendorsMultiSelectField";
import { ThirdPartiesMultiSelectField } from "#/components/form/ThirdPartiesMultiSelectField";
import {
datumNodeQuery,
useDeleteDatum,
@@ -46,7 +46,7 @@ const updateDatumSchema = z.object({
name: z.string().min(1, "Name is required"),
dataClassification: z.enum(["PUBLIC", "INTERNAL", "CONFIDENTIAL", "SECRET"]),
ownerId: z.string().min(1, "Owner is required"),
vendorIds: z.array(z.string()).optional(),
thirdPartyIds: z.array(z.string()).optional(),
});
type Props = {
@@ -69,8 +69,8 @@ export default function DatumDetailsPage(props: Props) {
ConnectionHandler.getConnectionID(organizationId, "DataPage_data"),
);
const vendors = datumEntry?.vendors?.edges.map(edge => edge.node) ?? [];
const vendorIds = vendors.map(vendor => vendor.id);
const thirdParties = datumEntry?.thirdParties?.edges.map(edge => edge.node) ?? [];
const thirdPartyIds = thirdParties.map(thirdParty => thirdParty.id);
const { control, formState, handleSubmit, register, reset }
= useFormWithSchema(updateDatumSchema, {
@@ -78,7 +78,7 @@ export default function DatumDetailsPage(props: Props) {
name: datumEntry?.name || "",
dataClassification: datumEntry?.dataClassification || "PUBLIC",
ownerId: datumEntry?.owner?.id || "",
vendorIds: vendorIds,
thirdPartyIds: thirdPartyIds,
},
});
@@ -161,13 +161,13 @@ export default function DatumDetailsPage(props: Props) {
disabled={!datumEntry.canUpdate}
/>
<VendorsMultiSelectField
<ThirdPartiesMultiSelectField
organizationId={organizationId}
control={control}
name="vendorIds"
label={__("Vendors")}
name="thirdPartyIds"
label={__("Third parties")}
disabled={!datumEntry.canUpdate}
selectedVendors={vendors}
selectedThirdParties={thirdParties}
/>
<div className="flex justify-end">

View File

@@ -27,7 +27,7 @@ import { z } from "zod";
import { ControlledField } from "#/components/form/ControlledField";
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
import { VendorsMultiSelectField } from "#/components/form/VendorsMultiSelectField";
import { ThirdPartiesMultiSelectField } from "#/components/form/ThirdPartiesMultiSelectField";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useCreateDatum } from "../../../../hooks/graph/DatumGraph";
@@ -36,7 +36,7 @@ const schema = z.object({
name: z.string().min(1, "Name is required"),
dataClassification: z.enum(["PUBLIC", "INTERNAL", "CONFIDENTIAL", "SECRET"]),
ownerId: z.string().min(1, "Owner is required"),
vendorIds: z.array(z.string()).optional(),
thirdPartyIds: z.array(z.string()).optional(),
});
type Props = {
@@ -59,7 +59,7 @@ export function CreateDatumDialog({
name: "",
dataClassification: "PUBLIC",
ownerId: "",
vendorIds: [],
thirdPartyIds: [],
},
});
const ref = useDialogRef();
@@ -105,11 +105,11 @@ export function CreateDatumDialog({
name="ownerId"
label={__("Owner")}
/>
<VendorsMultiSelectField
<ThirdPartiesMultiSelectField
organizationId={organizationId}
control={control}
name="vendorIds"
label={__("Vendors")}
name="thirdPartyIds"
label={__("Third parties")}
/>
</DialogContent>
<DialogFooter>

View File

@@ -46,7 +46,7 @@ import { z } from "zod";
import type { ProcessingActivityGraphNodeQuery } from "#/__generated__/core/ProcessingActivityGraphNodeQuery.graphql";
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
import { VendorsMultiSelectField } from "#/components/form/VendorsMultiSelectField";
import { ThirdPartiesMultiSelectField } from "#/components/form/ThirdPartiesMultiSelectField";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useOrganizationId } from "#/hooks/useOrganizationId";
@@ -102,7 +102,7 @@ const updateProcessingActivitySchema = z.object({
nextReviewDate: z.string().optional(),
role: z.enum(["CONTROLLER", "PROCESSOR"] as const),
dataProtectionOfficerId: z.string().optional(),
vendorIds: z.array(z.string()).optional(),
thirdPartyIds: z.array(z.string()).optional(),
});
type Props = {
@@ -199,8 +199,8 @@ export default function ProcessingActivityDetailsPage(props: Props) {
connectionId,
);
const vendors = activity?.vendors?.edges.map(edge => edge.node) ?? [];
const vendorIds = vendors.map(vendor => vendor.id);
const thirdParties = activity?.thirdParties?.edges.map(edge => edge.node) ?? [];
const thirdPartyIds = thirdParties.map(thirdParty => thirdParty.id);
const { register, handleSubmit, formState, control } = useFormWithSchema(
updateProcessingActivitySchema,
@@ -229,7 +229,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
nextReviewDate: toDateInput(activity.nextReviewDate),
role: activity.role || ("CONTROLLER" as const),
dataProtectionOfficerId: activity.dataProtectionOfficer?.id || "",
vendorIds: vendorIds,
thirdPartyIds: thirdPartyIds,
},
},
);
@@ -287,7 +287,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
nextReviewDate: formatDatetime(formData.nextReviewDate) ?? null,
role: formData.role,
dataProtectionOfficerId: formData.dataProtectionOfficerId || null,
vendorIds: formData.vendorIds,
thirdPartyIds: formData.thirdPartyIds,
});
toast({
@@ -777,12 +777,12 @@ export default function ProcessingActivityDetailsPage(props: Props) {
</div>
</div>
<VendorsMultiSelectField
<ThirdPartiesMultiSelectField
organizationId={organizationId}
control={control}
name="vendorIds"
selectedVendors={vendors}
label={__("Vendors")}
name="thirdPartyIds"
selectedThirdParties={thirdParties}
label={__("Third parties")}
disabled={!activity.canUpdate}
/>

View File

@@ -34,7 +34,7 @@ import { Controller } from "react-hook-form";
import { z } from "zod";
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
import { VendorsMultiSelectField } from "#/components/form/VendorsMultiSelectField";
import { ThirdPartiesMultiSelectField } from "#/components/form/ThirdPartiesMultiSelectField";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import {
@@ -67,7 +67,7 @@ const schema = z.object({
nextReviewDate: z.string().optional(),
role: z.enum(["CONTROLLER", "PROCESSOR"] as const),
dataProtectionOfficerId: z.string().optional(),
vendorIds: z.array(z.string()).optional(),
thirdPartyIds: z.array(z.string()).optional(),
});
type FormData = z.infer<typeof schema>;
@@ -110,7 +110,7 @@ export function CreateProcessingActivityDialog({
nextReviewDate: "",
role: "PROCESSOR" as const,
dataProtectionOfficerId: "",
vendorIds: [],
thirdPartyIds: [],
},
});
@@ -137,7 +137,7 @@ export function CreateProcessingActivityDialog({
nextReviewDate: formatDatetime(formData.nextReviewDate),
role: formData.role,
dataProtectionOfficerId: formData.dataProtectionOfficerId || undefined,
vendorIds: formData.vendorIds,
thirdPartyIds: formData.thirdPartyIds,
});
toast({
@@ -430,12 +430,12 @@ export function CreateProcessingActivityDialog({
</div>
</div>
<VendorsMultiSelectField
<ThirdPartiesMultiSelectField
organizationId={organizationId}
control={control}
name="vendorIds"
selectedVendors={[]}
label={__("Vendors")}
name="thirdPartyIds"
selectedThirdParties={[]}
label={__("Third parties")}
/>
</DialogContent>

View File

@@ -155,9 +155,9 @@ const deleteWebhookSubscriptionMutation = graphql`
`;
const EVENT_TYPES = [
{ value: "VENDOR_CREATED", label: "vendor:created" },
{ value: "VENDOR_UPDATED", label: "vendor:updated" },
{ value: "VENDOR_DELETED", label: "vendor:deleted" },
{ value: "THIRD_PARTY_CREATED", label: "third-party:created" },
{ value: "THIRD_PARTY_UPDATED", label: "third-party:updated" },
{ value: "THIRD_PARTY_DELETED", label: "third-party:deleted" },
{ value: "USER_CREATED", label: "user:created" },
{ value: "USER_UPDATED", label: "user:updated" },
{ value: "USER_DELETED", label: "user:deleted" },

View File

@@ -39,75 +39,75 @@ import {
} from "react-relay";
import { useNavigate } from "react-router";
import type { VendorGraphListQuery } from "#/__generated__/core/VendorGraphListQuery.graphql";
import type { ThirdPartyGraphListQuery } from "#/__generated__/core/ThirdPartyGraphListQuery.graphql";
import type {
VendorGraphPaginatedFragment$data,
VendorGraphPaginatedFragment$key,
} from "#/__generated__/core/VendorGraphPaginatedFragment.graphql";
ThirdPartyGraphPaginatedFragment$data,
ThirdPartyGraphPaginatedFragment$key,
} from "#/__generated__/core/ThirdPartyGraphPaginatedFragment.graphql";
import { SortableTable, SortableTh } from "#/components/SortableTable";
import {
paginatedVendorsFragment,
useDeleteVendor,
vendorsQuery,
} from "#/hooks/graph/VendorGraph";
paginatedThirdPartiesFragment,
thirdPartiesQuery,
useDeleteThirdParty,
} from "#/hooks/graph/ThirdPartyGraph";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import type { NodeOf } from "#/types";
import { CreateVendorDialog } from "./dialogs/CreateVendorDialog";
import { PublishVendorListDialog } from "./dialogs/PublishVendorListDialog";
import { CreateThirdPartyDialog } from "./dialogs/CreateThirdPartyDialog";
import { PublishThirdPartyListDialog } from "./dialogs/PublishThirdPartyListDialog";
type Vendor = NodeOf<VendorGraphPaginatedFragment$data["vendors"]>;
type ThirdParty = NodeOf<ThirdPartyGraphPaginatedFragment$data["thirdParties"]>;
type Props = {
queryRef: PreloadedQuery<VendorGraphListQuery>;
queryRef: PreloadedQuery<ThirdPartyGraphListQuery>;
};
export default function VendorsPage(props: Props) {
export default function ThirdPartiesPage(props: Props) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const navigate = useNavigate();
const data = usePreloadedQuery(vendorsQuery, props.queryRef);
const data = usePreloadedQuery(thirdPartiesQuery, props.queryRef);
// eslint-disable-next-line relay/generated-typescript-types
const pagination = usePaginationFragment(
paginatedVendorsFragment,
data.node as VendorGraphPaginatedFragment$key,
paginatedThirdPartiesFragment,
data.node as ThirdPartyGraphPaginatedFragment$key,
);
const vendors = pagination.data.vendors?.edges.map(edge => edge.node);
const connectionId = pagination.data.vendors.__id;
const thirdParties = pagination.data.thirdParties?.edges.map(edge => edge.node);
const connectionId = pagination.data.thirdParties.__id;
usePageTitle(__("Vendors"));
usePageTitle(__("Third parties"));
const hasAnyAction
= vendors.some(({ canUpdate, canDelete }) => canUpdate || canDelete);
= thirdParties.some(({ canUpdate, canDelete }) => canUpdate || canDelete);
const vendorsDocument = data.node?.vendorsDocument;
const thirdPartiesDocument = data.node?.thirdPartiesDocument;
const defaultApproverIds
= vendorsDocument?.defaultApprovers?.map(a => a.id) ?? [];
= thirdPartiesDocument?.defaultApprovers?.map(a => a.id) ?? [];
return (
<div className="space-y-6">
<PageHeader
title={__("Vendors")}
title={__("Third parties")}
description={__(
"Vendors are third-party services that your company uses. Add them to keep track of their risk and compliance status.",
"Third parties are external services and providers that your company uses. Add them to keep track of their risk and compliance status.",
)}
>
<div className="flex gap-2">
{vendorsDocument && (
{thirdPartiesDocument && (
<Button
variant="secondary"
icon={IconPageTextLine}
onClick={() => void navigate(
`/organizations/${organizationId}/documents/${vendorsDocument.id}`,
`/organizations/${organizationId}/documents/${thirdPartiesDocument.id}`,
)}
>
{__("Document")}
</Button>
)}
{data.node.canPublishVendor && (
<PublishVendorListDialog
{data.node.canPublishThirdParty && (
<PublishThirdPartyListDialog
organizationId={organizationId}
defaultApproverIds={defaultApproverIds}
onPublished={documentId => void navigate(
@@ -117,22 +117,22 @@ export default function VendorsPage(props: Props) {
<Button variant="secondary" icon={IconUpload}>
{__("Publish")}
</Button>
</PublishVendorListDialog>
</PublishThirdPartyListDialog>
)}
{data.node.canCreateVendor && (
<CreateVendorDialog
{data.node.canCreateThirdParty && (
<CreateThirdPartyDialog
connection={connectionId}
organizationId={organizationId}
>
<Button icon={IconPlusLarge}>{__("Add vendor")}</Button>
</CreateVendorDialog>
<Button icon={IconPlusLarge}>{__("Add third party")}</Button>
</CreateThirdPartyDialog>
)}
</div>
</PageHeader>
<SortableTable {...pagination}>
<Thead>
<Tr>
<SortableTh field="NAME">{__("Vendor")}</SortableTh>
<SortableTh field="NAME">{__("Third party")}</SortableTh>
<Th>{__("Accessed At")}</Th>
<Th>{__("Data Risk")}</Th>
<Th>{__("Business Risk")}</Th>
@@ -140,10 +140,10 @@ export default function VendorsPage(props: Props) {
</Tr>
</Thead>
<Tbody>
{vendors?.map(vendor => (
<VendorRow
key={vendor.id}
vendor={vendor}
{thirdParties?.map(thirdParty => (
<ThirdPartyRow
key={thirdParty.id}
thirdParty={thirdParty}
organizationId={organizationId}
connectionId={connectionId}
hasAnyAction={hasAnyAction}
@@ -155,30 +155,30 @@ export default function VendorsPage(props: Props) {
);
}
function VendorRow({
vendor,
function ThirdPartyRow({
thirdParty,
organizationId,
connectionId,
hasAnyAction,
}: {
vendor: Vendor;
thirdParty: ThirdParty;
organizationId: string;
connectionId: string;
hasAnyAction: boolean;
}) {
const { __ } = useTranslate();
const latestAssessment = vendor.riskAssessments?.edges[0]?.node;
const deleteVendor = useDeleteVendor(vendor, connectionId);
const latestAssessment = thirdParty.riskAssessments?.edges[0]?.node;
const deleteThirdParty = useDeleteThirdParty(thirdParty, connectionId);
const vendorUrl = `/organizations/${organizationId}/vendors/${vendor.id}/overview`;
const thirdPartyUrl = `/organizations/${organizationId}/third-parties/${thirdParty.id}/overview`;
return (
<>
<Tr to={vendorUrl}>
<Tr to={thirdPartyUrl}>
<Td>
<div className="flex gap-2 items-center">
<Avatar name={vendor.name} src={faviconUrl(vendor.websiteUrl)} />
<div>{vendor.name}</div>
<Avatar name={thirdParty.name} src={faviconUrl(thirdParty.websiteUrl)} />
<div>{thirdParty.name}</div>
</div>
</Td>
<Td>
@@ -195,9 +195,9 @@ function VendorRow({
{hasAnyAction && (
<Td noLink width={50} className="text-end">
<ActionDropdown>
{vendor.canDelete && (
{thirdParty.canDelete && (
<DropdownItem
onClick={deleteVendor}
onClick={deleteThirdParty}
variant="danger"
icon={IconTrashCan}
>

View File

@@ -33,52 +33,52 @@ import {
} from "react-relay";
import { Outlet } from "react-router";
import type { VendorComplianceTabFragment$key } from "#/__generated__/core/VendorComplianceTabFragment.graphql";
import type { VendorGraphNodeQuery } from "#/__generated__/core/VendorGraphNodeQuery.graphql";
import type { ThirdPartyComplianceTabFragment$key } from "#/__generated__/core/ThirdPartyComplianceTabFragment.graphql";
import type { ThirdPartyGraphNodeQuery } from "#/__generated__/core/ThirdPartyGraphNodeQuery.graphql";
import {
useDeleteVendor,
vendorConnectionKey,
vendorNodeQuery,
} from "#/hooks/graph/VendorGraph";
thirdPartyConnectionKey,
thirdPartyNodeQuery,
useDeleteThirdParty,
} from "#/hooks/graph/ThirdPartyGraph";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { ImportAssessmentDialog } from "./dialogs/ImportAssessmentDialog";
import { complianceReportsFragment } from "./tabs/VendorComplianceTab";
import { complianceReportsFragment } from "./tabs/ThirdPartyComplianceTab";
type Props = {
queryRef: PreloadedQuery<VendorGraphNodeQuery>;
queryRef: PreloadedQuery<ThirdPartyGraphNodeQuery>;
};
export default function VendorDetailPage(props: Props) {
const { node: vendor } = usePreloadedQuery(vendorNodeQuery, props.queryRef);
export default function ThirdPartyDetailPage(props: Props) {
const { node: thirdParty } = usePreloadedQuery(thirdPartyNodeQuery, props.queryRef);
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const deleteVendor = useDeleteVendor(
vendor,
ConnectionHandler.getConnectionID(organizationId, vendorConnectionKey),
const deleteThirdParty = useDeleteThirdParty(
thirdParty,
ConnectionHandler.getConnectionID(organizationId, thirdPartyConnectionKey),
);
const logo = faviconUrl(vendor.websiteUrl);
const logo = faviconUrl(thirdParty.websiteUrl);
const reportsCount = useFragment(
complianceReportsFragment,
vendor as VendorComplianceTabFragment$key,
thirdParty as ThirdPartyComplianceTabFragment$key,
).complianceReports.edges.length;
const vendorsUrl = `/organizations/${organizationId}/vendors`;
const thirdPartiesUrl = `/organizations/${organizationId}/third-parties`;
const baseVendorUrl
= `/organizations/${organizationId}/vendors/${vendor.id}`;
const baseThirdPartyUrl
= `/organizations/${organizationId}/third-parties/${thirdParty.id}`;
return (
<div className="space-y-6">
<Breadcrumb
items={[
{
label: __("Vendors"),
to: vendorsUrl,
label: __("Third parties"),
to: thirdPartiesUrl,
},
{
label: vendor.name ?? "",
label: thirdParty.name ?? "",
},
]}
/>
@@ -87,26 +87,26 @@ export default function VendorDetailPage(props: Props) {
{logo && (
<img
src={logo}
alt={vendor.name ?? ""}
alt={thirdParty.name ?? ""}
className="shadow-mid rounded-2xl"
/>
)}
<div className="text-2xl">{vendor.name}</div>
<div className="text-2xl">{thirdParty.name}</div>
</div>
<div className="flex gap-2 items-center">
{vendor.canAssess && (
<ImportAssessmentDialog vendorId={vendor.id}>
{thirdParty.canAssess && (
<ImportAssessmentDialog thirdPartyId={thirdParty.id}>
<Button icon={IconPageTextLine} variant="secondary">
{__("Assessment From Website")}
</Button>
</ImportAssessmentDialog>
)}
{vendor.canDelete && (
{thirdParty.canDelete && (
<ActionDropdown variant="secondary">
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={deleteVendor}
onClick={deleteThirdParty}
>
{__("Delete")}
</DropdownItem>
@@ -116,20 +116,20 @@ export default function VendorDetailPage(props: Props) {
</div>
<Tabs>
<TabLink to={`${baseVendorUrl}/overview`}>{__("Overview")}</TabLink>
<TabLink to={`${baseVendorUrl}/certifications`}>
<TabLink to={`${baseThirdPartyUrl}/overview`}>{__("Overview")}</TabLink>
<TabLink to={`${baseThirdPartyUrl}/certifications`}>
{__("Certifications")}
</TabLink>
<TabLink to={`${baseVendorUrl}/compliance`}>
<TabLink to={`${baseThirdPartyUrl}/compliance`}>
{__("Compliance reports")}
{reportsCount > 0 && <TabBadge>{reportsCount}</TabBadge>}
</TabLink>
<TabLink to={`${baseVendorUrl}/risks`}>{__("Risk Assessment")}</TabLink>
<TabLink to={`${baseVendorUrl}/contacts`}>{__("Contacts")}</TabLink>
<TabLink to={`${baseVendorUrl}/services`}>{__("Services")}</TabLink>
<TabLink to={`${baseThirdPartyUrl}/risks`}>{__("Risk Assessment")}</TabLink>
<TabLink to={`${baseThirdPartyUrl}/contacts`}>{__("Contacts")}</TabLink>
<TabLink to={`${baseThirdPartyUrl}/services`}>{__("Services")}</TabLink>
</Tabs>
<Outlet context={{ vendor }} />
<Outlet context={{ thirdParty }} />
</div>
);
}

View File

@@ -22,7 +22,7 @@ import type {
CommonThirdPartyCombobox_commonThirdParty$key,
} from "#/__generated__/core/CommonThirdPartyCombobox_commonThirdParty.graphql";
import type { CommonThirdPartyComboboxQuery } from "#/__generated__/core/CommonThirdPartyComboboxQuery.graphql";
import type { CreateVendorInput } from "#/__generated__/core/VendorGraphCreateMutation.graphql";
import type { CreateThirdPartyInput } from "#/__generated__/core/ThirdPartyGraphCreateMutation.graphql";
export type CommonThirdPartyRef
= CommonThirdPartyCombobox_commonThirdParty$data;
@@ -59,7 +59,7 @@ export const commonThirdPartiesQuery = graphql`
interface CommonThirdPartyComboboxProps {
queryRef: PreloadedQuery<CommonThirdPartyComboboxQuery>;
onSelect: (thridParty: Omit<CreateVendorInput, "organizationId">) => void;
onSelect: (thridParty: Omit<CreateThirdPartyInput, "organizationId">) => void;
}
export function CommonThirdPartyCombobox({

View File

@@ -33,20 +33,20 @@ import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
type Props = {
children: ReactNode;
connectionId: string;
vendorId: string;
thirdPartyId: string;
};
const createContactMutation = graphql`
mutation CreateContactDialogMutation(
$input: CreateVendorContactInput!
$input: CreateThirdPartyContactInput!
$connections: [ID!]!
) {
createVendorContact(input: $input) {
vendorContactEdge @prependEdge(connections: $connections) {
createThirdPartyContact(input: $input) {
thirdPartyContactEdge @prependEdge(connections: $connections) {
node {
canUpdate: permission(action: "core:vendor-contact:update")
canDelete: permission(action: "core:vendor-contact:delete")
...VendorContactsTabFragment_contact
canUpdate: permission(action: "core:thirdParty-contact:update")
canDelete: permission(action: "core:thirdParty-contact:delete")
...ThirdPartyContactsTabFragment_contact
}
}
}
@@ -58,7 +58,7 @@ const phoneRegex = /^\+[0-9]{8,15}$/;
export function CreateContactDialog({
children,
connectionId,
vendorId,
thirdPartyId,
}: Props) {
const { __ } = useTranslate();
@@ -107,7 +107,7 @@ export function CreateContactDialog({
await createContact({
variables: {
input: {
vendorId,
thirdPartyId,
...cleanData,
},
connections: [connectionId],

View File

@@ -35,18 +35,18 @@ import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
type Props = {
children: ReactNode;
connection: string;
vendorId: string;
thirdPartyId: string;
};
const createRiskAssessmentMutation = graphql`
mutation CreateRiskAssessmentDialogMutation(
$input: CreateVendorRiskAssessmentInput!
$input: CreateThirdPartyRiskAssessmentInput!
$connections: [ID!]!
) {
createVendorRiskAssessment(input: $input) {
vendorRiskAssessmentEdge @prependEdge(connections: $connections) {
createThirdPartyRiskAssessment(input: $input) {
thirdPartyRiskAssessmentEdge @prependEdge(connections: $connections) {
node {
...VendorRiskAssessmentTabFragment_assessment
...ThirdPartyRiskAssessmentTabFragment_assessment
}
}
}
@@ -65,7 +65,7 @@ const schema = z.object({
export function CreateRiskAssessmentDialog({
children,
connection,
vendorId,
thirdPartyId,
}: Props) {
const { __ } = useTranslate();
@@ -92,7 +92,7 @@ export function CreateRiskAssessmentDialog({
input: {
...data,
notes: data.notes || null,
vendorId,
thirdPartyId,
expiresAt: nextYear.toISOString(),
},
connections: [connection],

View File

@@ -33,18 +33,18 @@ import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
type Props = {
children: ReactNode;
connectionId: string;
vendorId: string;
thirdPartyId: string;
};
const createServiceMutation = graphql`
mutation CreateServiceDialogMutation(
$input: CreateVendorServiceInput!
$input: CreateThirdPartyServiceInput!
$connections: [ID!]!
) {
createVendorService(input: $input) {
vendorServiceEdge @prependEdge(connections: $connections) {
createThirdPartyService(input: $input) {
thirdPartyServiceEdge @prependEdge(connections: $connections) {
node {
...VendorServicesTabFragment_service
...ThirdPartyServicesTabFragment_service
}
}
}
@@ -54,7 +54,7 @@ const createServiceMutation = graphql`
export function CreateServiceDialog({
children,
connectionId,
vendorId,
thirdPartyId,
}: Props) {
const { __ } = useTranslate();
@@ -86,7 +86,7 @@ export function CreateServiceDialog({
await createService({
variables: {
input: {
vendorId,
thirdPartyId,
...cleanData,
},
connections: [connectionId],

View File

@@ -27,8 +27,8 @@ import { useQueryLoader } from "react-relay";
import { useDebounceCallback } from "usehooks-ts";
import type { CommonThirdPartyComboboxQuery } from "#/__generated__/core/CommonThirdPartyComboboxQuery.graphql";
import type { CreateVendorInput } from "#/__generated__/core/VendorGraphCreateMutation.graphql";
import { useCreateVendorMutation } from "#/hooks/graph/VendorGraph";
import type { CreateThirdPartyInput } from "#/__generated__/core/ThirdPartyGraphCreateMutation.graphql";
import { useCreateThirdPartyMutation } from "#/hooks/graph/ThirdPartyGraph";
import {
commonThirdPartiesQuery,
@@ -41,19 +41,19 @@ type Props = {
connection: string;
};
export function CreateVendorDialog({
export function CreateThirdPartyDialog({
children,
organizationId,
connection,
}: Props) {
const { __ } = useTranslate();
const [createVendor] = useCreateVendorMutation();
const [createThirdParty] = useCreateThirdPartyMutation();
const dialogRef = useDialogRef();
const [searchQuery, setSearchQuery] = useState("");
const [queryRef, loadQuery]
= useQueryLoader<CommonThirdPartyComboboxQuery>(commonThirdPartiesQuery);
const onSelect = async (thirdParty: Omit<CreateVendorInput, "organizationId"> | string) => {
const onSelect = async (thirdParty: Omit<CreateThirdPartyInput, "organizationId"> | string) => {
const input
= typeof thirdParty === "string"
? {
@@ -65,7 +65,7 @@ export function CreateVendorDialog({
...thirdParty,
organizationId,
};
await createVendor({
await createThirdParty({
variables: {
input,
connections: [connection],
@@ -95,9 +95,9 @@ export function CreateVendorDialog({
};
return (
<Dialog ref={dialogRef} trigger={children} title={__("Add a vendor")}>
<Dialog ref={dialogRef} trigger={children} title={__("Add a third party")}>
<DialogContent className="p-6">
<Combobox onSearch={handleSearch} placeholder={__("Type vendor's name")}>
<Combobox onSearch={handleSearch} placeholder={__("Type third party's name")}>
{searchQuery.trim().length >= 2 && queryRef && (
<Suspense>
<CommonThirdPartyCombobox
@@ -109,7 +109,7 @@ export function CreateVendorDialog({
{searchQuery.trim().length >= 2 && (
<ComboboxItem onClick={() => void onSelect(searchQuery.trim())}>
<IconPlusLarge size={20} />
{__("Create a new vendor")}
{__("Create a new third party")}
{" "}
:
{searchQuery}

View File

@@ -28,24 +28,24 @@ import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
const deleteBusinessAssociateAgreementMutation = graphql`
mutation DeleteBusinessAssociateAgreementDialogMutation(
$input: DeleteVendorBusinessAssociateAgreementInput!
$input: DeleteThirdPartyBusinessAssociateAgreementInput!
) {
deleteVendorBusinessAssociateAgreement(input: $input) {
deletedVendorId
deleteThirdPartyBusinessAssociateAgreement(input: $input) {
deletedThirdPartyId
}
}
`;
type Props = {
children: React.ReactNode;
vendorId: string;
thirdPartyId: string;
fileName: string;
onSuccess?: () => void;
};
export function DeleteBusinessAssociateAgreementDialog({
children,
vendorId,
thirdPartyId,
fileName,
onSuccess,
}: Props) {
@@ -61,7 +61,7 @@ export function DeleteBusinessAssociateAgreementDialog({
await mutate({
variables: {
input: {
vendorId,
thirdPartyId,
},
},
});

View File

@@ -28,24 +28,24 @@ import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
const deleteDataPrivacyAgreementMutation = graphql`
mutation DeleteDataPrivacyAgreementDialogMutation(
$input: DeleteVendorDataPrivacyAgreementInput!
$input: DeleteThirdPartyDataPrivacyAgreementInput!
) {
deleteVendorDataPrivacyAgreement(input: $input) {
deletedVendorId
deleteThirdPartyDataPrivacyAgreement(input: $input) {
deletedThirdPartyId
}
}
`;
type Props = {
children: React.ReactNode;
vendorId: string;
thirdPartyId: string;
fileName: string;
onSuccess?: () => void;
};
export function DeleteDataPrivacyAgreementDialog({
children,
vendorId,
thirdPartyId,
fileName,
onSuccess,
}: Props) {
@@ -61,7 +61,7 @@ export function DeleteDataPrivacyAgreementDialog({
await mutate({
variables: {
input: {
vendorId,
thirdPartyId,
},
},
});

View File

@@ -31,10 +31,10 @@ import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
const updateBusinessAssociateAgreementMutation = graphql`
mutation EditBusinessAssociateAgreementDialogMutation(
$input: UpdateVendorBusinessAssociateAgreementInput!
$input: UpdateThirdPartyBusinessAssociateAgreementInput!
) {
updateVendorBusinessAssociateAgreement(input: $input) {
vendorBusinessAssociateAgreement {
updateThirdPartyBusinessAssociateAgreement(input: $input) {
thirdPartyBusinessAssociateAgreement {
id
fileUrl
validFrom
@@ -52,7 +52,7 @@ const schema = z.object({
type Props = {
children: React.ReactNode;
vendorId: string;
thirdPartyId: string;
agreement: {
validFrom?: string | null;
validUntil?: string | null;
@@ -62,7 +62,7 @@ type Props = {
export function EditBusinessAssociateAgreementDialog({
children,
vendorId,
thirdPartyId,
agreement,
onSuccess,
}: Props) {
@@ -100,7 +100,7 @@ export function EditBusinessAssociateAgreementDialog({
await mutate({
variables: {
input: {
vendorId,
thirdPartyId,
validFrom: formatDatetime(data.validFrom),
validUntil: formatDatetime(data.validUntil),
},

View File

@@ -27,21 +27,21 @@ import { useEffect } from "react";
import { graphql } from "relay-runtime";
import { z } from "zod";
import type { VendorContactsTabFragment_contact$data } from "#/__generated__/core/VendorContactsTabFragment_contact.graphql";
import type { ThirdPartyContactsTabFragment_contact$data } from "#/__generated__/core/ThirdPartyContactsTabFragment_contact.graphql";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
type Props = {
contactId: string;
contact: VendorContactsTabFragment_contact$data;
contact: ThirdPartyContactsTabFragment_contact$data;
onClose: () => void;
};
const updateContactMutation = graphql`
mutation EditContactDialogUpdateMutation($input: UpdateVendorContactInput!) {
updateVendorContact(input: $input) {
vendorContact {
...VendorContactsTabFragment_contact
mutation EditContactDialogUpdateMutation($input: UpdateThirdPartyContactInput!) {
updateThirdPartyContact(input: $input) {
thirdPartyContact {
...ThirdPartyContactsTabFragment_contact
}
}
}

View File

@@ -31,10 +31,10 @@ import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
const updateDataPrivacyAgreementMutation = graphql`
mutation EditDataPrivacyAgreementDialogMutation(
$input: UpdateVendorDataPrivacyAgreementInput!
$input: UpdateThirdPartyDataPrivacyAgreementInput!
) {
updateVendorDataPrivacyAgreement(input: $input) {
vendorDataPrivacyAgreement {
updateThirdPartyDataPrivacyAgreement(input: $input) {
thirdPartyDataPrivacyAgreement {
id
fileUrl
validFrom
@@ -52,7 +52,7 @@ const schema = z.object({
type Props = {
children: React.ReactNode;
vendorId: string;
thirdPartyId: string;
agreement: {
validFrom?: string | null;
validUntil?: string | null;
@@ -62,7 +62,7 @@ type Props = {
export function EditDataPrivacyAgreementDialog({
children,
vendorId,
thirdPartyId,
agreement,
onSuccess,
}: Props) {
@@ -100,7 +100,7 @@ export function EditDataPrivacyAgreementDialog({
await mutate({
variables: {
input: {
vendorId,
thirdPartyId,
validFrom: formatDatetime(data.validFrom),
validUntil: formatDatetime(data.validUntil),
},

View File

@@ -40,10 +40,10 @@ type Props = {
};
const updateServiceMutation = graphql`
mutation EditServiceDialogUpdateMutation($input: UpdateVendorServiceInput!) {
updateVendorService(input: $input) {
vendorService {
...VendorServicesTabFragment_service
mutation EditServiceDialogUpdateMutation($input: UpdateThirdPartyServiceInput!) {
updateThirdPartyService(input: $input) {
thirdPartyService {
...ThirdPartyServicesTabFragment_service
}
}
}

View File

@@ -33,26 +33,26 @@ const schema = z.object({
});
const importAssessmentMutation = graphql`
mutation ImportAssessmentDialogMutation($input: AssessVendorInput!) {
assessVendor(input: $input) {
vendor {
mutation ImportAssessmentDialogMutation($input: AssessThirdPartyInput!) {
assessThirdParty(input: $input) {
thirdParty {
id
name
websiteUrl
...useVendorFormFragment
...VendorComplianceTabFragment
...VendorRiskAssessmentTabFragment
...useThirdPartyFormFragment
...ThirdPartyComplianceTabFragment
...ThirdPartyRiskAssessmentTabFragment
}
}
}
`;
type Props = {
vendorId: string;
thirdPartyId: string;
children: ReactNode;
};
export function ImportAssessmentDialog({ vendorId, children }: Props) {
export function ImportAssessmentDialog({ thirdPartyId, children }: Props) {
const { __ } = useTranslate();
const dialogRef = useDialogRef();
const { register, handleSubmit, reset, formState } = useFormWithSchema(
@@ -66,8 +66,8 @@ export function ImportAssessmentDialog({ vendorId, children }: Props) {
const [assess, isAssessing] = useMutationWithToasts(
importAssessmentMutation,
{
successMessage: __("Vendor assessed successfully."),
errorMessage: __("Failed to assess vendor"),
successMessage: __("Third party assessed successfully."),
errorMessage: __("Failed to assess third party"),
},
);
@@ -75,7 +75,7 @@ export function ImportAssessmentDialog({ vendorId, children }: Props) {
await assess({
variables: {
input: {
id: vendorId,
id: thirdPartyId,
websiteUrl: data.url,
},
},

View File

@@ -30,15 +30,15 @@ import { useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import { z } from "zod";
import type { PublishVendorListDialogMutation } from "#/__generated__/core/PublishVendorListDialogMutation.graphql";
import type { PublishThirdPartyListDialogMutation } from "#/__generated__/core/PublishThirdPartyListDialogMutation.graphql";
import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
const publishMutation = graphql`
mutation PublishVendorListDialogMutation(
$input: PublishVendorListInput!
mutation PublishThirdPartyListDialogMutation(
$input: PublishThirdPartyListInput!
) {
publishVendorList(input: $input) {
publishThirdPartyList(input: $input) {
documentEdge {
node {
id
@@ -55,7 +55,7 @@ type Props = {
onPublished?: (documentId: string) => void;
};
export function PublishVendorListDialog({
export function PublishThirdPartyListDialog({
children,
organizationId,
defaultApproverIds,
@@ -82,7 +82,7 @@ export function PublishVendorListDialog({
});
const [publish, isPublishing]
= useMutation<PublishVendorListDialogMutation>(publishMutation);
= useMutation<PublishThirdPartyListDialogMutation>(publishMutation);
const minorRef = useRef(false);
@@ -99,13 +99,13 @@ export function PublishVendorListDialog({
},
},
onCompleted(response) {
const documentId = response.publishVendorList?.documentEdge?.node?.id;
const documentId = response.publishThirdPartyList?.documentEdge?.node?.id;
if (documentId) {
toast({
title: __("Success"),
description: hasApprovers
? __("Approval requested successfully.")
: __("Vendors published successfully."),
: __("Third parties published successfully."),
variant: "success",
});
dialogRef.current?.close();
@@ -117,7 +117,7 @@ export function PublishVendorListDialog({
toast({
title: __("Error"),
description: formatError(
__("Failed to publish vendors"),
__("Failed to publish third parties"),
error as GraphQLError,
),
variant: "error",
@@ -131,7 +131,7 @@ export function PublishVendorListDialog({
className="max-w-xl"
ref={dialogRef}
trigger={children}
title={__("Publish Vendors")}
title={__("Publish third parties")}
>
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
<DialogContent padded>

View File

@@ -33,10 +33,10 @@ import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
const uploadBusinessAssociateAgreementMutation = graphql`
mutation UploadBusinessAssociateAgreementDialogMutation(
$input: UploadVendorBusinessAssociateAgreementInput!
$input: UploadThirdPartyBusinessAssociateAgreementInput!
) {
uploadVendorBusinessAssociateAgreement(input: $input) {
vendorBusinessAssociateAgreement {
uploadThirdPartyBusinessAssociateAgreement(input: $input) {
thirdPartyBusinessAssociateAgreement {
id
fileName
fileUrl
@@ -56,13 +56,13 @@ const schema = z.object({
type Props = {
children: React.ReactNode;
vendorId: string;
thirdPartyId: string;
onSuccess?: () => void;
};
export function UploadBusinessAssociateAgreementDialog({
children,
vendorId,
thirdPartyId,
onSuccess,
}: Props) {
const { __ } = useTranslate();
@@ -109,7 +109,7 @@ export function UploadBusinessAssociateAgreementDialog({
await mutate({
variables: {
input: {
vendorId,
thirdPartyId,
fileName: data.fileName,
validFrom: formatDatetime(data.validFrom),
validUntil: formatDatetime(data.validUntil),

View File

@@ -34,11 +34,11 @@ import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
const uploadComplianceReportMutation = graphql`
mutation UploadComplianceReportDialogMutation(
$input: UploadVendorComplianceReportInput!
$input: UploadThirdPartyComplianceReportInput!
$connections: [ID!]!
) {
uploadVendorComplianceReport(input: $input) {
vendorComplianceReportEdge @appendEdge(connections: $connections) {
uploadThirdPartyComplianceReport(input: $input) {
thirdPartyComplianceReportEdge @appendEdge(connections: $connections) {
node {
id
reportName
@@ -50,7 +50,7 @@ const uploadComplianceReportMutation = graphql`
size
downloadUrl
}
canDelete: permission(action: "core:vendor-compliance-report:delete")
canDelete: permission(action: "core:thirdParty-compliance-report:delete")
}
}
}
@@ -64,14 +64,14 @@ const schema = z.object({
type Props = {
children: React.ReactNode;
vendorId: string;
thirdPartyId: string;
connectionId: string;
onSuccess?: () => void;
};
export function UploadComplianceReportDialog({
children,
vendorId,
thirdPartyId,
connectionId,
onSuccess,
}: Props) {
@@ -111,7 +111,7 @@ export function UploadComplianceReportDialog({
variables: {
connections: [connectionId],
input: {
vendorId,
thirdPartyId,
reportName: uploadedFile.name,
reportDate: `${data.reportDate}T00:00:00Z`,
validUntil: formatDatetime(data.validUntil),

View File

@@ -33,10 +33,10 @@ import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
const uploadDataPrivacyAgreementMutation = graphql`
mutation UploadDataPrivacyAgreementDialogMutation(
$input: UploadVendorDataPrivacyAgreementInput!
$input: UploadThirdPartyDataPrivacyAgreementInput!
) {
uploadVendorDataPrivacyAgreement(input: $input) {
vendorDataPrivacyAgreement {
uploadThirdPartyDataPrivacyAgreement(input: $input) {
thirdPartyDataPrivacyAgreement {
id
fileName
fileUrl
@@ -56,13 +56,13 @@ const schema = z.object({
type Props = {
children: React.ReactNode;
vendorId: string;
thirdPartyId: string;
onSuccess?: () => void;
};
export function UploadDataPrivacyAgreementDialog({
children,
vendorId,
thirdPartyId,
onSuccess,
}: Props) {
const { __ } = useTranslate();
@@ -109,7 +109,7 @@ export function UploadDataPrivacyAgreementDialog({
await mutate({
variables: {
input: {
vendorId,
thirdPartyId,
fileName: data.fileName,
validFrom: formatDatetime(data.validFrom),
validUntil: formatDatetime(data.validUntil),

View File

@@ -32,23 +32,23 @@ import { useState } from "react";
import { Controller } from "react-hook-form";
import { useOutletContext } from "react-router";
import type { VendorGraphNodeQuery$data } from "#/__generated__/core/VendorGraphNodeQuery.graphql";
import { useVendorForm } from "#/hooks/forms/useVendorForm";
import type { ThirdPartyGraphNodeQuery$data } from "#/__generated__/core/ThirdPartyGraphNodeQuery.graphql";
import { useThirdPartyForm } from "#/hooks/forms/useThirdPartyForm";
/**
* Vendor certifications tab
* ThirdParty certifications tab
*/
export default function VendorCertificationsTab() {
const { vendor } = useOutletContext<{
vendor: VendorGraphNodeQuery$data["node"];
export default function ThirdPartyCertificationsTab() {
const { thirdParty } = useOutletContext<{
thirdParty: ThirdPartyGraphNodeQuery$data["node"];
}>();
const { __ } = useTranslate();
const { control, handleSubmit } = useVendorForm(vendor);
const { control, handleSubmit } = useThirdPartyForm(thirdParty);
return (
<form
className="space-y-4"
onSubmit={vendor.canUpdate
onSubmit={thirdParty.canUpdate
? e => void handleSubmit(e)
: undefined}
>
@@ -60,14 +60,14 @@ export default function VendorCertificationsTab() {
<Certifications
onValueChange={field.onChange}
value={field.value ?? []}
readOnly={!vendor.canUpdate}
readOnly={!thirdParty.canUpdate}
/>
)}
/>
</Card>
{vendor.canUpdate && (
{thirdParty.canUpdate && (
<div className="flex justify-end">
<Button type="submit">{__("Update vendor")}</Button>
<Button type="submit">{__("Update third party")}</Button>
</div>
)}
</form>

View File

@@ -36,20 +36,20 @@ import { useOutletContext } from "react-router";
import { graphql } from "relay-runtime";
import type { ComplianceReportListQuery } from "#/__generated__/core/ComplianceReportListQuery.graphql";
import type { VendorComplianceTabFragment$key } from "#/__generated__/core/VendorComplianceTabFragment.graphql";
import type { VendorComplianceTabFragment_report$key } from "#/__generated__/core/VendorComplianceTabFragment_report.graphql";
import type { VendorGraphNodeQuery$data } from "#/__generated__/core/VendorGraphNodeQuery.graphql";
import type { ThirdPartyComplianceTabFragment$key } from "#/__generated__/core/ThirdPartyComplianceTabFragment.graphql";
import type { ThirdPartyComplianceTabFragment_report$key } from "#/__generated__/core/ThirdPartyComplianceTabFragment_report.graphql";
import type { ThirdPartyGraphNodeQuery$data } from "#/__generated__/core/ThirdPartyGraphNodeQuery.graphql";
import { SortableTable, SortableTh } from "#/components/SortableTable";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import { UploadComplianceReportDialog } from "../dialogs/UploadComplianceReportDialog";
export const complianceReportsFragment = graphql`
fragment VendorComplianceTabFragment on Vendor
fragment ThirdPartyComplianceTabFragment on ThirdParty
@refetchable(queryName: "ComplianceReportListQuery")
@argumentDefinitions(
first: { type: "Int", defaultValue: 50 }
order: { type: "VendorComplianceReportOrder", defaultValue: null }
order: { type: "ThirdPartyComplianceReportOrder", defaultValue: null }
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
@@ -60,13 +60,13 @@ export const complianceReportsFragment = graphql`
last: $last
before: $before
orderBy: $order
) @connection(key: "VendorComplianceTabFragment_complianceReports") {
) @connection(key: "ThirdPartyComplianceTabFragment_complianceReports") {
__id
edges {
node {
id
canDelete: permission(action: "core:vendor-compliance-report:delete")
...VendorComplianceTabFragment_report
canDelete: permission(action: "core:thirdParty-compliance-report:delete")
...ThirdPartyComplianceTabFragment_report
}
}
}
@@ -74,7 +74,7 @@ export const complianceReportsFragment = graphql`
`;
const complianceReportFragment = graphql`
fragment VendorComplianceTabFragment_report on VendorComplianceReport {
fragment ThirdPartyComplianceTabFragment_report on ThirdPartyComplianceReport {
id
reportDate
validUntil
@@ -84,43 +84,43 @@ const complianceReportFragment = graphql`
size
downloadUrl
}
canDelete: permission(action: "core:vendor-compliance-report:delete")
canDelete: permission(action: "core:thirdParty-compliance-report:delete")
}
`;
const deleteReportMutation = graphql`
mutation VendorComplianceTabDeleteReportMutation(
$input: DeleteVendorComplianceReportInput!
mutation ThirdPartyComplianceTabDeleteReportMutation(
$input: DeleteThirdPartyComplianceReportInput!
$connections: [ID!]!
) {
deleteVendorComplianceReport(input: $input) {
deletedVendorComplianceReportId @deleteEdge(connections: $connections)
deleteThirdPartyComplianceReport(input: $input) {
deletedThirdPartyComplianceReportId @deleteEdge(connections: $connections)
}
}
`;
export default function VendorComplianceTab() {
const { vendor } = useOutletContext<{
vendor: VendorGraphNodeQuery$data["node"];
export default function ThirdPartyComplianceTab() {
const { thirdParty } = useOutletContext<{
thirdParty: ThirdPartyGraphNodeQuery$data["node"];
}>();
const [data, refetch] = useRefetchableFragment<
ComplianceReportListQuery,
VendorComplianceTabFragment$key
>(complianceReportsFragment, vendor);
ThirdPartyComplianceTabFragment$key
>(complianceReportsFragment, thirdParty);
const connectionId = data.complianceReports.__id;
const reports = data.complianceReports.edges.map(edge => edge.node);
const { __ } = useTranslate();
usePageTitle(vendor.name + " - " + __("Compliance reports"));
usePageTitle(thirdParty.name + " - " + __("Compliance reports"));
return (
<div className="space-y-6">
<PageHeader
title={__("Compliance reports")}
description={__("Track vendor compliance certifications and reports.")}
description={__("Track third party compliance certifications and reports.")}
>
{vendor.canUploadComplianceReport && (
{thirdParty.canUploadComplianceReport && (
<UploadComplianceReportDialog
vendorId={vendor.id}
thirdPartyId={thirdParty.id}
connectionId={connectionId}
>
<Button icon={IconPlusLarge}>{__("Add report")}</Button>
@@ -155,13 +155,13 @@ export default function VendorComplianceTab() {
}
type ReportRowProps = {
reportKey: VendorComplianceTabFragment_report$key;
reportKey: ThirdPartyComplianceTabFragment_report$key;
connectionId: string;
};
function ReportRow(props: ReportRowProps) {
const { __ } = useTranslate();
const report = useFragment<VendorComplianceTabFragment_report$key>(
const report = useFragment<ThirdPartyComplianceTabFragment_report$key>(
complianceReportFragment,
props.reportKey,
);

View File

@@ -35,25 +35,25 @@ import { useFragment, useRefetchableFragment } from "react-relay";
import { useOutletContext } from "react-router";
import { graphql } from "relay-runtime";
import type { VendorContactsListQuery } from "#/__generated__/core/VendorContactsListQuery.graphql";
import type { VendorContactsTabFragment$key } from "#/__generated__/core/VendorContactsTabFragment.graphql";
import type { ThirdPartyContactsListQuery } from "#/__generated__/core/ThirdPartyContactsListQuery.graphql";
import type { ThirdPartyContactsTabFragment$key } from "#/__generated__/core/ThirdPartyContactsTabFragment.graphql";
import type {
VendorContactsTabFragment_contact$data,
VendorContactsTabFragment_contact$key,
} from "#/__generated__/core/VendorContactsTabFragment_contact.graphql";
import type { VendorGraphNodeQuery$data } from "#/__generated__/core/VendorGraphNodeQuery.graphql";
ThirdPartyContactsTabFragment_contact$data,
ThirdPartyContactsTabFragment_contact$key,
} from "#/__generated__/core/ThirdPartyContactsTabFragment_contact.graphql";
import type { ThirdPartyGraphNodeQuery$data } from "#/__generated__/core/ThirdPartyGraphNodeQuery.graphql";
import { SortableTable, SortableTh } from "#/components/SortableTable";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import { CreateContactDialog } from "../dialogs/CreateContactDialog";
import { EditContactDialog } from "../dialogs/EditContactDialog";
export const vendorContactsFragment = graphql`
fragment VendorContactsTabFragment on Vendor
@refetchable(queryName: "VendorContactsListQuery")
export const thirdPartyContactsFragment = graphql`
fragment ThirdPartyContactsTabFragment on ThirdParty
@refetchable(queryName: "ThirdPartyContactsListQuery")
@argumentDefinitions(
first: { type: "Int", defaultValue: 50 }
order: { type: "VendorContactOrder", defaultValue: null }
order: { type: "ThirdPartyContactOrder", defaultValue: null }
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
@@ -64,14 +64,14 @@ export const vendorContactsFragment = graphql`
last: $last
before: $before
orderBy: $order
) @connection(key: "VendorContactsTabFragment_contacts") {
) @connection(key: "ThirdPartyContactsTabFragment_contacts") {
__id
edges {
node {
id
canUpdate: permission(action: "core:vendor-contact:update")
canDelete: permission(action: "core:vendor-contact:delete")
...VendorContactsTabFragment_contact
canUpdate: permission(action: "core:thirdParty-contact:update")
canDelete: permission(action: "core:thirdParty-contact:delete")
...ThirdPartyContactsTabFragment_contact
}
}
}
@@ -79,55 +79,55 @@ export const vendorContactsFragment = graphql`
`;
const contactFragment = graphql`
fragment VendorContactsTabFragment_contact on VendorContact {
fragment ThirdPartyContactsTabFragment_contact on ThirdPartyContact {
id
fullName
email
phone
role
canUpdate: permission(action: "core:vendor-contact:update")
canDelete: permission(action: "core:vendor-contact:delete")
canUpdate: permission(action: "core:thirdParty-contact:update")
canDelete: permission(action: "core:thirdParty-contact:delete")
}
`;
const deleteContactMutation = graphql`
mutation VendorContactsTabDeleteContactMutation(
$input: DeleteVendorContactInput!
mutation ThirdPartyContactsTabDeleteContactMutation(
$input: DeleteThirdPartyContactInput!
$connections: [ID!]!
) {
deleteVendorContact(input: $input) {
deletedVendorContactId @deleteEdge(connections: $connections)
deleteThirdPartyContact(input: $input) {
deletedThirdPartyContactId @deleteEdge(connections: $connections)
}
}
`;
export default function VendorContactsTab() {
const { vendor } = useOutletContext<{
vendor: VendorGraphNodeQuery$data["node"];
export default function ThirdPartyContactsTab() {
const { thirdParty } = useOutletContext<{
thirdParty: ThirdPartyGraphNodeQuery$data["node"];
}>();
const [data, refetch] = useRefetchableFragment<
VendorContactsListQuery,
VendorContactsTabFragment$key
>(vendorContactsFragment, vendor);
ThirdPartyContactsListQuery,
ThirdPartyContactsTabFragment$key
>(thirdPartyContactsFragment, thirdParty);
const connectionId = data.contacts.__id;
const contacts = data.contacts.edges.map(edge => edge.node);
const { __ } = useTranslate();
const [editingContact, setEditingContact]
= useState<VendorContactsTabFragment_contact$data | null>(null);
= useState<ThirdPartyContactsTabFragment_contact$data | null>(null);
const hasAnyAction = contacts.some(
({ canUpdate, canDelete }) => canUpdate || canDelete,
);
usePageTitle(vendor.name + " - " + __("Contacts"));
usePageTitle(thirdParty.name + " - " + __("Contacts"));
return (
<div className="space-y-6">
<PageHeader
title={__("Contacts")}
description={__("Manage vendor contacts and their information.")}
description={__("Manage third party contacts and their information.")}
>
{vendor.canCreateContact && (
<CreateContactDialog vendorId={vendor.id} connectionId={connectionId}>
{thirdParty.canCreateContact && (
<CreateContactDialog thirdPartyId={thirdParty.id} connectionId={connectionId}>
<Button icon={IconPlusLarge}>{__("Add contact")}</Button>
</CreateContactDialog>
)}
@@ -169,14 +169,14 @@ export default function VendorContactsTab() {
}
type ContactRowProps = {
contactKey: VendorContactsTabFragment_contact$key;
contactKey: ThirdPartyContactsTabFragment_contact$key;
connectionId: string;
onEdit: (contact: VendorContactsTabFragment_contact$data) => void;
onEdit: (contact: ThirdPartyContactsTabFragment_contact$data) => void;
};
function ContactRow(props: ContactRowProps) {
const { __ } = useTranslate();
const contact = useFragment<VendorContactsTabFragment_contact$key>(
const contact = useFragment<ThirdPartyContactsTabFragment_contact$key>(
contactFragment,
props.contactKey,
);
@@ -194,7 +194,7 @@ function ContactRow(props: ContactRowProps) {
variables: {
connections: [props.connectionId],
input: {
vendorContactId: contact.id,
thirdPartyContactId: contact.id,
},
},
}),

View File

@@ -15,6 +15,7 @@
import { downloadFile, formatDate } from "@probo/helpers";
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import type { ThirdPartyCategory } from "@probo/third-parties";
import {
Button,
Card,
@@ -25,18 +26,17 @@ import {
Input,
Option,
} from "@probo/ui";
import type { VendorCategory } from "@probo/vendors";
import { useMemo } from "react";
import { graphql, useFragment } from "react-relay";
import { useOutletContext } from "react-router";
import type { VendorGraphNodeQuery$data } from "#/__generated__/core/VendorGraphNodeQuery.graphql";
import type { VendorOverviewTabBusinessAssociateAgreementFragment$key } from "#/__generated__/core/VendorOverviewTabBusinessAssociateAgreementFragment.graphql";
import type { VendorOverviewTabDataPrivacyAgreementFragment$key } from "#/__generated__/core/VendorOverviewTabDataPrivacyAgreementFragment.graphql";
import type { ThirdPartyGraphNodeQuery$data } from "#/__generated__/core/ThirdPartyGraphNodeQuery.graphql";
import type { ThirdPartyOverviewTabBusinessAssociateAgreementFragment$key } from "#/__generated__/core/ThirdPartyOverviewTabBusinessAssociateAgreementFragment.graphql";
import type { ThirdPartyOverviewTabDataPrivacyAgreementFragment$key } from "#/__generated__/core/ThirdPartyOverviewTabDataPrivacyAgreementFragment.graphql";
import { ControlledField } from "#/components/form/ControlledField";
import { CountriesField } from "#/components/form/CountriesField";
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
import { useVendorForm } from "#/hooks/forms/useVendorForm";
import { useThirdPartyForm } from "#/hooks/forms/useThirdPartyForm";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { DeleteBusinessAssociateAgreementDialog } from "../dialogs/DeleteBusinessAssociateAgreementDialog";
@@ -46,8 +46,8 @@ import { EditDataPrivacyAgreementDialog } from "../dialogs/EditDataPrivacyAgreem
import { UploadBusinessAssociateAgreementDialog } from "../dialogs/UploadBusinessAssociateAgreementDialog";
import { UploadDataPrivacyAgreementDialog } from "../dialogs/UploadDataPrivacyAgreementDialog";
const vendorBusinessAssociateAgreementFragment = graphql`
fragment VendorOverviewTabBusinessAssociateAgreementFragment on Vendor {
const thirdPartyBusinessAssociateAgreementFragment = graphql`
fragment ThirdPartyOverviewTabBusinessAssociateAgreementFragment on ThirdParty {
businessAssociateAgreement {
id
fileName
@@ -55,36 +55,36 @@ const vendorBusinessAssociateAgreementFragment = graphql`
validFrom
validUntil
canUpdate: permission(
action: "core:vendor-business-associate-agreement:update"
action: "core:thirdParty-business-associate-agreement:update"
)
canDelete: permission(
action: "core:vendor-business-associate-agreement:delete"
action: "core:thirdParty-business-associate-agreement:delete"
)
}
}
`;
const vendorDataPrivacyAgreementFragment = graphql`
fragment VendorOverviewTabDataPrivacyAgreementFragment on Vendor {
const thirdPartyDataPrivacyAgreementFragment = graphql`
fragment ThirdPartyOverviewTabDataPrivacyAgreementFragment on ThirdParty {
dataPrivacyAgreement {
id
fileName
fileUrl
validFrom
validUntil
canUpdate: permission(action: "core:vendor-data-privacy-agreement:update")
canDelete: permission(action: "core:vendor-data-privacy-agreement:delete")
canUpdate: permission(action: "core:thirdParty-data-privacy-agreement:update")
canDelete: permission(action: "core:thirdParty-data-privacy-agreement:delete")
}
}
`;
export default function VendorOverviewTab() {
const { vendor } = useOutletContext<{
vendor: VendorGraphNodeQuery$data["node"];
export default function ThirdPartyOverviewTab() {
const { thirdParty } = useOutletContext<{
thirdParty: ThirdPartyGraphNodeQuery$data["node"];
}>();
const { __ } = useTranslate();
const vendorCategories: { value: VendorCategory; label: string }[] = [
const thirdPartyCategories: { value: ThirdPartyCategory; label: string }[] = [
{ value: "ANALYTICS", label: __("Analytics") },
{ value: "CLOUD_MONITORING", label: __("Cloud Monitoring") },
{ value: "CLOUD_PROVIDER", label: __("Cloud Provider") },
@@ -118,21 +118,21 @@ export default function VendorOverviewTab() {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useVendorForm(vendor);
} = useThirdPartyForm(thirdParty);
const vendorWithBAA
= useFragment<VendorOverviewTabBusinessAssociateAgreementFragment$key>(
vendorBusinessAssociateAgreementFragment,
vendor,
const thirdPartyWithBAA
= useFragment<ThirdPartyOverviewTabBusinessAssociateAgreementFragment$key>(
thirdPartyBusinessAssociateAgreementFragment,
thirdParty,
);
const businessAssociateAgreement = vendorWithBAA.businessAssociateAgreement;
const businessAssociateAgreement = thirdPartyWithBAA.businessAssociateAgreement;
const vendorWithDPA
= useFragment<VendorOverviewTabDataPrivacyAgreementFragment$key>(
vendorDataPrivacyAgreementFragment,
vendor,
const thirdPartyWithDPA
= useFragment<ThirdPartyOverviewTabDataPrivacyAgreementFragment$key>(
thirdPartyDataPrivacyAgreementFragment,
thirdParty,
);
const dataPrivacyAgreement = vendorWithDPA.dataPrivacyAgreement;
const dataPrivacyAgreement = thirdPartyWithDPA.dataPrivacyAgreement;
const urls = useMemo(
() =>
@@ -154,20 +154,20 @@ export default function VendorOverviewTab() {
[__],
);
usePageTitle(vendor.name + " - " + __("Overview"));
usePageTitle(thirdParty.name + " - " + __("Overview"));
const isFormDisabled = isSubmitting || !vendor.canUpdate;
const isFormDisabled = isSubmitting || !thirdParty.canUpdate;
return (
<form
onSubmit={!vendor.canUpdate
onSubmit={!thirdParty.canUpdate
? undefined
: e => void handleSubmit(e)}
className="space-y-12"
>
{/* Vendor Details */}
{/* ThirdParty Details */}
<div className="space-y-4">
<h2 className="text-base font-medium">{__("Vendor details")}</h2>
<h2 className="text-base font-medium">{__("Third party details")}</h2>
<Card className="space-y-4" padded>
<Field
{...register("name")}
@@ -192,7 +192,7 @@ export default function VendorOverviewTab() {
error={errors.category?.message}
disabled={isFormDisabled}
>
{vendorCategories.map(category => (
{thirdPartyCategories.map(category => (
<Option key={category.value} value={category.value}>
{category.label}
</Option>
@@ -330,7 +330,7 @@ export default function VendorOverviewTab() {
</Button>
{businessAssociateAgreement.canUpdate && (
<EditBusinessAssociateAgreementDialog
vendorId={vendor.id}
thirdPartyId={thirdParty.id}
agreement={{
validFrom: businessAssociateAgreement.validFrom,
validUntil: businessAssociateAgreement.validUntil,
@@ -342,7 +342,7 @@ export default function VendorOverviewTab() {
)}
{businessAssociateAgreement.canDelete && (
<DeleteBusinessAssociateAgreementDialog
vendorId={vendor.id}
thirdPartyId={thirdParty.id}
fileName={businessAssociateAgreement.fileName}
onSuccess={() => window.location.reload()}
>
@@ -352,9 +352,9 @@ export default function VendorOverviewTab() {
</>
)
: (
vendor.canUploadBAA && (
thirdParty.canUploadBAA && (
<UploadBusinessAssociateAgreementDialog
vendorId={vendor.id}
thirdPartyId={thirdParty.id}
onSuccess={() => window.location.reload()}
>
<Button variant="secondary" icon={IconPlusLarge}>
@@ -404,7 +404,7 @@ export default function VendorOverviewTab() {
</Button>
{dataPrivacyAgreement.canUpdate && (
<EditDataPrivacyAgreementDialog
vendorId={vendor.id}
thirdPartyId={thirdParty.id}
agreement={{
validFrom: dataPrivacyAgreement.validFrom,
validUntil: dataPrivacyAgreement.validUntil,
@@ -416,7 +416,7 @@ export default function VendorOverviewTab() {
)}
{dataPrivacyAgreement.canDelete && (
<DeleteDataPrivacyAgreementDialog
vendorId={vendor.id}
thirdPartyId={thirdParty.id}
fileName={dataPrivacyAgreement.fileName}
onSuccess={() => window.location.reload()}
>
@@ -426,9 +426,9 @@ export default function VendorOverviewTab() {
</>
)
: (
vendor.canUploadDPA && (
thirdParty.canUploadDPA && (
<UploadDataPrivacyAgreementDialog
vendorId={vendor.id}
thirdPartyId={thirdParty.id}
onSuccess={() => window.location.reload()}
>
<Button variant="secondary" icon={IconPlusLarge}>
@@ -444,9 +444,9 @@ export default function VendorOverviewTab() {
{/* Submit */}
<div className="flex justify-end">
{vendor.canUpdate && (
{thirdParty.canUpdate && (
<Button type="submit" disabled={isSubmitting}>
{__("Update vendor")}
{__("Update third party")}
</Button>
)}
</div>

View File

@@ -33,20 +33,20 @@ import { useFragment, useRefetchableFragment } from "react-relay";
import { useOutletContext } from "react-router";
import { graphql } from "relay-runtime";
import type { VendorGraphNodeQuery$data } from "#/__generated__/core/VendorGraphNodeQuery.graphql";
import type { VendorRiskAssessmentTabFragment$key } from "#/__generated__/core/VendorRiskAssessmentTabFragment.graphql";
import type { VendorRiskAssessmentTabFragment_assessment$key } from "#/__generated__/core/VendorRiskAssessmentTabFragment_assessment.graphql";
import type { VendorRiskAssessmentTabQuery } from "#/__generated__/core/VendorRiskAssessmentTabQuery.graphql";
import type { ThirdPartyGraphNodeQuery$data } from "#/__generated__/core/ThirdPartyGraphNodeQuery.graphql";
import type { ThirdPartyRiskAssessmentTabFragment$key } from "#/__generated__/core/ThirdPartyRiskAssessmentTabFragment.graphql";
import type { ThirdPartyRiskAssessmentTabFragment_assessment$key } from "#/__generated__/core/ThirdPartyRiskAssessmentTabFragment_assessment.graphql";
import type { ThirdPartyRiskAssessmentTabQuery } from "#/__generated__/core/ThirdPartyRiskAssessmentTabQuery.graphql";
import { SortableTable, SortableTh } from "#/components/SortableTable";
import { CreateRiskAssessmentDialog } from "../dialogs/CreateRiskAssessmentDialog";
const riskAssessmentsFragment = graphql`
fragment VendorRiskAssessmentTabFragment on Vendor
@refetchable(queryName: "VendorRiskAssessmentTabQuery")
fragment ThirdPartyRiskAssessmentTabFragment on ThirdParty
@refetchable(queryName: "ThirdPartyRiskAssessmentTabQuery")
@argumentDefinitions(
first: { type: "Int", defaultValue: 50 }
order: { type: "VendorRiskAssessmentOrder", defaultValue: null }
order: { type: "ThirdPartyRiskAssessmentOrder", defaultValue: null }
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
@@ -59,12 +59,12 @@ const riskAssessmentsFragment = graphql`
last: $last
before: $before
orderBy: $order
) @connection(key: "VendorRiskAssessmentTabFragment_riskAssessments") {
) @connection(key: "ThirdPartyRiskAssessmentTabFragment_riskAssessments") {
__id
edges {
node {
id
...VendorRiskAssessmentTabFragment_assessment
...ThirdPartyRiskAssessmentTabFragment_assessment
}
}
pageInfo {
@@ -76,7 +76,7 @@ const riskAssessmentsFragment = graphql`
`;
const riskAssessmentFragment = graphql`
fragment VendorRiskAssessmentTabFragment_assessment on VendorRiskAssessment {
fragment ThirdPartyRiskAssessmentTabFragment_assessment on ThirdPartyRiskAssessment {
id
createdAt
expiresAt
@@ -86,27 +86,27 @@ const riskAssessmentFragment = graphql`
}
`;
export default function VendorRiskAssessmentTab() {
const { vendor } = useOutletContext<{
vendor: VendorGraphNodeQuery$data["node"];
export default function ThirdPartyRiskAssessmentTab() {
const { thirdParty } = useOutletContext<{
thirdParty: ThirdPartyGraphNodeQuery$data["node"];
}>();
const [data, refetch] = useRefetchableFragment<
VendorRiskAssessmentTabQuery,
VendorRiskAssessmentTabFragment$key
>(riskAssessmentsFragment, vendor);
ThirdPartyRiskAssessmentTabQuery,
ThirdPartyRiskAssessmentTabFragment$key
>(riskAssessmentsFragment, thirdParty);
const assessments = data.riskAssessments.edges.map(edge => edge.node);
const { __ } = useTranslate();
const [expanded, setExpanded] = useState<string | null>(null);
usePageTitle(vendor.name + " - " + __("Risk Assessments"));
usePageTitle(thirdParty.name + " - " + __("Risk Assessments"));
if (assessments.length === 0) {
return (
<div className="text-center text-sm py-6 text-txt-secondary flex flex-col items-center gap-2">
{__("No risk assessments found")}
{vendor.canCreateRiskAssessment && (
{thirdParty.canCreateRiskAssessment && (
<CreateRiskAssessmentDialog
vendorId={vendor.id}
thirdPartyId={thirdParty.id}
connection={data.riskAssessments.__id}
>
<Button icon={IconPlusLarge} variant="secondary">
@@ -134,9 +134,9 @@ export default function VendorRiskAssessmentTab() {
</Tr>
</Thead>
<Tbody>
{vendor.canCreateRiskAssessment && (
{thirdParty.canCreateRiskAssessment && (
<CreateRiskAssessmentDialog
vendorId={vendor.id}
thirdPartyId={thirdParty.id}
connection={data.riskAssessments.__id}
>
<TrButton colspan={5} onClick={() => {}}>
@@ -163,7 +163,7 @@ export default function VendorRiskAssessmentTab() {
}
type AssessmentRowProps = {
assessmentKey: VendorRiskAssessmentTabFragment_assessment$key;
assessmentKey: ThirdPartyRiskAssessmentTabFragment_assessment$key;
onClick: (id: string) => void;
isExpanded: boolean;
};
@@ -171,7 +171,7 @@ type AssessmentRowProps = {
function AssessmentRow(props: AssessmentRowProps) {
const { __ } = useTranslate();
const assessment
= useFragment<VendorRiskAssessmentTabFragment_assessment$key>(
= useFragment<ThirdPartyRiskAssessmentTabFragment_assessment$key>(
riskAssessmentFragment,
props.assessmentKey,
);

View File

@@ -35,25 +35,25 @@ import { useFragment, useRefetchableFragment } from "react-relay";
import { useOutletContext } from "react-router";
import { graphql } from "relay-runtime";
import type { VendorGraphNodeQuery$data } from "#/__generated__/core/VendorGraphNodeQuery.graphql";
import type { VendorServicesListQuery } from "#/__generated__/core/VendorServicesListQuery.graphql";
import type { VendorServicesTabFragment$key } from "#/__generated__/core/VendorServicesTabFragment.graphql";
import type { ThirdPartyGraphNodeQuery$data } from "#/__generated__/core/ThirdPartyGraphNodeQuery.graphql";
import type { ThirdPartyServicesListQuery } from "#/__generated__/core/ThirdPartyServicesListQuery.graphql";
import type { ThirdPartyServicesTabFragment$key } from "#/__generated__/core/ThirdPartyServicesTabFragment.graphql";
import type {
VendorServicesTabFragment_service$data,
VendorServicesTabFragment_service$key,
} from "#/__generated__/core/VendorServicesTabFragment_service.graphql";
ThirdPartyServicesTabFragment_service$data,
ThirdPartyServicesTabFragment_service$key,
} from "#/__generated__/core/ThirdPartyServicesTabFragment_service.graphql";
import { SortableTable, SortableTh } from "#/components/SortableTable";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import { CreateServiceDialog } from "../dialogs/CreateServiceDialog";
import { EditServiceDialog } from "../dialogs/EditServiceDialog";
export const vendorServicesFragment = graphql`
fragment VendorServicesTabFragment on Vendor
@refetchable(queryName: "VendorServicesListQuery")
export const thirdPartyServicesFragment = graphql`
fragment ThirdPartyServicesTabFragment on ThirdParty
@refetchable(queryName: "ThirdPartyServicesListQuery")
@argumentDefinitions(
first: { type: "Int", defaultValue: 50 }
order: { type: "VendorServiceOrder", defaultValue: null }
order: { type: "ThirdPartyServiceOrder", defaultValue: null }
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
@@ -64,14 +64,14 @@ export const vendorServicesFragment = graphql`
last: $last
before: $before
orderBy: $order
) @connection(key: "VendorServicesTabFragment_services") {
) @connection(key: "ThirdPartyServicesTabFragment_services") {
__id
edges {
node {
id
canUpdate: permission(action: "core:vendor-service:update")
canDelete: permission(action: "core:vendor-service:delete")
...VendorServicesTabFragment_service
canUpdate: permission(action: "core:thirdParty-service:update")
canDelete: permission(action: "core:thirdParty-service:delete")
...ThirdPartyServicesTabFragment_service
}
}
}
@@ -79,53 +79,53 @@ export const vendorServicesFragment = graphql`
`;
const serviceFragment = graphql`
fragment VendorServicesTabFragment_service on VendorService {
fragment ThirdPartyServicesTabFragment_service on ThirdPartyService {
id
name
description
canUpdate: permission(action: "core:vendor-service:update")
canDelete: permission(action: "core:vendor-service:delete")
canUpdate: permission(action: "core:thirdParty-service:update")
canDelete: permission(action: "core:thirdParty-service:delete")
}
`;
const deleteServiceMutation = graphql`
mutation VendorServicesTabDeleteServiceMutation(
$input: DeleteVendorServiceInput!
mutation ThirdPartyServicesTabDeleteServiceMutation(
$input: DeleteThirdPartyServiceInput!
$connections: [ID!]!
) {
deleteVendorService(input: $input) {
deletedVendorServiceId @deleteEdge(connections: $connections)
deleteThirdPartyService(input: $input) {
deletedThirdPartyServiceId @deleteEdge(connections: $connections)
}
}
`;
export default function VendorServicesTab() {
const { vendor } = useOutletContext<{
vendor: VendorGraphNodeQuery$data["node"];
export default function ThirdPartyServicesTab() {
const { thirdParty } = useOutletContext<{
thirdParty: ThirdPartyGraphNodeQuery$data["node"];
}>();
const [data, refetch] = useRefetchableFragment<
VendorServicesListQuery,
VendorServicesTabFragment$key
>(vendorServicesFragment, vendor);
ThirdPartyServicesListQuery,
ThirdPartyServicesTabFragment$key
>(thirdPartyServicesFragment, thirdParty);
const connectionId = data.services.__id;
const services = data.services.edges.map(edge => edge.node);
const { __ } = useTranslate();
const [editingService, setEditingService]
= useState<VendorServicesTabFragment_service$data | null>(null);
= useState<ThirdPartyServicesTabFragment_service$data | null>(null);
const hasAnyAction = services.some(
({ canUpdate, canDelete }) => canUpdate || canDelete,
);
usePageTitle(vendor.name + " - " + __("Services"));
usePageTitle(thirdParty.name + " - " + __("Services"));
return (
<div className="space-y-6">
<PageHeader
title={__("Services")}
description={__("Manage services provided by this vendor.")}
description={__("Manage services provided by this third party.")}
>
{vendor.canCreateService && (
<CreateServiceDialog vendorId={vendor.id} connectionId={connectionId}>
{thirdParty.canCreateService && (
<CreateServiceDialog thirdPartyId={thirdParty.id} connectionId={connectionId}>
<Button icon={IconPlusLarge}>{__("Add service")}</Button>
</CreateServiceDialog>
)}
@@ -165,14 +165,14 @@ export default function VendorServicesTab() {
}
type ServiceRowProps = {
serviceKey: VendorServicesTabFragment_service$key;
serviceKey: ThirdPartyServicesTabFragment_service$key;
connectionId: string;
onEdit: (service: VendorServicesTabFragment_service$data) => void;
onEdit: (service: ThirdPartyServicesTabFragment_service$data) => void;
};
function ServiceRow(props: ServiceRowProps) {
const { __ } = useTranslate();
const service = useFragment<VendorServicesTabFragment_service$key>(
const service = useFragment<ThirdPartyServicesTabFragment_service$key>(
serviceFragment,
props.serviceKey,
);
@@ -190,7 +190,7 @@ function ServiceRow(props: ServiceRowProps) {
variables: {
connections: [props.connectionId],
input: {
vendorServiceId: service.id,
thirdPartyServiceId: service.id,
},
},
}),

View File

@@ -47,7 +47,7 @@ import { rightsRequestRoutes } from "./routes/rightsRequestRoutes";
import { riskRoutes } from "./routes/riskRoutes";
import { statementsOfApplicabilityRoutes } from "./routes/statementsOfApplicabilityRoutes";
import { taskRoutes } from "./routes/taskRoutes";
import { vendorRoutes } from "./routes/vendorRoutes";
import { thirdPartyRoutes } from "./routes/thirdPartyRoutes";
const routes = [
{
@@ -291,7 +291,7 @@ const routes = [
...riskRoutes,
...measureRoutes,
...documentsRoutes,
...vendorRoutes,
...thirdPartyRoutes,
...frameworkRoutes,
...taskRoutes,
...assetRoutes,

View File

@@ -20,43 +20,43 @@ import {
} from "@probo/routes";
import { loadQuery } from "react-relay";
import type { VendorGraphListQuery } from "#/__generated__/core/VendorGraphListQuery.graphql";
import type { VendorGraphNodeQuery } from "#/__generated__/core/VendorGraphNodeQuery.graphql";
import type { ThirdPartyGraphListQuery } from "#/__generated__/core/ThirdPartyGraphListQuery.graphql";
import type { ThirdPartyGraphNodeQuery } from "#/__generated__/core/ThirdPartyGraphNodeQuery.graphql";
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
import { coreEnvironment } from "#/environments";
import { vendorNodeQuery, vendorsQuery } from "#/hooks/graph/VendorGraph";
import { thirdPartiesQuery, thirdPartyNodeQuery } from "#/hooks/graph/ThirdPartyGraph";
export const vendorRoutes = [
export const thirdPartyRoutes = [
{
path: "vendors",
path: "third-parties",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery<VendorGraphListQuery>(coreEnvironment, vendorsQuery, {
loadQuery<ThirdPartyGraphListQuery>(coreEnvironment, thirdPartiesQuery, {
organizationId: organizationId,
}),
),
Component: withQueryRef(
lazy(() => import("#/pages/organizations/vendors/VendorsPage")),
lazy(() => import("#/pages/organizations/third-parties/ThirdPartiesPage")),
),
},
{
path: "vendors/:vendorId",
path: "third-parties/:thirdPartyId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ vendorId }) =>
loadQuery<VendorGraphNodeQuery>(coreEnvironment, vendorNodeQuery, {
vendorId: vendorId,
loader: loaderFromQueryLoader(({ thirdPartyId }) =>
loadQuery<ThirdPartyGraphNodeQuery>(coreEnvironment, thirdPartyNodeQuery, {
thirdPartyId: thirdPartyId,
}),
),
Component: withQueryRef(
lazy(() => import("../pages/organizations/vendors/VendorDetailPage")),
lazy(() => import("../pages/organizations/third-parties/ThirdPartyDetailPage")),
),
children: [
{
path: "overview",
Fallback: LinkCardSkeleton,
Component: lazy(
() => import("../pages/organizations/vendors/tabs/VendorOverviewTab"),
() => import("../pages/organizations/third-parties/tabs/ThirdPartyOverviewTab"),
),
},
{
@@ -64,7 +64,7 @@ export const vendorRoutes = [
Fallback: LinkCardSkeleton,
Component: lazy(
() =>
import("../pages/organizations/vendors/tabs/VendorCertificationsTab"),
import("../pages/organizations/third-parties/tabs/ThirdPartyCertificationsTab"),
),
},
{
@@ -72,7 +72,7 @@ export const vendorRoutes = [
Fallback: LinkCardSkeleton,
Component: lazy(
() =>
import("../pages/organizations/vendors/tabs/VendorComplianceTab"),
import("../pages/organizations/third-parties/tabs/ThirdPartyComplianceTab"),
),
},
{
@@ -80,21 +80,21 @@ export const vendorRoutes = [
Fallback: LinkCardSkeleton,
Component: lazy(
() =>
import("../pages/organizations/vendors/tabs/VendorRiskAssessmentTab"),
import("../pages/organizations/third-parties/tabs/ThirdPartyRiskAssessmentTab"),
),
},
{
path: "contacts",
Fallback: LinkCardSkeleton,
Component: lazy(
() => import("../pages/organizations/vendors/tabs/VendorContactsTab"),
() => import("../pages/organizations/third-parties/tabs/ThirdPartyContactsTab"),
),
},
{
path: "services",
Fallback: LinkCardSkeleton,
Component: lazy(
() => import("../pages/organizations/vendors/tabs/VendorServicesTab"),
() => import("../pages/organizations/third-parties/tabs/ThirdPartyServicesTab"),
),
},
],

View File

@@ -13,7 +13,7 @@
// PERFORMANCE OF THIS SOFTWARE.
// Command common-third-parties-import seeds the common_third_parties table from
// packages/vendors/data.json. It is idempotent: re-running upserts on conflict
// packages/thirdParties/data.json. It is idempotent: re-running upserts on conflict
// (lower(name)) so existing rows keep their id and created_at.
//
// When -fetch-logos is set, the tool inspects each third party's website to
@@ -419,15 +419,15 @@ func loadThirdParties(path string) ([]thirdPartyData, error) {
return thirdParties, nil
}
func parseCategory(tp thirdPartyData) coredata.VendorCategory {
func parseCategory(tp thirdPartyData) coredata.ThirdPartyCategory {
if tp.Category == nil || *tp.Category == "" {
return coredata.VendorCategoryOther
return coredata.ThirdPartyCategoryOther
}
var c coredata.VendorCategory
var c coredata.ThirdPartyCategory
if err := c.Scan(*tp.Category); err != nil {
fmt.Fprintf(os.Stderr, "warning: third party %q has unknown category %q, falling back to OTHER\n", tp.Name, *tp.Category)
return coredata.VendorCategoryOther
return coredata.ThirdPartyCategoryOther
}
return c

View File

@@ -339,41 +339,41 @@ ORDER BY a.name ASC;
return "", err
}
vendorRows, err := tx.Query(
thirdPartyRows, err := tx.Query(
ctx,
`
SELECT
av.asset_id,
v.name
FROM asset_vendors av
JOIN vendors v ON v.id = av.vendor_id
FROM asset_third_parties av
JOIN third_parties v ON v.id = av.third_party_id
WHERE av.snapshot_id = @snapshot_id
ORDER BY v.name ASC;
`,
pgx.NamedArgs{"snapshot_id": snapshotID},
)
if err != nil {
return "", fmt.Errorf("cannot load snapshot asset vendors: %w", err)
return "", fmt.Errorf("cannot load snapshot asset thirdParties: %w", err)
}
defer vendorRows.Close()
defer thirdPartyRows.Close()
vendorsByAsset := make(map[string][]string)
for vendorRows.Next() {
var assetID, vendorName string
if err := vendorRows.Scan(&assetID, &vendorName); err != nil {
return "", fmt.Errorf("cannot scan vendor: %w", err)
thirdPartiesByAsset := make(map[string][]string)
for thirdPartyRows.Next() {
var assetID, thirdPartyName string
if err := thirdPartyRows.Scan(&assetID, &thirdPartyName); err != nil {
return "", fmt.Errorf("cannot scan thirdParty: %w", err)
}
vendorsByAsset[assetID] = append(vendorsByAsset[assetID], vendorName)
thirdPartiesByAsset[assetID] = append(thirdPartiesByAsset[assetID], thirdPartyName)
}
if err := vendorRows.Err(); err != nil {
if err := thirdPartyRows.Err(); err != nil {
return "", err
}
assetRows := make([]docgen.AssetListRow, len(assets))
for i, a := range assets {
vendors := "-"
if v, ok := vendorsByAsset[a.id]; ok && len(v) > 0 {
vendors = strings.Join(v, ", ")
thirdParties := "-"
if v, ok := thirdPartiesByAsset[a.id]; ok && len(v) > 0 {
thirdParties = strings.Join(v, ", ")
}
assetRows[i] = docgen.AssetListRow{
@@ -382,7 +382,7 @@ ORDER BY v.name ASC;
Amount: a.amount,
DataTypesStored: a.dataTypesStored,
Owner: a.ownerName,
Vendors: vendors,
ThirdParties: thirdParties,
}
}

View File

@@ -335,49 +335,49 @@ ORDER BY d.name ASC;
return "", err
}
// Load vendors for each datum in this snapshot.
vendorRows, err := tx.Query(
// Load thirdParties for each datum in this snapshot.
thirdPartyRows, err := tx.Query(
ctx,
`
SELECT
dv.datum_id,
v.name
FROM data_vendors dv
JOIN vendors v ON v.id = dv.vendor_id
FROM data_third_parties dv
JOIN third_parties v ON v.id = dv.third_party_id
WHERE dv.snapshot_id = @snapshot_id
ORDER BY v.name ASC;
`,
pgx.NamedArgs{"snapshot_id": snapshotID},
)
if err != nil {
return "", fmt.Errorf("cannot load snapshot data vendors: %w", err)
return "", fmt.Errorf("cannot load snapshot data thirdParties: %w", err)
}
defer vendorRows.Close()
defer thirdPartyRows.Close()
vendorsByDatum := make(map[string][]string)
for vendorRows.Next() {
var datumID, vendorName string
if err := vendorRows.Scan(&datumID, &vendorName); err != nil {
return "", fmt.Errorf("cannot scan vendor: %w", err)
thirdPartiesByDatum := make(map[string][]string)
for thirdPartyRows.Next() {
var datumID, thirdPartyName string
if err := thirdPartyRows.Scan(&datumID, &thirdPartyName); err != nil {
return "", fmt.Errorf("cannot scan thirdParty: %w", err)
}
vendorsByDatum[datumID] = append(vendorsByDatum[datumID], vendorName)
thirdPartiesByDatum[datumID] = append(thirdPartiesByDatum[datumID], thirdPartyName)
}
if err := vendorRows.Err(); err != nil {
if err := thirdPartyRows.Err(); err != nil {
return "", err
}
dataRows := make([]docgen.DataListRow, len(data))
for i, d := range data {
vendors := "-"
if v, ok := vendorsByDatum[d.id]; ok && len(v) > 0 {
vendors = strings.Join(v, ", ")
thirdParties := "-"
if v, ok := thirdPartiesByDatum[d.id]; ok && len(v) > 0 {
thirdParties = strings.Join(v, ", ")
}
dataRows[i] = docgen.DataListRow{
Name: d.name,
Classification: formatClassificationString(d.classification),
Owner: d.ownerName,
Vendors: vendors,
ThirdParties: thirdParties,
}
}

View File

@@ -403,7 +403,7 @@ ORDER BY pa.name ASC;
return "", 0, nil
}
vendorMap, err := loadVendorsForSnapshot(ctx, tx, snapshotID)
thirdPartyMap, err := loadThirdPartiesForSnapshot(ctx, tx, snapshotID)
if err != nil {
return "", 0, err
}
@@ -415,9 +415,9 @@ ORDER BY pa.name ASC;
dpo = p.dpoName
}
vendors := "None"
if v, ok := vendorMap[p.id]; ok && len(v) > 0 {
vendors = strings.Join(v, ", ")
thirdParties := "None"
if v, ok := thirdPartyMap[p.id]; ok && len(v) > 0 {
thirdParties = strings.Join(v, ", ")
}
listRows[i] = docgen.ProcessingActivityListRow{
@@ -440,7 +440,7 @@ ORDER BY pa.name ASC;
LastReviewDate: formatDateOrNotSpecified(p.lastReviewDate),
NextReviewDate: formatDateOrNotSpecified(p.nextReviewDate),
DataProtectionOfficer: dpo,
Vendors: vendors,
ThirdParties: thirdParties,
}
}
@@ -457,20 +457,20 @@ ORDER BY pa.name ASC;
return content, len(listRows), nil
}
func loadVendorsForSnapshot(ctx context.Context, tx pg.Tx, snapshotID string) (map[gid.GID][]string, error) {
func loadThirdPartiesForSnapshot(ctx context.Context, tx pg.Tx, snapshotID string) (map[gid.GID][]string, error) {
rows, err := tx.Query(
ctx,
`
SELECT pav.processing_activity_id, v.name
FROM processing_activity_vendors pav
INNER JOIN vendors v ON v.id = pav.vendor_id
FROM processing_activity_third_parties pav
INNER JOIN third_parties v ON v.id = pav.third_party_id
WHERE pav.snapshot_id = @snapshot_id
ORDER BY pav.processing_activity_id, v.name;
`,
pgx.NamedArgs{"snapshot_id": snapshotID},
)
if err != nil {
return nil, fmt.Errorf("cannot load snapshot vendors: %w", err)
return nil, fmt.Errorf("cannot load snapshot thirdParties: %w", err)
}
defer rows.Close()
@@ -479,7 +479,7 @@ ORDER BY pav.processing_activity_id, v.name;
var paID gid.GID
var name string
if err := rows.Scan(&paID, &name); err != nil {
return nil, fmt.Errorf("cannot scan vendor row: %w", err)
return nil, fmt.Errorf("cannot scan thirdParty row: %w", err)
}
result[paID] = append(result[paID], name)
}

View File

@@ -12,9 +12,9 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
// Command migrate-vendor-snapshots-to-documents creates documents and document
// versions from existing vendor snapshots. For each organization that has vendor
// snapshots, it generates a vendor list document using the same ProseMirror
// Command migrate-thirdParty-snapshots-to-documents creates documents and document
// versions from existing thirdParty snapshots. For each organization that has thirdParty
// snapshots, it generates a thirdParty list document using the same ProseMirror
// builder as the publish flow.
package main
@@ -72,22 +72,22 @@ func run() error {
return migrate(ctx, pgClient, dryRun)
}
type orgWithVendorSnapshots struct {
type orgWithThirdPartySnapshots struct {
organizationID gid.GID
tenantID gid.TenantID
organizationName string
}
type vendorSnapshot struct {
type thirdPartySnapshot struct {
snapshotID string
publishedAt time.Time
}
func migrate(ctx context.Context, pgClient *pg.Client, dryRun bool) error {
var orgs []orgWithVendorSnapshots
var orgs []orgWithThirdPartySnapshots
err := pgClient.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
var err error
orgs, err = loadOrgsWithVendorSnapshots(ctx, conn)
orgs, err = loadOrgsWithThirdPartySnapshots(ctx, conn)
return err
})
if err != nil {
@@ -95,7 +95,7 @@ func migrate(ctx context.Context, pgClient *pg.Client, dryRun bool) error {
}
if len(orgs) == 0 {
fmt.Println("no organizations with vendor snapshots to migrate")
fmt.Println("no organizations with thirdParty snapshots to migrate")
return nil
}
@@ -107,14 +107,14 @@ func migrate(ctx context.Context, pgClient *pg.Client, dryRun bool) error {
if dryRun {
var count int
err := pgClient.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
snapshots, err := loadVendorSnapshots(ctx, conn, org.organizationID)
snapshots, err := loadThirdPartySnapshots(ctx, conn, org.organizationID)
count = len(snapshots)
return err
})
if err != nil {
return err
}
fmt.Printf("would migrate org %s (%s) — %d vendor snapshot(s)\n",
fmt.Printf("would migrate org %s (%s) — %d thirdParty snapshot(s)\n",
org.organizationID, org.organizationName, count)
continue
}
@@ -143,8 +143,8 @@ func migrate(ctx context.Context, pgClient *pg.Client, dryRun bool) error {
return nil
}
func migrateOrg(ctx context.Context, tx pg.Tx, org orgWithVendorSnapshots) error {
snapshots, err := loadVendorSnapshots(ctx, tx, org.organizationID)
func migrateOrg(ctx context.Context, tx pg.Tx, org orgWithThirdPartySnapshots) error {
snapshots, err := loadThirdPartySnapshots(ctx, tx, org.organizationID)
if err != nil {
return err
}
@@ -186,15 +186,15 @@ INSERT INTO documents (
_, err = tx.Exec(
ctx,
`INSERT INTO generated_documents (organization_id, tenant_id, vendors_document_id, created_at, updated_at)
VALUES (@organization_id, @tenant_id, @vendors_document_id, @created_at, @updated_at)
ON CONFLICT (organization_id) DO UPDATE SET vendors_document_id = @vendors_document_id, updated_at = @updated_at`,
`INSERT INTO generated_documents (organization_id, tenant_id, third_parties_document_id, created_at, updated_at)
VALUES (@organization_id, @tenant_id, @third_parties_document_id, @created_at, @updated_at)
ON CONFLICT (organization_id) DO UPDATE SET third_parties_document_id = @third_parties_document_id, updated_at = @updated_at`,
pgx.NamedArgs{
"organization_id": org.organizationID,
"tenant_id": org.tenantID,
"vendors_document_id": documentID,
"created_at": now,
"updated_at": now,
"organization_id": org.organizationID,
"tenant_id": org.tenantID,
"third_parties_document_id": documentID,
"created_at": now,
"updated_at": now,
},
)
if err != nil {
@@ -234,7 +234,7 @@ INSERT INTO document_versions (
"tenant_id": org.tenantID,
"organization_id": org.organizationID,
"document_id": documentID,
"title": "Vendors",
"title": "ThirdParties",
"major": major + 1,
"content": content,
"published_at": snap.publishedAt,
@@ -251,7 +251,7 @@ INSERT INTO document_versions (
return nil
}
func loadOrgsWithVendorSnapshots(ctx context.Context, conn pg.Querier) ([]orgWithVendorSnapshots, error) {
func loadOrgsWithThirdPartySnapshots(ctx context.Context, conn pg.Querier) ([]orgWithThirdPartySnapshots, error) {
rows, err := conn.Query(
ctx,
`
@@ -263,7 +263,7 @@ SELECT DISTINCT
FROM organizations o
WHERE NOT EXISTS (
SELECT 1 FROM generated_documents gd
WHERE gd.organization_id = o.id AND gd.vendors_document_id IS NOT NULL
WHERE gd.organization_id = o.id AND gd.third_parties_document_id IS NOT NULL
)
AND EXISTS (
SELECT 1 FROM snapshots s
@@ -273,13 +273,13 @@ ORDER BY o.created_at;
`,
)
if err != nil {
return nil, fmt.Errorf("cannot query organizations with vendor snapshots: %w", err)
return nil, fmt.Errorf("cannot query organizations with thirdParty snapshots: %w", err)
}
defer rows.Close()
var result []orgWithVendorSnapshots
var result []orgWithThirdPartySnapshots
for rows.Next() {
var o orgWithVendorSnapshots
var o orgWithThirdPartySnapshots
var createdAt time.Time
if err := rows.Scan(&o.organizationID, &o.tenantID, &o.organizationName, &createdAt); err != nil {
return nil, fmt.Errorf("cannot scan organization: %w", err)
@@ -290,7 +290,7 @@ ORDER BY o.created_at;
return result, rows.Err()
}
func loadVendorSnapshots(ctx context.Context, conn pg.Querier, organizationID gid.GID) ([]vendorSnapshot, error) {
func loadThirdPartySnapshots(ctx context.Context, conn pg.Querier, organizationID gid.GID) ([]thirdPartySnapshot, error) {
rows, err := conn.Query(
ctx,
`
@@ -305,13 +305,13 @@ ORDER BY s.created_at ASC;
pgx.NamedArgs{"organization_id": organizationID},
)
if err != nil {
return nil, fmt.Errorf("cannot query vendor snapshots for org %s: %w", organizationID, err)
return nil, fmt.Errorf("cannot query thirdParty snapshots for org %s: %w", organizationID, err)
}
defer rows.Close()
var result []vendorSnapshot
var result []thirdPartySnapshot
for rows.Next() {
var s vendorSnapshot
var s thirdPartySnapshot
if err := rows.Scan(&s.snapshotID, &s.publishedAt); err != nil {
return nil, fmt.Errorf("cannot scan snapshot: %w", err)
}
@@ -321,7 +321,7 @@ ORDER BY s.created_at ASC;
return result, rows.Err()
}
type vendorInfo struct {
type thirdPartyInfo struct {
id string
name string
category string
@@ -352,7 +352,7 @@ func buildSnapshotContent(
orgName string,
publishedAt time.Time,
) (string, error) {
vendorRows, err := tx.Query(
thirdPartyRows, err := tx.Query(
ctx,
`
SELECT
@@ -376,7 +376,7 @@ SELECT
v.countries,
COALESCE(bo.full_name, 'Not assigned'),
COALESCE(so.full_name, 'Not assigned')
FROM vendors v
FROM third_parties v
LEFT JOIN iam_membership_profiles bo ON bo.id = v.business_owner_profile_id
LEFT JOIN iam_membership_profiles so ON so.id = v.security_owner_profile_id
WHERE v.snapshot_id = @snapshot_id
@@ -385,14 +385,14 @@ ORDER BY v.name ASC;
pgx.NamedArgs{"snapshot_id": snapshotID},
)
if err != nil {
return "", fmt.Errorf("cannot load snapshot vendors: %w", err)
return "", fmt.Errorf("cannot load snapshot thirdParties: %w", err)
}
defer vendorRows.Close()
defer thirdPartyRows.Close()
var vendors []vendorInfo
for vendorRows.Next() {
var v vendorInfo
if err := vendorRows.Scan(
var thirdParties []thirdPartyInfo
for thirdPartyRows.Next() {
var v thirdPartyInfo
if err := thirdPartyRows.Scan(
&v.id, &v.name, &v.category,
&v.legalName, &v.description, &v.headquarterAddress,
&v.websiteURL, &v.privacyPolicyURL, &v.serviceLevelAgreementURL,
@@ -402,52 +402,52 @@ ORDER BY v.name ASC;
&v.certifications, &v.countries,
&v.businessOwnerName, &v.securityOwnerName,
); err != nil {
return "", fmt.Errorf("cannot scan vendor: %w", err)
return "", fmt.Errorf("cannot scan thirdParty: %w", err)
}
vendors = append(vendors, v)
thirdParties = append(thirdParties, v)
}
if err := vendorRows.Err(); err != nil {
if err := thirdPartyRows.Err(); err != nil {
return "", err
}
vendorIDs := make([]string, len(vendors))
for i, v := range vendors {
vendorIDs[i] = v.id
thirdPartyIDs := make([]string, len(thirdParties))
for i, v := range thirdParties {
thirdPartyIDs[i] = v.id
}
servicesByVendor, err := loadSnapshotServices(ctx, tx, snapshotID, vendorIDs)
servicesByThirdParty, err := loadSnapshotServices(ctx, tx, snapshotID, thirdPartyIDs)
if err != nil {
return "", err
}
contactsByVendor, err := loadSnapshotContacts(ctx, tx, snapshotID, vendorIDs)
contactsByThirdParty, err := loadSnapshotContacts(ctx, tx, snapshotID, thirdPartyIDs)
if err != nil {
return "", err
}
assessmentsByVendor, err := loadSnapshotRiskAssessments(ctx, tx, snapshotID, vendorIDs)
assessmentsByThirdParty, err := loadSnapshotRiskAssessments(ctx, tx, snapshotID, thirdPartyIDs)
if err != nil {
return "", err
}
reportsByVendor, err := loadSnapshotComplianceReports(ctx, tx, snapshotID, vendorIDs)
reportsByThirdParty, err := loadSnapshotComplianceReports(ctx, tx, snapshotID, thirdPartyIDs)
if err != nil {
return "", err
}
baaByVendor, err := loadSnapshotBAAs(ctx, tx, snapshotID, vendorIDs)
baaByThirdParty, err := loadSnapshotBAAs(ctx, tx, snapshotID, thirdPartyIDs)
if err != nil {
return "", err
}
dpaByVendor, err := loadSnapshotDPAs(ctx, tx, snapshotID, vendorIDs)
dpaByThirdParty, err := loadSnapshotDPAs(ctx, tx, snapshotID, thirdPartyIDs)
if err != nil {
return "", err
}
rows := make([]docgen.VendorListRow, 0, len(vendors))
for _, v := range vendors {
row := docgen.VendorListRow{
rows := make([]docgen.ThirdPartyListRow, 0, len(thirdParties))
for _, v := range thirdParties {
row := docgen.ThirdPartyListRow{
Name: v.name,
LegalName: deref(v.legalName),
Description: deref(v.description),
@@ -467,99 +467,99 @@ ORDER BY v.name ASC;
Countries: joinOrDefault(v.countries),
BusinessOwner: v.businessOwnerName,
SecurityOwner: v.securityOwnerName,
Services: servicesByVendor[v.id],
Contacts: contactsByVendor[v.id],
RiskAssessments: assessmentsByVendor[v.id],
ComplianceReports: reportsByVendor[v.id],
BusinessAssociateAgreement: baaByVendor[v.id],
DataPrivacyAgreement: dpaByVendor[v.id],
Services: servicesByThirdParty[v.id],
Contacts: contactsByThirdParty[v.id],
RiskAssessments: assessmentsByThirdParty[v.id],
ComplianceReports: reportsByThirdParty[v.id],
BusinessAssociateAgreement: baaByThirdParty[v.id],
DataPrivacyAgreement: dpaByThirdParty[v.id],
}
rows = append(rows, row)
}
docData := docgen.VendorListData{
Title: "Vendors",
OrganizationName: orgName,
CreatedAt: publishedAt,
TotalVendors: len(rows),
Rows: rows,
docData := docgen.ThirdPartyListData{
Title: "ThirdParties",
OrganizationName: orgName,
CreatedAt: publishedAt,
TotalThirdParties: len(rows),
Rows: rows,
}
return probo.BuildVendorListDocument(docData)
return probo.BuildThirdPartyListDocument(docData)
}
func loadSnapshotServices(ctx context.Context, tx pg.Tx, snapshotID string, vendorIDs []string) (map[string][]docgen.VendorListService, error) {
func loadSnapshotServices(ctx context.Context, tx pg.Tx, snapshotID string, thirdPartyIDs []string) (map[string][]docgen.ThirdPartyListService, error) {
rows, err := tx.Query(ctx,
`SELECT vs.vendor_id, vs.name, COALESCE(vs.description, 'Not specified')
FROM vendor_services vs
WHERE vs.snapshot_id = @snapshot_id AND vs.vendor_id = ANY(@vendor_ids)
ORDER BY vs.vendor_id, vs.name ASC`,
pgx.NamedArgs{"snapshot_id": snapshotID, "vendor_ids": vendorIDs})
`SELECT vs.third_party_id, vs.name, COALESCE(vs.description, 'Not specified')
FROM third_party_services vs
WHERE vs.snapshot_id = @snapshot_id AND vs.third_party_id = ANY(@third_party_ids)
ORDER BY vs.third_party_id, vs.name ASC`,
pgx.NamedArgs{"snapshot_id": snapshotID, "third_party_ids": thirdPartyIDs})
if err != nil {
return nil, fmt.Errorf("cannot load snapshot services: %w", err)
}
defer rows.Close()
result := make(map[string][]docgen.VendorListService)
result := make(map[string][]docgen.ThirdPartyListService)
for rows.Next() {
var vendorID, name, desc string
if err := rows.Scan(&vendorID, &name, &desc); err != nil {
var thirdPartyID, name, desc string
if err := rows.Scan(&thirdPartyID, &name, &desc); err != nil {
return nil, fmt.Errorf("cannot scan service: %w", err)
}
result[vendorID] = append(result[vendorID], docgen.VendorListService{Name: name, Description: desc})
result[thirdPartyID] = append(result[thirdPartyID], docgen.ThirdPartyListService{Name: name, Description: desc})
}
return result, rows.Err()
}
func loadSnapshotContacts(ctx context.Context, tx pg.Tx, snapshotID string, vendorIDs []string) (map[string][]docgen.VendorListContact, error) {
func loadSnapshotContacts(ctx context.Context, tx pg.Tx, snapshotID string, thirdPartyIDs []string) (map[string][]docgen.ThirdPartyListContact, error) {
rows, err := tx.Query(ctx,
`SELECT vc.vendor_id,
`SELECT vc.third_party_id,
COALESCE(vc.full_name, 'Not specified'),
COALESCE(vc.email, 'Not specified'),
COALESCE(vc.phone, 'Not specified'),
COALESCE(vc.role, 'Not specified')
FROM vendor_contacts vc
WHERE vc.snapshot_id = @snapshot_id AND vc.vendor_id = ANY(@vendor_ids)
ORDER BY vc.vendor_id, vc.full_name ASC`,
pgx.NamedArgs{"snapshot_id": snapshotID, "vendor_ids": vendorIDs})
FROM third_party_contacts vc
WHERE vc.snapshot_id = @snapshot_id AND vc.third_party_id = ANY(@third_party_ids)
ORDER BY vc.third_party_id, vc.full_name ASC`,
pgx.NamedArgs{"snapshot_id": snapshotID, "third_party_ids": thirdPartyIDs})
if err != nil {
return nil, fmt.Errorf("cannot load snapshot contacts: %w", err)
}
defer rows.Close()
result := make(map[string][]docgen.VendorListContact)
result := make(map[string][]docgen.ThirdPartyListContact)
for rows.Next() {
var vendorID, name, email, phone, role string
if err := rows.Scan(&vendorID, &name, &email, &phone, &role); err != nil {
var thirdPartyID, name, email, phone, role string
if err := rows.Scan(&thirdPartyID, &name, &email, &phone, &role); err != nil {
return nil, fmt.Errorf("cannot scan contact: %w", err)
}
result[vendorID] = append(result[vendorID], docgen.VendorListContact{
result[thirdPartyID] = append(result[thirdPartyID], docgen.ThirdPartyListContact{
FullName: name, Email: email, Phone: phone, Role: role,
})
}
return result, rows.Err()
}
func loadSnapshotRiskAssessments(ctx context.Context, tx pg.Tx, snapshotID string, vendorIDs []string) (map[string][]docgen.VendorListRiskAssessment, error) {
func loadSnapshotRiskAssessments(ctx context.Context, tx pg.Tx, snapshotID string, thirdPartyIDs []string) (map[string][]docgen.ThirdPartyListRiskAssessment, error) {
rows, err := tx.Query(ctx,
`SELECT vra.vendor_id, vra.created_at, vra.expires_at, vra.data_sensitivity, vra.business_impact, COALESCE(vra.notes, 'Not specified')
FROM vendor_risk_assessments vra
WHERE vra.snapshot_id = @snapshot_id AND vra.vendor_id = ANY(@vendor_ids)
ORDER BY vra.vendor_id, vra.created_at DESC`,
pgx.NamedArgs{"snapshot_id": snapshotID, "vendor_ids": vendorIDs})
`SELECT vra.third_party_id, vra.created_at, vra.expires_at, vra.data_sensitivity, vra.business_impact, COALESCE(vra.notes, 'Not specified')
FROM third_party_risk_assessments vra
WHERE vra.snapshot_id = @snapshot_id AND vra.third_party_id = ANY(@third_party_ids)
ORDER BY vra.third_party_id, vra.created_at DESC`,
pgx.NamedArgs{"snapshot_id": snapshotID, "third_party_ids": thirdPartyIDs})
if err != nil {
return nil, fmt.Errorf("cannot load snapshot risk assessments: %w", err)
}
defer rows.Close()
result := make(map[string][]docgen.VendorListRiskAssessment)
result := make(map[string][]docgen.ThirdPartyListRiskAssessment)
for rows.Next() {
var vendorID, sensitivity, impact, notes string
var thirdPartyID, sensitivity, impact, notes string
var assessedAt, expiresAt time.Time
if err := rows.Scan(&vendorID, &assessedAt, &expiresAt, &sensitivity, &impact, &notes); err != nil {
if err := rows.Scan(&thirdPartyID, &assessedAt, &expiresAt, &sensitivity, &impact, &notes); err != nil {
return nil, fmt.Errorf("cannot scan risk assessment: %w", err)
}
result[vendorID] = append(result[vendorID], docgen.VendorListRiskAssessment{
result[thirdPartyID] = append(result[thirdPartyID], docgen.ThirdPartyListRiskAssessment{
AssessedAt: assessedAt.Format("2006-01-02"),
ExpiresAt: expiresAt.Format("2006-01-02"),
DataSensitivity: sensitivity,
@@ -570,81 +570,81 @@ func loadSnapshotRiskAssessments(ctx context.Context, tx pg.Tx, snapshotID strin
return result, rows.Err()
}
func loadSnapshotComplianceReports(ctx context.Context, tx pg.Tx, snapshotID string, vendorIDs []string) (map[string][]docgen.VendorListComplianceReport, error) {
func loadSnapshotComplianceReports(ctx context.Context, tx pg.Tx, snapshotID string, thirdPartyIDs []string) (map[string][]docgen.ThirdPartyListComplianceReport, error) {
rows, err := tx.Query(ctx,
`SELECT vcr.vendor_id, vcr.report_name, vcr.report_date, vcr.valid_until
FROM vendor_compliance_reports vcr
WHERE vcr.snapshot_id = @snapshot_id AND vcr.vendor_id = ANY(@vendor_ids)
ORDER BY vcr.vendor_id, vcr.report_date DESC`,
pgx.NamedArgs{"snapshot_id": snapshotID, "vendor_ids": vendorIDs})
`SELECT vcr.third_party_id, vcr.report_name, vcr.report_date, vcr.valid_until
FROM third_party_compliance_reports vcr
WHERE vcr.snapshot_id = @snapshot_id AND vcr.third_party_id = ANY(@third_party_ids)
ORDER BY vcr.third_party_id, vcr.report_date DESC`,
pgx.NamedArgs{"snapshot_id": snapshotID, "third_party_ids": thirdPartyIDs})
if err != nil {
return nil, fmt.Errorf("cannot load snapshot compliance reports: %w", err)
}
defer rows.Close()
result := make(map[string][]docgen.VendorListComplianceReport)
result := make(map[string][]docgen.ThirdPartyListComplianceReport)
for rows.Next() {
var vendorID, name string
var thirdPartyID, name string
var reportDate time.Time
var validUntil *time.Time
if err := rows.Scan(&vendorID, &name, &reportDate, &validUntil); err != nil {
if err := rows.Scan(&thirdPartyID, &name, &reportDate, &validUntil); err != nil {
return nil, fmt.Errorf("cannot scan compliance report: %w", err)
}
vu := "Not specified"
if validUntil != nil {
vu = validUntil.Format("2006-01-02")
}
result[vendorID] = append(result[vendorID], docgen.VendorListComplianceReport{
result[thirdPartyID] = append(result[thirdPartyID], docgen.ThirdPartyListComplianceReport{
ReportName: name, ReportDate: reportDate.Format("2006-01-02"), ValidUntil: vu,
})
}
return result, rows.Err()
}
func loadSnapshotBAAs(ctx context.Context, tx pg.Tx, snapshotID string, vendorIDs []string) (map[string]*docgen.VendorListAgreement, error) {
func loadSnapshotBAAs(ctx context.Context, tx pg.Tx, snapshotID string, thirdPartyIDs []string) (map[string]*docgen.ThirdPartyListAgreement, error) {
rows, err := tx.Query(ctx,
`SELECT vbaa.vendor_id, vbaa.valid_from, vbaa.valid_until
FROM vendor_business_associate_agreements vbaa
WHERE vbaa.snapshot_id = @snapshot_id AND vbaa.vendor_id = ANY(@vendor_ids)`,
pgx.NamedArgs{"snapshot_id": snapshotID, "vendor_ids": vendorIDs})
`SELECT vbaa.third_party_id, vbaa.valid_from, vbaa.valid_until
FROM third_party_business_associate_agreements vbaa
WHERE vbaa.snapshot_id = @snapshot_id AND vbaa.third_party_id = ANY(@third_party_ids)`,
pgx.NamedArgs{"snapshot_id": snapshotID, "third_party_ids": thirdPartyIDs})
if err != nil {
return nil, fmt.Errorf("cannot load snapshot BAAs: %w", err)
}
defer rows.Close()
result := make(map[string]*docgen.VendorListAgreement)
result := make(map[string]*docgen.ThirdPartyListAgreement)
for rows.Next() {
var vendorID string
var thirdPartyID string
var validFrom, validUntil *time.Time
if err := rows.Scan(&vendorID, &validFrom, &validUntil); err != nil {
if err := rows.Scan(&thirdPartyID, &validFrom, &validUntil); err != nil {
return nil, fmt.Errorf("cannot scan BAA: %w", err)
}
result[vendorID] = &docgen.VendorListAgreement{
result[thirdPartyID] = &docgen.ThirdPartyListAgreement{
ValidFrom: fmtTime(validFrom), ValidUntil: fmtTime(validUntil),
}
}
return result, rows.Err()
}
func loadSnapshotDPAs(ctx context.Context, tx pg.Tx, snapshotID string, vendorIDs []string) (map[string]*docgen.VendorListAgreement, error) {
func loadSnapshotDPAs(ctx context.Context, tx pg.Tx, snapshotID string, thirdPartyIDs []string) (map[string]*docgen.ThirdPartyListAgreement, error) {
rows, err := tx.Query(ctx,
`SELECT vdpa.vendor_id, vdpa.valid_from, vdpa.valid_until
FROM vendor_data_privacy_agreements vdpa
WHERE vdpa.snapshot_id = @snapshot_id AND vdpa.vendor_id = ANY(@vendor_ids)`,
pgx.NamedArgs{"snapshot_id": snapshotID, "vendor_ids": vendorIDs})
`SELECT vdpa.third_party_id, vdpa.valid_from, vdpa.valid_until
FROM third_party_data_privacy_agreements vdpa
WHERE vdpa.snapshot_id = @snapshot_id AND vdpa.third_party_id = ANY(@third_party_ids)`,
pgx.NamedArgs{"snapshot_id": snapshotID, "third_party_ids": thirdPartyIDs})
if err != nil {
return nil, fmt.Errorf("cannot load snapshot DPAs: %w", err)
}
defer rows.Close()
result := make(map[string]*docgen.VendorListAgreement)
result := make(map[string]*docgen.ThirdPartyListAgreement)
for rows.Next() {
var vendorID string
var thirdPartyID string
var validFrom, validUntil *time.Time
if err := rows.Scan(&vendorID, &validFrom, &validUntil); err != nil {
if err := rows.Scan(&thirdPartyID, &validFrom, &validUntil); err != nil {
return nil, fmt.Errorf("cannot scan DPA: %w", err)
}
result[vendorID] = &docgen.VendorListAgreement{
result[thirdPartyID] = &docgen.ThirdPartyListAgreement{
ValidFrom: fmtTime(validFrom), ValidUntil: fmtTime(validUntil),
}
}

View File

@@ -22,12 +22,12 @@ The `pages/` folder **is** the route tree. Every route segment maps to a folder
// Bad — separate routes/ folder duplicates pages/ structure
src/
routes/
vendorRoutes.ts # route definitions for vendors
thirdPartyRoutes.ts # route definitions for third parties
assetRoutes.ts # route definitions for assets
pages/
organizations/
vendors/
VendorsPage.tsx
third-parties/
ThirdPartiesPage.tsx
assets/
AssetsPage.tsx
```
@@ -37,9 +37,9 @@ src/
src/
pages/
organizations/
vendors/
routes.ts # route definitions for vendors
VendorsPage.tsx
third-parties/
routes.ts # route definitions for third parties
ThirdPartiesPage.tsx
assets/
routes.ts # route definitions for assets
AssetsPage.tsx
@@ -77,11 +77,11 @@ Use the correct suffix so the role is clear from the file name alone:
```text
// Bad — a layout route named as a "Page"
VendorDetailPage.tsx # renders <Outlet />, wraps child routes
ThirdPartyDetailPage.tsx # renders <Outlet />, wraps child routes
CookieBannerConfigPage.tsx # renders tabs + <Outlet />
// Good — layout routes use the "Layout" suffix
VendorDetailLayout.tsx
ThirdPartyDetailLayout.tsx
CookieBannerConfigLayout.tsx
```
@@ -142,26 +142,26 @@ export default function CookieBannerLayout() {
Contains route objects for the current folder's feature, exported as a named array and spread into the parent. Keep imports minimal — only `lazy`, skeleton components, and typing.
```ts
// pages/organizations/vendors/routes.ts
// pages/organizations/third-parties/routes.ts
import { lazy } from "@probo/react-lazy";
import type { AppRoute } from "@probo/routes";
import { VendorsPageSkeleton } from "./VendorsPageSkeleton";
import { ThirdPartiesPageSkeleton } from "./ThirdPartiesPageSkeleton";
export const vendorRoutes = [
export const thirdPartyRoutes = [
{
path: "vendors",
Fallback: VendorsPageSkeleton,
Component: lazy(() => import("./VendorsPageLoader")),
path: "third-parties",
Fallback: ThirdPartiesPageSkeleton,
Component: lazy(() => import("./ThirdPartiesPageLoader")),
},
{
path: "vendors/:vendorId",
Fallback: VendorsPageSkeleton,
Component: lazy(() => import("./VendorDetailLayoutLoader")),
path: "third-parties/:thirdPartyId",
Fallback: ThirdPartiesPageSkeleton,
Component: lazy(() => import("./ThirdPartyDetailLayoutLoader")),
children: [
{
path: "overview",
Component: lazy(() => import("./overview/VendorOverviewPage")),
Component: lazy(() => import("./overview/ThirdPartyOverviewPage")),
},
],
},
@@ -173,35 +173,35 @@ export const vendorRoutes = [
The loader is the **lazy bundle entry point**. It sets up providers, triggers the Relay query, shows a skeleton until the query resolves, then renders the page.
```tsx
// pages/organizations/vendors/VendorsPageLoader.tsx
// pages/organizations/third-parties/ThirdPartiesPageLoader.tsx
import { Suspense, useEffect } from "react";
import { useQueryLoader } from "react-relay";
import type { VendorsPageQuery } from "#/__generated__/core/VendorsPageQuery.graphql";
import type { ThirdPartiesPageQuery } from "#/__generated__/core/ThirdPartiesPageQuery.graphql";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import VendorsPage, { vendorsPageQuery } from "./VendorsPage";
import { VendorsPageSkeleton } from "./VendorsPageSkeleton";
import ThirdPartiesPage, { thirdPartiesPageQuery } from "./ThirdPartiesPage";
import { ThirdPartiesPageSkeleton } from "./ThirdPartiesPageSkeleton";
function VendorsPageQueryLoader() {
function ThirdPartiesPageQueryLoader() {
const organizationId = useOrganizationId();
const [queryRef, loadQuery] = useQueryLoader<VendorsPageQuery>(vendorsPageQuery);
const [queryRef, loadQuery] = useQueryLoader<ThirdPartiesPageQuery>(thirdPartiesPageQuery);
useEffect(() => {
loadQuery({ organizationId });
}, [loadQuery, organizationId]);
if (!queryRef) {
return <VendorsPageSkeleton />;
return <ThirdPartiesPageSkeleton />;
}
return <VendorsPage queryRef={queryRef} />
return <ThirdPartiesPage queryRef={queryRef} />
}
export default function VendorsPageLoader() {
export default function ThirdPartiesPageLoader() {
return (
<CoreRelayProvider>
<VendorsPageQueryLoader />
<ThirdPartiesPageQueryLoader />
</CoreRelayProvider>
);
}
@@ -212,9 +212,9 @@ export default function VendorsPageLoader() {
Receives the `queryRef` from the loader and renders the UI. Default export so `lazy()` can import it.
```tsx
// pages/organizations/vendors/VendorsPage.tsx
export default function VendorsPage({ queryRef }: VendorsPageProps) {
const data = usePreloadedQuery(vendorsPageQuery, queryRef);
// pages/organizations/third-parties/ThirdPartiesPage.tsx
export default function ThirdPartiesPage({ queryRef }: ThirdPartiesPageProps) {
const data = usePreloadedQuery(thirdPartiesPageQuery, queryRef);
return (/* … */);
}
```
@@ -224,8 +224,8 @@ export default function VendorsPage({ queryRef }: VendorsPageProps) {
A lightweight loading placeholder. Keep it free of data-fetching logic so it loads instantly.
```tsx
// pages/organizations/vendors/VendorsPageSkeleton.tsx
export function VendorsPageSkeleton() {
// pages/organizations/third-parties/ThirdPartiesPageSkeleton.tsx
export function ThirdPartiesPageSkeleton() {
return (/* pulse / skeleton UI */);
}
```
@@ -235,8 +235,8 @@ export function VendorsPageSkeleton() {
Rendered by the route error boundary when the page throws.
```tsx
// pages/organizations/vendors/VendorsPageError.tsx
export function VendorsPageError() {
// pages/organizations/third-parties/ThirdPartiesPageError.tsx
export function ThirdPartiesPageError() {
const error = useRouteError();
return (/* error UI */);
}
@@ -244,24 +244,24 @@ export function VendorsPageError() {
## File naming
Component files (`.tsx` that export a React component) use **PascalCase**: `VendorsPage.tsx`, `VendorContactRow.tsx`, `VendorsPageSkeleton.tsx`.
Component files (`.tsx` that export a React component) use **PascalCase**: `ThirdPartiesPage.tsx`, `ThirdPartyContactRow.tsx`, `ThirdPartiesPageSkeleton.tsx`.
All other helper files (utilities, hooks, constants, configuration) use **camelCase**: `routes.ts`, `useVendorFilters.ts`, `formatCurrency.ts`, `constants.ts`.
All other helper files (utilities, hooks, constants, configuration) use **camelCase**: `routes.ts`, `useThirdPartyFilters.ts`, `formatCurrency.ts`, `constants.ts`.
### Do / don't: file naming
```text
// Bad — helper file in PascalCase
pages/organizations/vendors/FormatVendorStatus.ts
pages/organizations/vendors/UseVendorFilters.ts
pages/organizations/vendors/Routes.ts
pages/organizations/third-parties/FormatThirdPartyStatus.ts
pages/organizations/third-parties/UseThirdPartyFilters.ts
pages/organizations/third-parties/Routes.ts
// Good — helpers are camelCase, components are PascalCase
pages/organizations/vendors/formatVendorStatus.ts
pages/organizations/vendors/useVendorFilters.ts
pages/organizations/vendors/routes.ts
pages/organizations/vendors/VendorsPage.tsx
pages/organizations/vendors/VendorsPageSkeleton.tsx
pages/organizations/third-parties/formatThirdPartyStatus.ts
pages/organizations/third-parties/useThirdPartyFilters.ts
pages/organizations/third-parties/routes.ts
pages/organizations/third-parties/ThirdPartiesPage.tsx
pages/organizations/third-parties/ThirdPartiesPageSkeleton.tsx
```
## `_components` folder
@@ -270,7 +270,7 @@ Sub-components that are used **only** by a single page live in a `_components/`
| Situation | Where the component lives |
| ------------------------------------------ | ---------------------------------------------------------------------------------- |
| Used by one page only | `pages/organizations/vendors/_components/` |
| Used by one page only | `pages/organizations/third-parties/_components/` |
| Used by multiple pages in the same feature | Nearest common ancestor's `_components/` (e.g. `pages/organizations/_components/`) |
| Reusable UI primitive | `@probo/ui` package |
@@ -278,8 +278,8 @@ Sub-components that are used **only** by a single page live in a `_components/`
```text
// Bad — shared component buried in a single page's _components
pages/organizations/vendors/_components/StatusBadge.tsx # also used by risks page
pages/organizations/risks/SomeRiskPage.tsx # imports ../../vendors/_components/StatusBadge
pages/organizations/third-parties/_components/StatusBadge.tsx # also used by risks page
pages/organizations/risks/SomeRiskPage.tsx # imports ../../third-parties/_components/StatusBadge
// Good — shared component hoisted to common ancestor
pages/organizations/_components/StatusBadge.tsx
@@ -287,10 +287,10 @@ pages/organizations/_components/StatusBadge.tsx
```text
// Bad — page-specific helper placed in a global folder
src/components/VendorContactRow.tsx # only used by VendorContactsTab
src/components/ThirdPartyContactRow.tsx # only used by ThirdPartyContactsTab
// Good — scoped to the page that uses it
pages/organizations/vendors/_components/VendorContactRow.tsx
pages/organizations/third-parties/_components/ThirdPartyContactRow.tsx
```
## Child-route folder naming
@@ -303,40 +303,40 @@ Folders that contain child-route pages are named after the **resource or concept
// Bad — folder named after a UI element
configuration/
tabs/ # "tabs" is a UI component, not a resource
VendorOverviewTab.tsx
VendorComplianceTab.tsx
ThirdPartyOverviewTab.tsx
ThirdPartyComplianceTab.tsx
// Good — folders named after the resource each child route represents
configuration/
overview/
VendorOverviewPage.tsx
ThirdPartyOverviewPage.tsx
compliance/
VendorCompliancePage.tsx
ThirdPartyCompliancePage.tsx
```
This also means child-route components use the `*Page` suffix (not `*Tab`), because they are pages in their own right — the fact that a tab bar navigates between them is an implementation detail of the parent layout.
## Full example tree
Target layout for a `vendors` feature under `pages/organizations/`:
Target layout for a `third-parties` feature under `pages/organizations/`:
```text
pages/organizations/vendors/
routes.ts # route definitions for vendors
VendorsPageLoader.tsx # lazy entry — providers + Suspense + query loader
VendorsPage.tsx # page component (usePreloadedQuery)
VendorsPageSkeleton.tsx # loading fallback
VendorDetailLayoutLoader.tsx # lazy entry for detail layout
VendorDetailLayout.tsx # layout — breadcrumbs, tabs, <Outlet />
VendorDetailLayoutSkeleton.tsx # detail loading fallback
NewVendorPage.tsx # mutation-only page — default export, wraps itself in the Relay provider
_components/ # sub-components used only by vendor pages
VendorContactRow.tsx
VendorRiskSummary.tsx
overview/ # child route: /vendors/:vendorId/overview
VendorOverviewPage.tsx
compliance/ # child route: /vendors/:vendorId/compliance
VendorCompliancePage.tsx
contacts/ # child route: /vendors/:vendorId/contacts
VendorContactsPage.tsx
pages/organizations/third-parties/
routes.ts # route definitions for third parties
ThirdPartiesPageLoader.tsx # lazy entry — providers + Suspense + query loader
ThirdPartiesPage.tsx # page component (usePreloadedQuery)
ThirdPartiesPageSkeleton.tsx # loading fallback
ThirdPartyDetailLayoutLoader.tsx # lazy entry for detail layout
ThirdPartyDetailLayout.tsx # layout — breadcrumbs, tabs, <Outlet />
ThirdPartyDetailLayoutSkeleton.tsx # detail loading fallback
NewThirdPartyPage.tsx # mutation-only page — default export, wraps itself in the Relay provider
_components/ # sub-components used only by third party pages
ThirdPartyContactRow.tsx
ThirdPartyRiskSummary.tsx
overview/ # child route: /third-parties/:thirdPartyId/overview
ThirdPartyOverviewPage.tsx
compliance/ # child route: /third-parties/:thirdPartyId/compliance
ThirdPartyCompliancePage.tsx
contacts/ # child route: /third-parties/:thirdPartyId/contacts
ThirdPartyContactsPage.tsx
```

View File

@@ -8,18 +8,18 @@ Policy-based authorization in `pkg/iam/` using an evaluation model similar to AW
**Policy** — a named collection of statements:
```go
policy.NewPolicy("vendor-crud", "Vendor CRUD",
policy.Allow(ActionVendorGet, ActionVendorList).WithSID("read-vendors"),
policy.Deny(ActionVendorDelete).WithSID("deny-vendor-delete"),
).WithDescription("Standard vendor access")
policy.NewPolicy("thirdParty-crud", "ThirdParty CRUD",
policy.Allow(ActionThirdPartyGet, ActionThirdPartyList).WithSID("read-thirdParties"),
policy.Deny(ActionThirdPartyDelete).WithSID("deny-thirdParty-delete"),
).WithDescription("Standard third party access")
```
**Statement** — a single permission rule with effect (allow/deny), actions, optional resources, and optional conditions.
**Action format**`SERVICE:RESOURCE:OPERATION` with wildcard support:
```
core:vendor:create # specific action
core:vendor:* # all vendor actions
core:thirdParty:create # specific action
core:thirdParty:* # all third party actions
core:* # all core actions
* # everything
```
@@ -39,8 +39,8 @@ The evaluator processes all statements against a request:
```go
err := iamService.Authorizer.Authorize(ctx, iam.AuthorizeParams{
Principal: identityID, // who
Resource: vendorID, // what
Action: probo.ActionVendorGet, // which action
Resource: thirdPartyID, // what
Action: probo.ActionThirdPartyGet, // which action
ResourceAttributes: map[string]string{}, // optional extra attributes
})
```
@@ -78,8 +78,8 @@ Conditions constrain when a statement applies. All conditions must be satisfied.
// Users can only access resources in their organization
organizationCondition := policy.Equals("principal.organization_id", "resource.organization_id")
policy.Allow(ActionVendorGet).
WithSID("view-vendor").
policy.Allow(ActionThirdPartyGet).
WithSID("view-thirdParty").
When(organizationCondition)
```
@@ -97,14 +97,14 @@ Key paths use `principal.ATTR` or `resource.ATTR` (e.g., `principal.organization
Resources that support authorization must implement this interface in `pkg/coredata/`:
```go
func (v *Vendor) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (map[string]string, error) {
q := `SELECT organization_id FROM vendors WHERE id = $1 LIMIT 1;`
func (v *ThirdParty) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (map[string]string, error) {
q := `SELECT organization_id FROM thirdParties WHERE id = $1 LIMIT 1;`
var organizationID gid.GID
if err := conn.QueryRow(ctx, q, v.ID).Scan(&organizationID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrResourceNotFound
}
return nil, fmt.Errorf("cannot query vendor authorization attributes: %w", err)
return nil, fmt.Errorf("cannot query third party authorization attributes: %w", err)
}
return map[string]string{"organization_id": organizationID.String()}, nil
}
@@ -126,14 +126,14 @@ var (
**GraphQL resolvers** use `AuthorizeFunc` from `pkg/server/api/authz/`:
```go
if err := authorize(ctx, vendorID, probo.ActionVendorGet); err != nil {
if err := authorize(ctx, thirdPartyID, probo.ActionThirdPartyGet); err != nil {
return nil, err
}
```
**MCP resolvers** use `MustAuthorize` which panics (caught by middleware):
```go
r.MustAuthorize(ctx, input.ID, probo.ActionVendorGet)
r.MustAuthorize(ctx, input.ID, probo.ActionThirdPartyGet)
```
## File locations
@@ -155,11 +155,11 @@ IAM actions live in `pkg/iam/iam_actions.go`, probo actions in `pkg/probo/action
```go
const (
ActionVendorGet = "core:vendor:get"
ActionVendorList = "core:vendor:list"
ActionVendorCreate = "core:vendor:create"
ActionVendorUpdate = "core:vendor:update"
ActionVendorDelete = "core:vendor:delete"
ActionThirdPartyGet = "core:thirdParty:get"
ActionThirdPartyList = "core:thirdParty:list"
ActionThirdPartyCreate = "core:thirdParty:create"
ActionThirdPartyUpdate = "core:thirdParty:update"
ActionThirdPartyDelete = "core:thirdParty:delete"
)
```

View File

@@ -13,18 +13,18 @@ Follow the [seven rules of a great Git commit message](https://cbea.ms/git-commi
The subject line should complete the sentence: "If applied, this commit will *your subject line here*".
```
Add vendor assessment agent for third-party reviews
Add third-party assessment agent for third-party reviews
The existing changelog generator only covers internal changes.
This introduces a dedicated agent that evaluates third-party
vendors against our compliance criteria, producing a structured
thirdParties against our compliance criteria, producing a structured
risk report.
```
Not every commit needs a body -- a single line is fine when the change is self-explanatory:
```
Fix typo in vendor assessment prompt
Fix typo in third-party assessment prompt
```
## Signing and Authorship

View File

@@ -43,7 +43,7 @@ Two patterns in `e2e/internal/factory/`:
**Builder pattern (preferred):**
```go
vendorID := factory.NewVendor(owner).
thirdPartyID := factory.NewThirdParty(owner).
WithName("Stripe").
WithCategory("CLOUD_PROVIDER").
Create()
@@ -59,7 +59,7 @@ controlID := factory.NewControl(owner, frameworkID).
**Simple factory functions:**
```go
vendorID := factory.CreateVendor(c, factory.Attrs{"name": "Acme"})
thirdPartyID := factory.CreateThirdParty(c, factory.Attrs{"name": "Acme"})
taskID := factory.CreateTask(c, &measureID, factory.Attrs{"name": "Task 1"})
```
@@ -70,7 +70,7 @@ Use `factory.SafeName("prefix")` for unique names and `factory.SafeEmail()` for
Every test and subtest **must** call `t.Parallel()`. One test file per entity in `e2e/console/`. Function naming: `TestEntity_Operation`.
```go
func TestVendor_Create(t *testing.T) {
func TestThirdParty_Create(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
@@ -78,9 +78,9 @@ func TestVendor_Create(t *testing.T) {
t.Parallel()
const query = `
mutation CreateVendor($input: CreateVendorInput!) {
createVendor(input: $input) {
vendorEdge {
mutation CreateThirdParty($input: CreateThirdPartyInput!) {
createThirdParty(input: $input) {
thirdPartyEdge {
node { id name }
}
}
@@ -88,25 +88,25 @@ func TestVendor_Create(t *testing.T) {
`
var result struct {
CreateVendor struct {
VendorEdge struct {
CreateThirdParty struct {
ThirdPartyEdge struct {
Node struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"node"`
} `json:"vendorEdge"`
} `json:"createVendor"`
} `json:"thirdPartyEdge"`
} `json:"createThirdParty"`
}
err := owner.Execute(query, map[string]any{
"input": map[string]any{
"organizationId": owner.GetOrganizationID().String(),
"name": factory.SafeName("Vendor"),
"name": factory.SafeName("ThirdParty"),
},
}, &result)
require.NoError(t, err)
assert.NotEmpty(t, result.CreateVendor.VendorEdge.Node.ID)
assert.NotEmpty(t, result.CreateThirdParty.ThirdPartyEdge.Node.ID)
})
}
```
@@ -139,13 +139,13 @@ t.Run("other org cannot access", func(t *testing.T) {
owner1 := testutil.NewClient(t, testutil.RoleOwner)
owner2 := testutil.NewClient(t, testutil.RoleOwner)
vendorID := factory.NewVendor(owner1).WithName("Vendor").Create()
thirdPartyID := factory.NewThirdParty(owner1).WithName("ThirdParty").Create()
var result struct {
Node *struct{ ID string } `json:"node"`
}
err := owner2.Execute(nodeQuery, map[string]any{"id": vendorID}, &result)
testutil.AssertNodeNotAccessible(t, err, result.Node == nil, "Vendor")
err := owner2.Execute(nodeQuery, map[string]any{"id": thirdPartyID}, &result)
testutil.AssertNodeNotAccessible(t, err, result.Node == nil, "ThirdParty")
})
```
@@ -214,7 +214,7 @@ for _, tt := range tests {
```go
err := owner.ExecuteWithFile(
uploadQuery,
map[string]any{"input": map[string]any{"vendorId": vendorID, "file": nil}},
map[string]any{"input": map[string]any{"thirdPartyId": thirdPartyID, "file": nil}},
"input.file",
testutil.UploadFile{
Filename: "report.pdf",

View File

@@ -131,7 +131,7 @@ if errors.As(err, &ve) {
- Constructors: `New*` (e.g. `NewService`, `NewServer`, `NewBridge`)
- Config structs: `*Config` suffix (e.g. `APIConfig`, `PgConfig`, `TrustCenterConfig`)
- Request structs: `*Request` suffix (e.g. `UpdateTrustCenterRequest`)
- Unexported types for internal data: lowercase (e.g. `vendorInfo`, `ctxKey`)
- Unexported types for internal data: lowercase (e.g. `thirdPartyInfo`, `ctxKey`)
## Functional options and Config structs

View File

@@ -7,9 +7,9 @@ Schema-first GraphQL using [gqlgen](https://gqlgen.com/). The schema is hand-wri
Each API's schema lives in `pkg/server/api/{api}/v1/graphql/` as multiple `.graphql` files, one per coredata model:
- `base.graphql` — directives, scalars, Node interface, PageInfo, OrderDirection, root Query/Mutation/Organization types
- Entity files (e.g., `vendor.graphql`, `control.graphql`) — use `extend type Mutation` to add their mutations.
- Entity files (e.g., `thirdParty.graphql`, `control.graphql`) — use `extend type Mutation` to add their mutations.
gqlgen's `follow-schema` layout generates one resolver file per schema file (e.g., `vendor.resolvers.go`). Types that get extended across files (Organization, Mutation, Viewer, TrustCenter) must be defined in `base.graphql`.
gqlgen's `follow-schema` layout generates one resolver file per schema file (e.g., `thirdParty.resolvers.go`). Types that get extended across files (Organization, Mutation, Viewer, TrustCenter) must be defined in `base.graphql`.
### `extend type` restrictions
@@ -20,18 +20,18 @@ gqlgen's `follow-schema` layout generates one resolver file per schema file (e.g
**Always define a custom Go type for connection types** using the `@goModel` directive. The model path points to the `types` package for the relevant API. The `totalCount` field must use `@goField(forceResolver: true)`. Edge types do not need `@goModel`.
```graphql
type VendorConnection
type ThirdPartyConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.VendorConnection"
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.ThirdPartyConnection"
) {
totalCount: Int! @goField(forceResolver: true)
edges: [VendorEdge!]!
edges: [ThirdPartyEdge!]!
pageInfo: PageInfo!
}
type VendorEdge {
type ThirdPartyEdge {
cursor: CursorKey!
node: Vendor!
node: ThirdParty!
}
```
@@ -42,12 +42,12 @@ Without `@goModel`, gqlgen generates a default struct that lacks the custom fiel
Map GraphQL enums to existing Go types using `@goModel` on the enum and `@goEnum` on each value:
```graphql
enum VendorOrderField
@goModel(model: "go.probo.inc/probo/pkg/coredata.VendorOrderField") {
enum ThirdPartyOrderField
@goModel(model: "go.probo.inc/probo/pkg/coredata.ThirdPartyOrderField") {
CREATED_AT
@goEnum(value: "go.probo.inc/probo/pkg/coredata.VendorOrderFieldCreatedAt")
@goEnum(value: "go.probo.inc/probo/pkg/coredata.ThirdPartyOrderFieldCreatedAt")
NAME
@goEnum(value: "go.probo.inc/probo/pkg/coredata.VendorOrderFieldName")
@goEnum(value: "go.probo.inc/probo/pkg/coredata.ThirdPartyOrderFieldName")
}
```
@@ -88,14 +88,14 @@ Connection fields on parent types use standard Relay arguments:
```graphql
type Organization {
vendors(
thirdParties(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: VendorOrder
filter: VendorFilter
): VendorConnection!
orderBy: ThirdPartyOrder
filter: ThirdPartyFilter
): ThirdPartyConnection!
}
```
@@ -105,11 +105,11 @@ Each connection type lives in `types/*_connection.go` and follows this structure
```go
type (
VendorOrderBy OrderBy[coredata.VendorOrderField]
ThirdPartyOrderBy OrderBy[coredata.ThirdPartyOrderField]
VendorConnection struct {
ThirdPartyConnection struct {
TotalCount int
Edges []*VendorEdge
Edges []*ThirdPartyEdge
PageInfo PageInfo
Resolver any
@@ -117,17 +117,17 @@ type (
}
)
func NewVendorConnection(
p *page.Page[*coredata.Vendor, coredata.VendorOrderField],
func NewThirdPartyConnection(
p *page.Page[*coredata.ThirdParty, coredata.ThirdPartyOrderField],
parentType any,
parentID gid.GID,
) *VendorConnection {
edges := make([]*VendorEdge, len(p.Data))
) *ThirdPartyConnection {
edges := make([]*ThirdPartyEdge, len(p.Data))
for i, v := range p.Data {
edges[i] = NewVendorEdge(v, p.Cursor.OrderBy.Field)
edges[i] = NewThirdPartyEdge(v, p.Cursor.OrderBy.Field)
}
return &VendorConnection{
return &ThirdPartyConnection{
Edges: edges,
PageInfo: *NewPageInfo(p),
@@ -136,13 +136,13 @@ func NewVendorConnection(
}
}
func NewVendorEdge(
v *coredata.Vendor,
orderBy coredata.VendorOrderField,
) *VendorEdge {
return &VendorEdge{
func NewThirdPartyEdge(
v *coredata.ThirdParty,
orderBy coredata.ThirdPartyOrderField,
) *ThirdPartyEdge {
return &ThirdPartyEdge{
Cursor: v.CursorKey(orderBy),
Node: NewVendor(v),
Node: NewThirdParty(v),
}
}
```

View File

@@ -8,7 +8,7 @@ MCP tools are defined in `pkg/server/api/mcp/v1/specification.yaml` and generate
- `specification.yaml` — tool definitions, input/output schemas, component schemas
- `resolver.go``Resolver` struct, `MustAuthorize`, service accessors
- `helpers.go` — pagination helpers, `UnwrapOmittable`
- `types/*.go` (except `types/types.go`) — type conversion helpers (`NewVendor()`, `NewListVendorsOutput()`, etc.)
- `types/*.go` (except `types/types.go`) — type conversion helpers (`NewThirdParty()`, `NewListThirdPartiesOutput()`, etc.)
- `schema.resolvers.go` — tool implementation bodies (stubs generated, you edit the bodies)
**Generated** (do not edit):
@@ -24,16 +24,16 @@ go generate ./pkg/server/api/mcp/v1
```yaml
tools:
- name: listVendors
description: List all vendors for the organization
- name: listThirdParties
description: List all thirdParties for the organization
hints:
readonly: true
idempotent: true
destructive: false
inputSchema:
$ref: "#/components/schemas/ListVendorsInput"
$ref: "#/components/schemas/ListThirdPartiesInput"
outputSchema:
$ref: "#/components/schemas/ListVendorsOutput"
$ref: "#/components/schemas/ListThirdPartiesOutput"
```
Input/output schemas reference `components/schemas`. Map custom Go types with the `go.probo.inc/mcpgen/type` extension:
@@ -51,11 +51,11 @@ components:
Generated stubs follow this pattern:
```go
func (r *Resolver) ListVendorsTool(
func (r *Resolver) ListThirdPartiesTool(
ctx context.Context,
req *mcp.CallToolRequest,
input *types.ListVendorsInput,
) (*mcp.CallToolResult, types.ListVendorsOutput, error)
input *types.ListThirdPartiesInput,
) (*mcp.CallToolResult, types.ListThirdPartiesOutput, error)
```
First return is always `nil`. Errors are either returned (for recoverable) or panicked (for authorization and unexpected failures).
@@ -65,24 +65,24 @@ First return is always `nil`. Errors are either returned (for recoverable) or pa
Use `MustAuthorize` which panics on failure (caught by middleware):
```go
r.MustAuthorize(ctx, input.OrganizationID, probo.ActionVendorList)
r.MustAuthorize(ctx, input.OrganizationID, probo.ActionThirdPartyList)
```
## Common resolver patterns
**List with pagination:**
```go
func (r *Resolver) ListVendorsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListVendorsInput) (*mcp.CallToolResult, types.ListVendorsOutput, error) {
r.MustAuthorize(ctx, input.OrganizationID, probo.ActionVendorList)
func (r *Resolver) ListThirdPartiesTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListThirdPartiesInput) (*mcp.CallToolResult, types.ListThirdPartiesOutput, error) {
r.MustAuthorize(ctx, input.OrganizationID, probo.ActionThirdPartyList)
prb := r.ProboService(ctx, input.OrganizationID)
pageOrderBy := page.OrderBy[coredata.VendorOrderField]{
Field: coredata.VendorOrderFieldCreatedAt,
pageOrderBy := page.OrderBy[coredata.ThirdPartyOrderField]{
Field: coredata.ThirdPartyOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
if input.OrderBy != nil {
pageOrderBy = page.OrderBy[coredata.VendorOrderField]{
pageOrderBy = page.OrderBy[coredata.ThirdPartyOrderField]{
Field: input.OrderBy.Field,
Direction: input.OrderBy.Direction,
}
@@ -90,12 +90,12 @@ func (r *Resolver) ListVendorsTool(ctx context.Context, req *mcp.CallToolRequest
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
page, err := prb.Vendors.ListForOrganizationID(ctx, input.OrganizationID, cursor, coredata.NewVendorFilter(nil, nil))
page, err := prb.ThirdParties.ListForOrganizationID(ctx, input.OrganizationID, cursor, coredata.NewThirdPartyFilter(nil, nil))
if err != nil {
panic(fmt.Errorf("cannot list vendors: %w", err))
panic(fmt.Errorf("cannot list thirdParties: %w", err))
}
return nil, types.NewListVendorsOutput(page), nil
return nil, types.NewListThirdPartiesOutput(page), nil
}
```
@@ -156,8 +156,8 @@ Description: UnwrapOmittable(input.Description),
Live in `types/*.go` (not the generated `types/types.go`). One file per entity:
```go
func NewVendor(v *coredata.Vendor) *Vendor {
return &Vendor{
func NewThirdParty(v *coredata.ThirdParty) *ThirdParty {
return &ThirdParty{
ID: v.ID,
OrganizationID: v.OrganizationID,
Name: v.Name,
@@ -166,21 +166,21 @@ func NewVendor(v *coredata.Vendor) *Vendor {
}
}
func NewListVendorsOutput(vendorPage *page.Page[*coredata.Vendor, coredata.VendorOrderField]) ListVendorsOutput {
vendors := make([]*Vendor, 0, len(vendorPage.Data))
for _, v := range vendorPage.Data {
vendors = append(vendors, NewVendor(v))
func NewListThirdPartiesOutput(thirdPartyPage *page.Page[*coredata.ThirdParty, coredata.ThirdPartyOrderField]) ListThirdPartiesOutput {
thirdParties := make([]*ThirdParty, 0, len(thirdPartyPage.Data))
for _, v := range thirdPartyPage.Data {
thirdParties = append(thirdParties, NewThirdParty(v))
}
var nextCursor *page.CursorKey
if len(vendorPage.Data) > 0 {
cursorKey := vendorPage.Data[len(vendorPage.Data)-1].CursorKey(vendorPage.Cursor.OrderBy.Field)
if len(thirdPartyPage.Data) > 0 {
cursorKey := thirdPartyPage.Data[len(thirdPartyPage.Data)-1].CursorKey(thirdPartyPage.Cursor.OrderBy.Field)
nextCursor = &cursorKey
}
return ListVendorsOutput{
return ListThirdPartiesOutput{
NextCursor: nextCursor,
Vendors: vendors,
ThirdParties: thirdParties,
}
}
```

View File

@@ -79,10 +79,10 @@ export function UserCard({ name }: UserCardProps) {
```tsx
// Good — destructure in body when parameter-level destructuring would exceed the line-length limit
export function VendorComplianceOverviewPanel(
props: VendorComplianceOverviewPanelProps,
export function ThirdPartyComplianceOverviewPanel(
props: ThirdPartyComplianceOverviewPanelProps,
) {
const { className, vendorKey, onStatusChange } = props;
const { className, thirdPartyKey, onStatusChange } = props;
// …
}
```
@@ -139,11 +139,11 @@ export function Thing({ label }: ThingProps) {
```tsx
// Good — rare exception: route entry default export (names still clear in module)
type VendorsPageProps = {
queryRef: PreloadedQuery<VendorsQuery>;
type ThirdPartiesPageProps = {
queryRef: PreloadedQuery<ThirdPartiesQuery>;
};
export default function VendorsPage({ queryRef }: VendorsPageProps) {
export default function ThirdPartiesPage({ queryRef }: ThirdPartiesPageProps) {
// …
}
```
@@ -204,7 +204,7 @@ Use props for:
### Hooks for data and URL-derived identity
- **Fetched data:** Colocate Relay fragments and queries per [`contrib/claude/relay.md`](relay.md) (`useFragment`, `useLazyLoadQuery`, `usePreloadedQuery`, etc.) in the component that needs the data.
- **Route parameters:** Call `useParams()` (or a small `useOrganizationId()`-style hook) **inside** the component that needs the id — avoid drilling `organizationId` / `vendorId` from a parent that only read the URL to pass them down.
- **Route parameters:** Call `useParams()` (or a small `useOrganizationId()`-style hook) **inside** the component that needs the id — avoid drilling `organizationId` / `thirdPartyId` from a parent that only read the URL to pass them down.
### Relay: framework wiring is not “business data props”
@@ -214,36 +214,36 @@ Relay sometimes requires **opaque handles** on props: e.g. **`queryRef`** for `u
```tsx
// Bad — parent only needed the param to pass it down
function VendorLayout() {
const { vendorId } = useParams();
function ThirdPartyLayout() {
const { thirdPartyId } = useParams();
return (
<main>
<VendorSummary vendorId={vendorId!} />
<ThirdPartySummary thirdPartyId={thirdPartyId!} />
</main>
);
}
function VendorSummary({ vendorId }: { vendorId: string }) {
function ThirdPartySummary({ thirdPartyId }: { thirdPartyId: string }) {
return <div>{/* … */}</div>;
}
```
```tsx
// Good — component that needs the id reads it (or uses a dedicated hook)
function VendorLayout() {
function ThirdPartyLayout() {
return (
<main>
<VendorSummary />
<ThirdPartySummary />
</main>
);
}
function VendorSummary() {
const { vendorId } = useParams();
if (vendorId == null) {
function ThirdPartySummary() {
const { thirdPartyId } = useParams();
if (thirdPartyId == null) {
return null;
}
return <div>{/* use vendorId in a hook / query … */}</div>;
return <div>{/* use thirdPartyId in a hook / query … */}</div>;
}
```
@@ -251,13 +251,13 @@ function VendorSummary() {
```tsx
// Bad — parent loaded data and passes fields as props
function VendorPage() {
const vendor = useLazyLoadQuery(/* … */);
function ThirdPartyPage() {
const thirdParty = useLazyLoadQuery(/* … */);
return (
<VendorHeader
name={vendor.name}
riskScore={vendor.riskScore}
updatedAt={vendor.updatedAt}
<ThirdPartyHeader
name={thirdParty.name}
riskScore={thirdParty.riskScore}
updatedAt={thirdParty.updatedAt}
/>
);
}
@@ -265,24 +265,24 @@ function VendorPage() {
```tsx
// Good — header colocates its fragment and reads via useFragment
const vendorHeaderFragment = graphql`
fragment VendorHeader_vendor on Vendor {
const thirdPartyHeaderFragment = graphql`
fragment ThirdPartyHeader_thirdParty on ThirdParty {
name
riskScore
updatedAt
}
`;
interface VendorHeaderProps {
interface ThirdPartyHeaderProps {
className?: string;
vendorKey: VendorHeader_vendor$key;
thirdPartyKey: ThirdPartyHeader_thirdParty$key;
}
export function VendorHeader({ className, vendorKey }: VendorHeaderProps) {
const vendor = useFragment(vendorHeaderFragment, vendorKey);
export function ThirdPartyHeader({ className, thirdPartyKey }: ThirdPartyHeaderProps) {
const thirdParty = useFragment(thirdPartyHeaderFragment, thirdPartyKey);
return (
<header className={className}>
{/* render from vendor … */}
{/* render from thirdParty … */}
</header>
);
}

View File

@@ -191,7 +191,7 @@ Fragments colocate data requirements with the component that reads them:
```tsx
const contactFragment = graphql`
fragment ContactRow_contactFragment on VendorContact {
fragment ContactRow_contactFragment on ThirdPartyContact {
id
fullName
email
@@ -199,8 +199,8 @@ const contactFragment = graphql`
role
createdAt
updatedAt
canUpdate: permission(action: "core:vendor-contact:update")
canDelete: permission(action: "core:vendor-contact:delete")
canUpdate: permission(action: "core:thirdParty-contact:update")
canDelete: permission(action: "core:thirdParty-contact:delete")
}
`;
@@ -215,12 +215,12 @@ function ContactRow(props: { contactKey: ContactRow_contactFragment$key }) {
For lists that support sorting and pagination, use `@refetchable` with `@argumentDefinitions`:
```tsx
const vendorContactsFragment = graphql`
fragment VendorContactsTabFragment on Vendor
@refetchable(queryName: "VendorContactsListQuery")
const thirdPartyContactsFragment = graphql`
fragment ThirdPartyContactsTabFragment on ThirdParty
@refetchable(queryName: "ThirdPartyContactsListQuery")
@argumentDefinitions(
first: { type: "Int", defaultValue: 50 }
order: { type: "VendorContactOrder", defaultValue: null }
order: { type: "ThirdPartyContactOrder", defaultValue: null }
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
@@ -231,18 +231,18 @@ const vendorContactsFragment = graphql`
last: $last
before: $before
orderBy: $order
) @connection(key: "VendorContactsTabFragment_contacts") {
) @connection(key: "ThirdPartyContactsTabFragment_contacts") {
__id
edges {
node {
...VendorContactsTabFragment_contact
...ThirdPartyContactsTabFragment_contact
}
}
}
}
`;
const [data, refetch] = useRefetchableFragment(vendorContactsFragment, vendor);
const [data, refetch] = useRefetchableFragment(thirdPartyContactsFragment, thirdParty);
const connectionId = data.contacts.__id;
```
@@ -251,9 +251,9 @@ const connectionId = data.contacts.__id;
Use `usePaginationFragment` for cursor-based Relay pagination:
```tsx
const pagination = usePaginationFragment(paginatedVendorsFragment, data.node);
const vendors = pagination.data.vendors?.edges.map(edge => edge.node);
const connectionId = pagination.data.vendors.__id;
const pagination = usePaginationFragment(paginatedThirdPartiesFragment, data.node);
const thirdParties = pagination.data.thirdParties?.edges.map(edge => edge.node);
const connectionId = pagination.data.thirdParties.__id;
```
The `@connection(key: "...", filters: [...])` directive on the fragment tells Relay how to manage the paginated list in the store. The `filters` array controls which variables affect the connection identity.
@@ -294,7 +294,7 @@ createCookieBanner({ variables: { ... } });
#### Examples
```tsx
const [deleteVendor] = useMutation<VendorGraphDeleteMutation>(deleteVendorMutation);
const [deleteThirdParty] = useMutation<ThirdPartyGraphDeleteMutation>(deleteThirdPartyMutation);
```
For mutations with user feedback, combine with `useToast` and use `onCompleted`/`onError` callbacks:
@@ -415,9 +415,9 @@ This is useful for dialogs, drawers, or other components rendered outside the su
```tsx
// Add new edge to a connection
const createMutation = graphql`
mutation CreateVendorMutation($input: CreateVendorInput!, $connections: [ID!]!) {
createVendor(input: $input) {
vendorEdge @prependEdge(connections: $connections) {
mutation CreateThirdPartyMutation($input: CreateThirdPartyInput!, $connections: [ID!]!) {
createThirdParty(input: $input) {
thirdPartyEdge @prependEdge(connections: $connections) {
node {
id
name
@@ -429,19 +429,19 @@ const createMutation = graphql`
// Remove an edge from a connection
const deleteMutation = graphql`
mutation DeleteVendorMutation($input: DeleteVendorInput!, $connections: [ID!]!) {
deleteVendor(input: $input) {
deletedVendorId @deleteEdge(connections: $connections)
mutation DeleteThirdPartyMutation($input: DeleteThirdPartyInput!, $connections: [ID!]!) {
deleteThirdParty(input: $input) {
deletedThirdPartyId @deleteEdge(connections: $connections)
}
}
`;
// Update in-place (Relay matches by id — no directive needed)
const updateMutation = graphql`
mutation UpdateContactMutation($input: UpdateVendorContactInput!) {
updateVendorContact(input: $input) {
vendorContact {
...VendorContactsTabFragment_contact
mutation UpdateContactMutation($input: UpdateThirdPartyContactInput!) {
updateThirdPartyContact(input: $input) {
thirdPartyContact {
...ThirdPartyContactsTabFragment_contact
}
}
}
@@ -505,15 +505,15 @@ Destructive mutations (delete) are wrapped with a confirmation dialog:
```tsx
const confirm = useConfirm();
const [deleteVendor] = useMutation<DeleteVendorMutation>(deleteVendorMutation);
const [deleteThirdParty] = useMutation<DeleteThirdPartyMutation>(deleteThirdPartyMutation);
return () => {
confirm(
() =>
new Promise<void>((resolve) => {
deleteVendor({
deleteThirdParty({
variables: {
input: { vendorId: vendor.id! },
input: { thirdPartyId: thirdParty.id! },
connections: [connectionId],
},
onCompleted() {
@@ -534,14 +534,14 @@ return () => {
GraphQL operations are colocated with the components that use them. See [`contrib/claude/app-arborescence.md`](app-arborescence.md) for the full folder layout.
```
pages/organizations/vendors/
VendorsPage.tsx # query + pagination fragment
pages/organizations/third-parties/
ThirdPartiesPage.tsx # query + pagination fragment
_components/
CreateContactDialog.tsx # create mutation
EditContactDialog.tsx # update mutation
tabs/
VendorContactsTab.tsx # refetchable fragment + item fragment
VendorComplianceTab.tsx
ThirdPartyContactsTab.tsx # refetchable fragment + item fragment
ThirdPartyComplianceTab.tsx
```
Component-specific operations (queries, fragments, mutations) are defined inline in the component file that uses them. Shared sub-components live in `_components/` next to the page (scoped to the nearest common ancestor).

View File

@@ -7,7 +7,7 @@ Custom fluent validation API in `pkg/validator/`. Used in every service method t
Create a validator, chain `Check()` calls for each field, then call `Error()` to get accumulated errors:
```go
func (req *CreateVendorRequest) Validate() error {
func (req *CreateThirdPartyRequest) Validate() error {
v := validator.New()
v.Check(req.OrganizationID, "organization_id",
@@ -19,7 +19,7 @@ func (req *CreateVendorRequest) Validate() error {
validator.SafeTextNoNewLine(TitleMaxLength),
)
v.Check(req.Category, "category",
validator.OneOfSlice(coredata.VendorCategories()),
validator.OneOfSlice(coredata.ThirdPartyCategories()),
)
return v.Error()
@@ -81,7 +81,7 @@ v.CheckEach(ids, "ids", func(index int, item any) {
gidValue := item.(gid.GID)
v.Check(gidValue, fmt.Sprintf("ids[%d]", index),
validator.Required(),
validator.GID(coredata.VendorEntityType),
validator.GID(coredata.ThirdPartyEntityType),
)
})
```
@@ -135,7 +135,7 @@ Validation errors flow naturally through Go's error interface:
3. GraphQL/HTTP handlers convert `ValidationErrors` to appropriate response format
```go
func (s *Service) CreateVendor(ctx context.Context, req CreateVendorRequest) (*coredata.Vendor, error) {
func (s *Service) CreateThirdParty(ctx context.Context, req CreateThirdPartyRequest) (*coredata.ThirdParty, error) {
if err := req.Validate(); err != nil {
return nil, err
}

View File

@@ -480,7 +480,7 @@ create_risk \
SECURITY MITIGATED 1 5
create_risk \
"Third-party SaaS vendor data breach" \
"Third-party SaaS data breach" \
OPERATIONAL TRANSFERRED 3 4
create_risk \
"Cloud region outage causing service disruption" \
@@ -514,7 +514,7 @@ create_risk \
"Breach notification deadline missed" \
COMPLIANCE MITIGATED 1 5
create_risk \
"Inadequate data processing agreements with vendors" \
"Inadequate data processing agreements with third parties" \
COMPLIANCE MITIGATED 3 3
create_risk \
"Employee data retained beyond legal period" \
@@ -553,17 +553,17 @@ create_risk \
echo " 35 risks created"
echo " Creating vendors..."
echo " Creating third parties..."
create_vendor() {
create_third_party() {
local name="$1"
local description="$2"
local resp
resp=$(prb_api "createVendor: $name" '
mutation($input: CreateVendorInput!) {
createVendor(input: $input) {
vendorEdge {
resp=$(prb_api "createThirdParty: $name" '
mutation($input: CreateThirdPartyInput!) {
createThirdParty(input: $input) {
thirdPartyEdge {
node { id }
}
}
@@ -574,55 +574,55 @@ create_vendor() {
description="$description" \
)")
local id
id=$(echo "$resp" | jq -r '.data.createVendor.vendorEdge.node.id // empty')
id=$(echo "$resp" | jq -r '.data.createThirdParty.thirdPartyEdge.node.id // empty')
if [ -z "$id" ]; then
echo "ERROR (createVendor: $name): no vendor id in response" >&2
echo "ERROR (createThirdParty: $name): no third party id in response" >&2
exit 1
fi
}
create_vendor "Amazon Web Services" \
create_third_party "Amazon Web Services" \
"Cloud infrastructure and compute"
create_vendor "Google Cloud Platform" \
create_third_party "Google Cloud Platform" \
"BigQuery analytics and AI services"
create_vendor "Google Workspace" \
create_third_party "Google Workspace" \
"Email, calendar, and productivity suite"
create_vendor "Microsoft 365" \
create_third_party "Microsoft 365" \
"Office productivity and collaboration"
create_vendor "Datadog" \
create_third_party "Datadog" \
"Application monitoring and observability"
create_vendor "PagerDuty" \
create_third_party "PagerDuty" \
"Incident management and on-call scheduling"
create_vendor "Slack" \
create_third_party "Slack" \
"Team communication and messaging"
create_vendor "GitHub" \
create_third_party "GitHub" \
"Source code management and CI/CD"
create_vendor "Stripe" \
create_third_party "Stripe" \
"Payment processing and billing"
create_vendor "Salesforce" \
create_third_party "Salesforce" \
"Customer relationship management"
create_vendor "HubSpot" \
create_third_party "HubSpot" \
"Marketing automation and CRM"
create_vendor "Notion" \
create_third_party "Notion" \
"Documentation and knowledge management"
create_vendor "1Password" \
create_third_party "1Password" \
"Enterprise password management"
create_vendor "Okta" \
create_third_party "Okta" \
"Identity and access management"
create_vendor "CrowdStrike" \
create_third_party "CrowdStrike" \
"Endpoint protection and threat intelligence"
create_vendor "Vanta" \
create_third_party "Vanta" \
"Compliance automation and monitoring"
create_vendor "Jira" \
create_third_party "Jira" \
"Project management and issue tracking"
create_vendor "Cloudflare" \
create_third_party "Cloudflare" \
"CDN, DNS, and DDoS protection"
create_vendor "Twilio SendGrid" \
create_third_party "Twilio SendGrid" \
"Transactional email delivery"
create_vendor "Snowflake" \
create_third_party "Snowflake" \
"Cloud data warehouse"
echo " 20 vendors created"
echo " 20 third parties created"
echo " Creating measures..."
@@ -696,7 +696,7 @@ echo ""
echo " Created:"
echo " 3 frameworks, 43 controls"
echo " 35 risks"
echo " 20 vendors"
echo " 20 third parties"
echo " 15 measures"
echo " 8 people"
echo ""

View File

@@ -27,8 +27,8 @@ func TestAuditLog_List(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
// Create a vendor to generate an audit log entry.
factory.NewVendor(owner).WithName(factory.SafeName("AuditVendor")).Create()
// Create a thirdParty to generate an audit log entry.
factory.NewThirdParty(owner).WithName(factory.SafeName("AuditThirdParty")).Create()
const query = `
query($orgId: ID!) {
@@ -78,20 +78,20 @@ func TestAuditLog_List(t *testing.T) {
require.NoError(t, err)
assert.GreaterOrEqual(t, result.Node.AuditLogEntries.TotalCount, 1)
// Find the vendor create entry.
// Find the thirdParty create entry.
found := false
for _, edge := range result.Node.AuditLogEntries.Edges {
if edge.Node.Action == "core:vendor:create" {
if edge.Node.Action == "core:thirdParty:create" {
found = true
assert.Equal(t, "USER", edge.Node.ActorType)
assert.Equal(t, "Vendor", edge.Node.ResourceType)
assert.Equal(t, "ThirdParty", edge.Node.ResourceType)
assert.NotEmpty(t, edge.Node.ActorID)
assert.NotEmpty(t, edge.Node.ResourceID)
assert.NotEmpty(t, edge.Node.CreatedAt)
break
}
}
assert.True(t, found, "expected to find core:vendor:create audit log entry")
assert.True(t, found, "expected to find core:thirdParty:create audit log entry")
}
func TestAuditLog_Filter(t *testing.T) {
@@ -99,7 +99,7 @@ func TestAuditLog_Filter(t *testing.T) {
owner := testutil.NewClient(t, testutil.RoleOwner)
// Create different resources to generate different audit log entries.
factory.NewVendor(owner).WithName(factory.SafeName("FilterVendor")).Create()
factory.NewThirdParty(owner).WithName(factory.SafeName("FilterThirdParty")).Create()
const query = `
query($orgId: ID!, $filter: AuditLogEntryFilter) {
@@ -139,12 +139,12 @@ func TestAuditLog_Filter(t *testing.T) {
err := owner.Execute(query, map[string]any{
"orgId": owner.GetOrganizationID().String(),
"filter": map[string]any{"action": "core:vendor:create"},
"filter": map[string]any{"action": "core:thirdParty:create"},
}, &result)
require.NoError(t, err)
assert.GreaterOrEqual(t, result.Node.AuditLogEntries.TotalCount, 1)
for _, edge := range result.Node.AuditLogEntries.Edges {
assert.Equal(t, "core:vendor:create", edge.Node.Action)
assert.Equal(t, "core:thirdParty:create", edge.Node.Action)
}
})
@@ -167,12 +167,12 @@ func TestAuditLog_Filter(t *testing.T) {
err := owner.Execute(query, map[string]any{
"orgId": owner.GetOrganizationID().String(),
"filter": map[string]any{"resourceType": "Vendor"},
"filter": map[string]any{"resourceType": "ThirdParty"},
}, &result)
require.NoError(t, err)
assert.GreaterOrEqual(t, result.Node.AuditLogEntries.TotalCount, 1)
for _, edge := range result.Node.AuditLogEntries.Edges {
assert.Equal(t, "Vendor", edge.Node.ResourceType)
assert.Equal(t, "ThirdParty", edge.Node.ResourceType)
}
})
}
@@ -182,7 +182,7 @@ func TestAuditLog_RBAC(t *testing.T) {
owner := testutil.NewClient(t, testutil.RoleOwner)
// Generate an audit log entry.
factory.NewVendor(owner).WithName(factory.SafeName("RBACVendor")).Create()
factory.NewThirdParty(owner).WithName(factory.SafeName("RBACThirdParty")).Create()
const query = `
query($orgId: ID!) {
@@ -253,8 +253,8 @@ func TestAuditLog_TenantIsolation(t *testing.T) {
org1Owner := testutil.NewClient(t, testutil.RoleOwner)
org2Owner := testutil.NewClient(t, testutil.RoleOwner)
// Create a vendor in org1 to generate audit log entries.
factory.NewVendor(org1Owner).WithName(factory.SafeName("IsoVendor")).Create()
// Create a thirdParty in org1 to generate audit log entries.
factory.NewThirdParty(org1Owner).WithName(factory.SafeName("IsoThirdParty")).Create()
const query = `
query($orgId: ID!) {
@@ -275,7 +275,7 @@ func TestAuditLog_TenantIsolation(t *testing.T) {
}
`
// org2 should not see org1's audit log entries about vendors.
// org2 should not see org1's audit log entries about thirdParties.
var result struct {
Node struct {
AuditLogEntries struct {
@@ -298,9 +298,9 @@ func TestAuditLog_TenantIsolation(t *testing.T) {
for _, edge := range result.Node.AuditLogEntries.Edges {
// org2 may have its own audit log entries (from user/org creation),
// but should never see org1's vendor entries.
if edge.Node.ResourceType == "Vendor" {
t.Fatalf("org2 should not see org1's vendor audit log entries, but found: %s", edge.Node.Action)
// but should never see org1's thirdParty entries.
if edge.Node.ResourceType == "ThirdParty" {
t.Fatalf("org2 should not see org1's thirdParty audit log entries, but found: %s", edge.Node.Action)
}
}
}

View File

@@ -174,32 +174,32 @@ const (
}
}`
createVendorMutation = `
mutation CreateVendor($input: CreateVendorInput!) {
createVendor(input: $input) {
vendorEdge { node { id } }
createThirdPartyMutation = `
mutation CreateThirdParty($input: CreateThirdPartyInput!) {
createThirdParty(input: $input) {
thirdPartyEdge { node { id } }
}
}`
updateVendorMutation = `
mutation UpdateVendor($input: UpdateVendorInput!) {
updateVendor(input: $input) {
vendor { id }
updateThirdPartyMutation = `
mutation UpdateThirdParty($input: UpdateThirdPartyInput!) {
updateThirdParty(input: $input) {
thirdParty { id }
}
}`
deleteVendorMutation = `
mutation DeleteVendor($input: DeleteVendorInput!) {
deleteVendor(input: $input) {
deletedVendorId
deleteThirdPartyMutation = `
mutation DeleteThirdParty($input: DeleteThirdPartyInput!) {
deleteThirdParty(input: $input) {
deletedThirdPartyId
}
}`
listVendorsQuery = `
query GetVendors($id: ID!) {
listThirdPartiesQuery = `
query GetThirdParties($id: ID!) {
node(id: $id) {
... on Organization {
vendors(first: 10) { totalCount }
thirdParties(first: 10) { totalCount }
}
}
}`
@@ -304,7 +304,7 @@ func TestRBAC(t *testing.T) {
measureID := factory.NewMeasure(owner).WithName("RBAC Test Measure").Create()
taskID := factory.NewTask(owner, measureID).WithName("RBAC Test Task").Create()
riskID := factory.NewRisk(owner).WithName("RBAC Test Risk").Create()
vendorID := factory.NewVendor(owner).WithName("RBAC Test Vendor").Create()
thirdPartyID := factory.NewThirdParty(owner).WithName("RBAC Test ThirdParty").Create()
accessSourceID := factory.NewAccessSource(owner, owner.GetOrganizationID().String()).WithName("RBAC Test Source").Create()
accessReviewCampaignID := factory.NewAccessReviewCampaign(owner, owner.GetOrganizationID().String()).WithName("RBAC Test Campaign").Create()
@@ -936,123 +936,123 @@ func TestRBAC(t *testing.T) {
shouldAllow: true,
},
{
name: "owner can create vendor",
name: "owner can create thirdParty",
role: "owner",
client: owner,
query: createVendorMutation,
query: createThirdPartyMutation,
variables: func() map[string]any {
return map[string]any{"input": map[string]any{"organizationId": owner.GetOrganizationID().String(), "name": factory.SafeName("Vendor")}}
return map[string]any{"input": map[string]any{"organizationId": owner.GetOrganizationID().String(), "name": factory.SafeName("ThirdParty")}}
},
shouldAllow: true,
},
{
name: "admin can create vendor",
name: "admin can create thirdParty",
role: "admin",
client: admin,
query: createVendorMutation,
query: createThirdPartyMutation,
variables: func() map[string]any {
return map[string]any{"input": map[string]any{"organizationId": owner.GetOrganizationID().String(), "name": factory.SafeName("Vendor")}}
return map[string]any{"input": map[string]any{"organizationId": owner.GetOrganizationID().String(), "name": factory.SafeName("ThirdParty")}}
},
shouldAllow: true,
},
{
name: "viewer cannot create vendor",
name: "viewer cannot create thirdParty",
role: "viewer",
client: viewer,
query: createVendorMutation,
query: createThirdPartyMutation,
variables: func() map[string]any {
return map[string]any{"input": map[string]any{"organizationId": owner.GetOrganizationID().String(), "name": factory.SafeName("Vendor")}}
return map[string]any{"input": map[string]any{"organizationId": owner.GetOrganizationID().String(), "name": factory.SafeName("ThirdParty")}}
},
shouldAllow: false,
},
{
name: "owner can update vendor",
name: "owner can update thirdParty",
role: "owner",
client: owner,
query: updateVendorMutation,
query: updateThirdPartyMutation,
variables: func() map[string]any {
return map[string]any{"input": map[string]any{"id": vendorID, "name": factory.SafeName("Updated Vendor")}}
return map[string]any{"input": map[string]any{"id": thirdPartyID, "name": factory.SafeName("Updated ThirdParty")}}
},
shouldAllow: true,
},
{
name: "admin can update vendor",
name: "admin can update thirdParty",
role: "admin",
client: admin,
query: updateVendorMutation,
query: updateThirdPartyMutation,
variables: func() map[string]any {
return map[string]any{"input": map[string]any{"id": vendorID, "name": factory.SafeName("Updated Vendor")}}
return map[string]any{"input": map[string]any{"id": thirdPartyID, "name": factory.SafeName("Updated ThirdParty")}}
},
shouldAllow: true,
},
{
name: "viewer cannot update vendor",
name: "viewer cannot update thirdParty",
role: "viewer",
client: viewer,
query: updateVendorMutation,
query: updateThirdPartyMutation,
variables: func() map[string]any {
return map[string]any{"input": map[string]any{"id": vendorID, "name": factory.SafeName("Updated Vendor")}}
return map[string]any{"input": map[string]any{"id": thirdPartyID, "name": factory.SafeName("Updated ThirdParty")}}
},
shouldAllow: false,
},
{
name: "owner can delete vendor",
name: "owner can delete thirdParty",
role: "owner",
client: owner,
query: deleteVendorMutation,
query: deleteThirdPartyMutation,
variables: func() map[string]any {
id := factory.NewVendor(owner).WithName(factory.SafeName("ToDelete")).Create()
return map[string]any{"input": map[string]any{"vendorId": id}}
id := factory.NewThirdParty(owner).WithName(factory.SafeName("ToDelete")).Create()
return map[string]any{"input": map[string]any{"thirdPartyId": id}}
},
shouldAllow: true,
},
{
name: "admin can delete vendor",
name: "admin can delete thirdParty",
role: "admin",
client: admin,
query: deleteVendorMutation,
query: deleteThirdPartyMutation,
variables: func() map[string]any {
id := factory.NewVendor(owner).WithName(factory.SafeName("ToDelete")).Create()
return map[string]any{"input": map[string]any{"vendorId": id}}
id := factory.NewThirdParty(owner).WithName(factory.SafeName("ToDelete")).Create()
return map[string]any{"input": map[string]any{"thirdPartyId": id}}
},
shouldAllow: true,
},
{
name: "viewer cannot delete vendor",
name: "viewer cannot delete thirdParty",
role: "viewer",
client: viewer,
query: deleteVendorMutation,
query: deleteThirdPartyMutation,
variables: func() map[string]any {
id := factory.NewVendor(owner).WithName(factory.SafeName("ToDelete")).Create()
return map[string]any{"input": map[string]any{"vendorId": id}}
id := factory.NewThirdParty(owner).WithName(factory.SafeName("ToDelete")).Create()
return map[string]any{"input": map[string]any{"thirdPartyId": id}}
},
shouldAllow: false,
},
{
name: "owner can list vendors",
name: "owner can list third parties",
role: "owner",
client: owner,
query: listVendorsQuery,
query: listThirdPartiesQuery,
variables: func() map[string]any {
return map[string]any{"id": owner.GetOrganizationID().String()}
},
shouldAllow: true,
},
{
name: "admin can list vendors",
name: "admin can list third parties",
role: "admin",
client: admin,
query: listVendorsQuery,
query: listThirdPartiesQuery,
variables: func() map[string]any {
return map[string]any{"id": owner.GetOrganizationID().String()}
},
shouldAllow: true,
},
{
name: "viewer can list vendors",
name: "viewer can list third parties",
role: "viewer",
client: viewer,
query: listVendorsQuery,
query: listThirdPartiesQuery,
variables: func() map[string]any {
return map[string]any{"id": owner.GetOrganizationID().String()}
},

View File

@@ -23,15 +23,15 @@ import (
"go.probo.inc/probo/e2e/internal/testutil"
)
func TestVendorComplianceReport_Upload(t *testing.T) {
func TestThirdPartyComplianceReport_Upload(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
vendorID := factory.NewVendor(owner).WithName("Compliance Report Upload Vendor").Create()
thirdPartyID := factory.NewThirdParty(owner).WithName("Compliance Report Upload ThirdParty").Create()
const query = `
mutation UploadVendorComplianceReport($input: UploadVendorComplianceReportInput!) {
uploadVendorComplianceReport(input: $input) {
vendorComplianceReportEdge {
mutation UploadThirdPartyComplianceReport($input: UploadThirdPartyComplianceReportInput!) {
uploadThirdPartyComplianceReport(input: $input) {
thirdPartyComplianceReportEdge {
node {
id
reportName
@@ -46,27 +46,27 @@ func TestVendorComplianceReport_Upload(t *testing.T) {
pdfContent := []byte("%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF")
var result struct {
UploadVendorComplianceReport struct {
VendorComplianceReportEdge struct {
UploadThirdPartyComplianceReport struct {
ThirdPartyComplianceReportEdge struct {
Node struct {
ID string `json:"id"`
ReportName string `json:"reportName"`
ReportDate string `json:"reportDate"`
ValidUntil *string `json:"validUntil"`
} `json:"node"`
} `json:"vendorComplianceReportEdge"`
} `json:"uploadVendorComplianceReport"`
} `json:"thirdPartyComplianceReportEdge"`
} `json:"uploadThirdPartyComplianceReport"`
}
err := owner.ExecuteWithFile(
query,
map[string]any{
"input": map[string]any{
"vendorId": vendorID,
"reportName": "SOC 2 Type II",
"reportDate": "2024-01-01T00:00:00Z",
"validUntil": "2025-01-01T00:00:00Z",
"file": nil,
"thirdPartyId": thirdPartyID,
"reportName": "SOC 2 Type II",
"reportDate": "2024-01-01T00:00:00Z",
"validUntil": "2025-01-01T00:00:00Z",
"file": nil,
},
}, "input.file", testutil.UploadFile{
Filename: "soc2-report.pdf",
@@ -77,23 +77,23 @@ func TestVendorComplianceReport_Upload(t *testing.T) {
)
require.NoError(t, err)
node := result.UploadVendorComplianceReport.VendorComplianceReportEdge.Node
node := result.UploadThirdPartyComplianceReport.ThirdPartyComplianceReportEdge.Node
assert.NotEmpty(t, node.ID)
assert.Equal(t, "SOC 2 Type II", node.ReportName)
assert.NotEmpty(t, node.ReportDate)
assert.NotNil(t, node.ValidUntil)
}
func TestVendorComplianceReport_List(t *testing.T) {
func TestThirdPartyComplianceReport_List(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
vendorID := factory.NewVendor(owner).WithName("Compliance Report List Vendor").Create()
thirdPartyID := factory.NewThirdParty(owner).WithName("Compliance Report List ThirdParty").Create()
pdfContent := []byte("%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF")
uploadQuery := `
mutation UploadVendorComplianceReport($input: UploadVendorComplianceReportInput!) {
uploadVendorComplianceReport(input: $input) {
vendorComplianceReportEdge {
mutation UploadThirdPartyComplianceReport($input: UploadThirdPartyComplianceReportInput!) {
uploadThirdPartyComplianceReport(input: $input) {
thirdPartyComplianceReportEdge {
node { id }
}
}
@@ -101,23 +101,23 @@ func TestVendorComplianceReport_List(t *testing.T) {
`
var uploadResult struct {
UploadVendorComplianceReport struct {
VendorComplianceReportEdge struct {
UploadThirdPartyComplianceReport struct {
ThirdPartyComplianceReportEdge struct {
Node struct {
ID string `json:"id"`
} `json:"node"`
} `json:"vendorComplianceReportEdge"`
} `json:"uploadVendorComplianceReport"`
} `json:"thirdPartyComplianceReportEdge"`
} `json:"uploadThirdPartyComplianceReport"`
}
err := owner.ExecuteWithFile(
uploadQuery,
map[string]any{
"input": map[string]any{
"vendorId": vendorID,
"reportName": "ISO 27001",
"reportDate": "2024-06-01T00:00:00Z",
"file": nil,
"thirdPartyId": thirdPartyID,
"reportName": "ISO 27001",
"reportDate": "2024-06-01T00:00:00Z",
"file": nil,
},
}, "input.file", testutil.UploadFile{
Filename: "iso27001.pdf",
@@ -128,13 +128,13 @@ func TestVendorComplianceReport_List(t *testing.T) {
)
require.NoError(t, err)
reportID := uploadResult.UploadVendorComplianceReport.VendorComplianceReportEdge.Node.ID
reportID := uploadResult.UploadThirdPartyComplianceReport.ThirdPartyComplianceReportEdge.Node.ID
require.NotEmpty(t, reportID)
const listQuery = `
query($id: ID!) {
node(id: $id) {
... on Vendor {
... on ThirdParty {
id
complianceReports(first: 10) {
edges {
@@ -165,7 +165,7 @@ func TestVendorComplianceReport_List(t *testing.T) {
} `json:"node"`
}
err = owner.Execute(listQuery, map[string]any{"id": vendorID}, &listResult)
err = owner.Execute(listQuery, map[string]any{"id": thirdPartyID}, &listResult)
require.NoError(t, err)
require.Len(t, listResult.Node.ComplianceReports.Edges, 1)
@@ -173,17 +173,17 @@ func TestVendorComplianceReport_List(t *testing.T) {
assert.Equal(t, "ISO 27001", listResult.Node.ComplianceReports.Edges[0].Node.ReportName)
}
func TestVendorComplianceReport_Delete(t *testing.T) {
func TestThirdPartyComplianceReport_Delete(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
vendorID := factory.NewVendor(owner).WithName("Compliance Report Delete Vendor").Create()
thirdPartyID := factory.NewThirdParty(owner).WithName("Compliance Report Delete ThirdParty").Create()
pdfContent := []byte("%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF")
uploadQuery := `
mutation UploadVendorComplianceReport($input: UploadVendorComplianceReportInput!) {
uploadVendorComplianceReport(input: $input) {
vendorComplianceReportEdge {
mutation UploadThirdPartyComplianceReport($input: UploadThirdPartyComplianceReportInput!) {
uploadThirdPartyComplianceReport(input: $input) {
thirdPartyComplianceReportEdge {
node { id }
}
}
@@ -191,21 +191,21 @@ func TestVendorComplianceReport_Delete(t *testing.T) {
`
var uploadResult struct {
UploadVendorComplianceReport struct {
VendorComplianceReportEdge struct {
UploadThirdPartyComplianceReport struct {
ThirdPartyComplianceReportEdge struct {
Node struct {
ID string `json:"id"`
} `json:"node"`
} `json:"vendorComplianceReportEdge"`
} `json:"uploadVendorComplianceReport"`
} `json:"thirdPartyComplianceReportEdge"`
} `json:"uploadThirdPartyComplianceReport"`
}
err := owner.ExecuteWithFile(uploadQuery, map[string]any{
"input": map[string]any{
"vendorId": vendorID,
"reportName": "PCI DSS",
"reportDate": "2024-03-01T00:00:00Z",
"file": nil,
"thirdPartyId": thirdPartyID,
"reportName": "PCI DSS",
"reportDate": "2024-03-01T00:00:00Z",
"file": nil,
},
}, "input.file", testutil.UploadFile{
Filename: "pci-dss.pdf",
@@ -214,21 +214,21 @@ func TestVendorComplianceReport_Delete(t *testing.T) {
}, &uploadResult)
require.NoError(t, err)
reportID := uploadResult.UploadVendorComplianceReport.VendorComplianceReportEdge.Node.ID
reportID := uploadResult.UploadThirdPartyComplianceReport.ThirdPartyComplianceReportEdge.Node.ID
require.NotEmpty(t, reportID)
const deleteQuery = `
mutation DeleteVendorComplianceReport($input: DeleteVendorComplianceReportInput!) {
deleteVendorComplianceReport(input: $input) {
deletedVendorComplianceReportId
mutation DeleteThirdPartyComplianceReport($input: DeleteThirdPartyComplianceReportInput!) {
deleteThirdPartyComplianceReport(input: $input) {
deletedThirdPartyComplianceReportId
}
}
`
var deleteResult struct {
DeleteVendorComplianceReport struct {
DeletedVendorComplianceReportID string `json:"deletedVendorComplianceReportId"`
} `json:"deleteVendorComplianceReport"`
DeleteThirdPartyComplianceReport struct {
DeletedThirdPartyComplianceReportID string `json:"deletedThirdPartyComplianceReportId"`
} `json:"deleteThirdPartyComplianceReport"`
}
err = owner.Execute(
@@ -241,5 +241,5 @@ func TestVendorComplianceReport_Delete(t *testing.T) {
&deleteResult,
)
require.NoError(t, err)
assert.Equal(t, reportID, deleteResult.DeleteVendorComplianceReport.DeletedVendorComplianceReportID)
assert.Equal(t, reportID, deleteResult.DeleteThirdPartyComplianceReport.DeletedThirdPartyComplianceReportID)
}

View File

@@ -25,17 +25,17 @@ import (
"go.probo.inc/probo/e2e/internal/testutil"
)
func TestVendorContact_Create(t *testing.T) {
func TestThirdPartyContact_Create(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
// Create a vendor first
vendorID := factory.NewVendor(owner).WithName("Contact Test Vendor").Create()
// Create a thirdParty first
thirdPartyID := factory.NewThirdParty(owner).WithName("Contact Test ThirdParty").Create()
query := `
mutation CreateVendorContact($input: CreateVendorContactInput!) {
createVendorContact(input: $input) {
vendorContactEdge {
mutation CreateThirdPartyContact($input: CreateThirdPartyContactInput!) {
createThirdPartyContact(input: $input) {
thirdPartyContactEdge {
node {
id
fullName
@@ -49,8 +49,8 @@ func TestVendorContact_Create(t *testing.T) {
`
var result struct {
CreateVendorContact struct {
VendorContactEdge struct {
CreateThirdPartyContact struct {
ThirdPartyContactEdge struct {
Node struct {
ID string `json:"id"`
FullName string `json:"fullName"`
@@ -58,39 +58,39 @@ func TestVendorContact_Create(t *testing.T) {
Phone string `json:"phone"`
Role string `json:"role"`
} `json:"node"`
} `json:"vendorContactEdge"`
} `json:"createVendorContact"`
} `json:"thirdPartyContactEdge"`
} `json:"createThirdPartyContact"`
}
err := owner.Execute(query, map[string]any{
"input": map[string]any{
"vendorId": vendorID,
"fullName": "John Doe",
"email": fmt.Sprintf("john.doe.%d@vendor.com", time.Now().UnixNano()),
"phone": "+1-555-123-4567",
"role": "Account Manager",
"thirdPartyId": thirdPartyID,
"fullName": "John Doe",
"email": fmt.Sprintf("john.doe.%d@thirdParty.com", time.Now().UnixNano()),
"phone": "+1-555-123-4567",
"role": "Account Manager",
},
}, &result)
require.NoError(t, err)
contact := result.CreateVendorContact.VendorContactEdge.Node
contact := result.CreateThirdPartyContact.ThirdPartyContactEdge.Node
assert.NotEmpty(t, contact.ID)
assert.Equal(t, "John Doe", contact.FullName)
assert.Equal(t, "+1-555-123-4567", contact.Phone)
assert.Equal(t, "Account Manager", contact.Role)
}
func TestVendorContact_Update(t *testing.T) {
func TestThirdPartyContact_Update(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
// Create a vendor and contact
vendorID := factory.NewVendor(owner).WithName("Update Contact Vendor").Create()
// Create a thirdParty and contact
thirdPartyID := factory.NewThirdParty(owner).WithName("Update Contact ThirdParty").Create()
createQuery := `
mutation CreateVendorContact($input: CreateVendorContactInput!) {
createVendorContact(input: $input) {
vendorContactEdge {
mutation CreateThirdPartyContact($input: CreateThirdPartyContactInput!) {
createThirdPartyContact(input: $input) {
thirdPartyContactEdge {
node {
id
}
@@ -100,30 +100,30 @@ func TestVendorContact_Update(t *testing.T) {
`
var createResult struct {
CreateVendorContact struct {
VendorContactEdge struct {
CreateThirdPartyContact struct {
ThirdPartyContactEdge struct {
Node struct {
ID string `json:"id"`
} `json:"node"`
} `json:"vendorContactEdge"`
} `json:"createVendorContact"`
} `json:"thirdPartyContactEdge"`
} `json:"createThirdPartyContact"`
}
err := owner.Execute(createQuery, map[string]any{
"input": map[string]any{
"vendorId": vendorID,
"fullName": "Initial Name",
"email": fmt.Sprintf("initial.%d@vendor.com", time.Now().UnixNano()),
"thirdPartyId": thirdPartyID,
"fullName": "Initial Name",
"email": fmt.Sprintf("initial.%d@thirdParty.com", time.Now().UnixNano()),
},
}, &createResult)
require.NoError(t, err)
contactID := createResult.CreateVendorContact.VendorContactEdge.Node.ID
contactID := createResult.CreateThirdPartyContact.ThirdPartyContactEdge.Node.ID
query := `
mutation UpdateVendorContact($input: UpdateVendorContactInput!) {
updateVendorContact(input: $input) {
vendorContact {
mutation UpdateThirdPartyContact($input: UpdateThirdPartyContactInput!) {
updateThirdPartyContact(input: $input) {
thirdPartyContact {
id
fullName
phone
@@ -134,14 +134,14 @@ func TestVendorContact_Update(t *testing.T) {
`
var result struct {
UpdateVendorContact struct {
VendorContact struct {
UpdateThirdPartyContact struct {
ThirdPartyContact struct {
ID string `json:"id"`
FullName string `json:"fullName"`
Phone string `json:"phone"`
Role string `json:"role"`
} `json:"vendorContact"`
} `json:"updateVendorContact"`
} `json:"thirdPartyContact"`
} `json:"updateThirdPartyContact"`
}
err = owner.Execute(query, map[string]any{
@@ -154,23 +154,23 @@ func TestVendorContact_Update(t *testing.T) {
}, &result)
require.NoError(t, err)
assert.Equal(t, contactID, result.UpdateVendorContact.VendorContact.ID)
assert.Equal(t, "Updated Name", result.UpdateVendorContact.VendorContact.FullName)
assert.Equal(t, "+1-555-999-8888", result.UpdateVendorContact.VendorContact.Phone)
assert.Equal(t, "Senior Account Manager", result.UpdateVendorContact.VendorContact.Role)
assert.Equal(t, contactID, result.UpdateThirdPartyContact.ThirdPartyContact.ID)
assert.Equal(t, "Updated Name", result.UpdateThirdPartyContact.ThirdPartyContact.FullName)
assert.Equal(t, "+1-555-999-8888", result.UpdateThirdPartyContact.ThirdPartyContact.Phone)
assert.Equal(t, "Senior Account Manager", result.UpdateThirdPartyContact.ThirdPartyContact.Role)
}
func TestVendorContact_Delete(t *testing.T) {
func TestThirdPartyContact_Delete(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
vendorID := factory.NewVendor(owner).WithName("Delete Contact Vendor").Create()
thirdPartyID := factory.NewThirdParty(owner).WithName("Delete Contact ThirdParty").Create()
// Create a contact to delete
createQuery := `
mutation CreateVendorContact($input: CreateVendorContactInput!) {
createVendorContact(input: $input) {
vendorContactEdge {
mutation CreateThirdPartyContact($input: CreateThirdPartyContactInput!) {
createThirdPartyContact(input: $input) {
thirdPartyContactEdge {
node {
id
}
@@ -180,61 +180,61 @@ func TestVendorContact_Delete(t *testing.T) {
`
var createResult struct {
CreateVendorContact struct {
VendorContactEdge struct {
CreateThirdPartyContact struct {
ThirdPartyContactEdge struct {
Node struct {
ID string `json:"id"`
} `json:"node"`
} `json:"vendorContactEdge"`
} `json:"createVendorContact"`
} `json:"thirdPartyContactEdge"`
} `json:"createThirdPartyContact"`
}
err := owner.Execute(createQuery, map[string]any{
"input": map[string]any{
"vendorId": vendorID,
"fullName": fmt.Sprintf("Contact to Delete %d", time.Now().UnixNano()),
"email": fmt.Sprintf("delete.%d@vendor.com", time.Now().UnixNano()),
"thirdPartyId": thirdPartyID,
"fullName": fmt.Sprintf("Contact to Delete %d", time.Now().UnixNano()),
"email": fmt.Sprintf("delete.%d@thirdParty.com", time.Now().UnixNano()),
},
}, &createResult)
require.NoError(t, err)
contactID := createResult.CreateVendorContact.VendorContactEdge.Node.ID
contactID := createResult.CreateThirdPartyContact.ThirdPartyContactEdge.Node.ID
deleteQuery := `
mutation DeleteVendorContact($input: DeleteVendorContactInput!) {
deleteVendorContact(input: $input) {
deletedVendorContactId
mutation DeleteThirdPartyContact($input: DeleteThirdPartyContactInput!) {
deleteThirdPartyContact(input: $input) {
deletedThirdPartyContactId
}
}
`
var result struct {
DeleteVendorContact struct {
DeletedVendorContactID string `json:"deletedVendorContactId"`
} `json:"deleteVendorContact"`
DeleteThirdPartyContact struct {
DeletedThirdPartyContactID string `json:"deletedThirdPartyContactId"`
} `json:"deleteThirdPartyContact"`
}
err = owner.Execute(deleteQuery, map[string]any{
"input": map[string]any{
"vendorContactId": contactID,
"thirdPartyContactId": contactID,
},
}, &result)
require.NoError(t, err)
assert.Equal(t, contactID, result.DeleteVendorContact.DeletedVendorContactID)
assert.Equal(t, contactID, result.DeleteThirdPartyContact.DeletedThirdPartyContactID)
}
func TestVendorContact_List(t *testing.T) {
func TestThirdPartyContact_List(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
vendorID := factory.NewVendor(owner).WithName("List Contacts Vendor").Create()
thirdPartyID := factory.NewThirdParty(owner).WithName("List Contacts ThirdParty").Create()
// Create multiple contacts
for i := range 3 {
query := `
mutation CreateVendorContact($input: CreateVendorContactInput!) {
createVendorContact(input: $input) {
vendorContactEdge {
mutation CreateThirdPartyContact($input: CreateThirdPartyContactInput!) {
createThirdPartyContact(input: $input) {
thirdPartyContactEdge {
node {
id
}
@@ -245,18 +245,18 @@ func TestVendorContact_List(t *testing.T) {
_, err := owner.Do(query, map[string]any{
"input": map[string]any{
"vendorId": vendorID,
"fullName": fmt.Sprintf("Contact %d", i),
"email": fmt.Sprintf("contact.%d.%d@vendor.com", i, time.Now().UnixNano()),
"thirdPartyId": thirdPartyID,
"fullName": fmt.Sprintf("Contact %d", i),
"email": fmt.Sprintf("contact.%d.%d@thirdParty.com", i, time.Now().UnixNano()),
},
})
require.NoError(t, err)
}
query := `
query GetVendorContacts($id: ID!) {
query GetThirdPartyContacts($id: ID!) {
node(id: $id) {
... on Vendor {
... on ThirdParty {
contacts(first: 10) {
edges {
node {
@@ -286,7 +286,7 @@ func TestVendorContact_List(t *testing.T) {
}
err := owner.Execute(query, map[string]any{
"id": vendorID,
"id": thirdPartyID,
}, &result)
require.NoError(t, err)
assert.GreaterOrEqual(t, len(result.Node.Contacts.Edges), 3)

View File

@@ -23,7 +23,7 @@ import (
"go.probo.inc/probo/e2e/internal/testutil"
)
func TestVendor_PublishVendorList(t *testing.T) {
func TestThirdParty_PublishThirdPartyList(t *testing.T) {
t.Parallel()
t.Run(
@@ -32,11 +32,11 @@ func TestVendor_PublishVendorList(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
factory.CreateVendor(owner, factory.Attrs{"name": "Test Vendor"})
factory.CreateThirdParty(owner, factory.Attrs{"name": "Test ThirdParty"})
const query = `
mutation($input: PublishVendorListInput!) {
publishVendorList(input: $input) {
mutation($input: PublishThirdPartyListInput!) {
publishThirdPartyList(input: $input) {
documentEdge {
node {
id
@@ -60,7 +60,7 @@ func TestVendor_PublishVendorList(t *testing.T) {
`
var result struct {
PublishVendorList struct {
PublishThirdPartyList struct {
DocumentEdge struct {
Node struct {
ID string `json:"id"`
@@ -79,7 +79,7 @@ func TestVendor_PublishVendorList(t *testing.T) {
Content string `json:"content"`
} `json:"node"`
} `json:"documentVersionEdge"`
} `json:"publishVendorList"`
} `json:"publishThirdPartyList"`
}
err := owner.Execute(
@@ -94,12 +94,12 @@ func TestVendor_PublishVendorList(t *testing.T) {
)
require.NoError(t, err)
doc := result.PublishVendorList.DocumentEdge.Node
doc := result.PublishThirdPartyList.DocumentEdge.Node
assert.NotEmpty(t, doc.ID)
assert.Equal(t, "GENERATED", doc.WriteMode)
assert.Equal(t, "ACTIVE", doc.Status)
ver := result.PublishVendorList.DocumentVersionEdge.Node
ver := result.PublishThirdPartyList.DocumentVersionEdge.Node
assert.NotEmpty(t, ver.ID)
assert.Equal(t, "REGISTER", ver.DocumentType)
assert.Equal(t, "PUBLISHED", ver.Status)
@@ -117,8 +117,8 @@ func TestVendor_PublishVendorList(t *testing.T) {
owner := testutil.NewClient(t, testutil.RoleOwner)
const query = `
mutation($input: PublishVendorListInput!) {
publishVendorList(input: $input) {
mutation($input: PublishThirdPartyListInput!) {
publishThirdPartyList(input: $input) {
documentEdge {
node { id writeMode }
}
@@ -130,7 +130,7 @@ func TestVendor_PublishVendorList(t *testing.T) {
`
var result struct {
PublishVendorList struct {
PublishThirdPartyList struct {
DocumentEdge struct {
Node struct {
ID string `json:"id"`
@@ -144,7 +144,7 @@ func TestVendor_PublishVendorList(t *testing.T) {
Major int `json:"major"`
} `json:"node"`
} `json:"documentVersionEdge"`
} `json:"publishVendorList"`
} `json:"publishThirdPartyList"`
}
err := owner.Execute(
@@ -160,11 +160,11 @@ func TestVendor_PublishVendorList(t *testing.T) {
)
require.NoError(t, err)
doc := result.PublishVendorList.DocumentEdge.Node
doc := result.PublishThirdPartyList.DocumentEdge.Node
assert.NotEmpty(t, doc.ID)
assert.Equal(t, "GENERATED", doc.WriteMode)
ver := result.PublishVendorList.DocumentVersionEdge.Node
ver := result.PublishThirdPartyList.DocumentVersionEdge.Node
assert.NotEmpty(t, ver.ID)
assert.Equal(t, "PENDING_APPROVAL", ver.Status)
},
@@ -176,11 +176,11 @@ func TestVendor_PublishVendorList(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
factory.CreateVendor(owner, factory.Attrs{"name": "Reuse Vendor"})
factory.CreateThirdParty(owner, factory.Attrs{"name": "Reuse ThirdParty"})
const query = `
mutation($input: PublishVendorListInput!) {
publishVendorList(input: $input) {
mutation($input: PublishThirdPartyListInput!) {
publishThirdPartyList(input: $input) {
documentEdge { node { id } }
documentVersionEdge { node { id major } }
}
@@ -188,7 +188,7 @@ func TestVendor_PublishVendorList(t *testing.T) {
`
var r1, r2 struct {
PublishVendorList struct {
PublishThirdPartyList struct {
DocumentEdge struct {
Node struct {
ID string `json:"id"`
@@ -200,7 +200,7 @@ func TestVendor_PublishVendorList(t *testing.T) {
Major int `json:"major"`
} `json:"node"`
} `json:"documentVersionEdge"`
} `json:"publishVendorList"`
} `json:"publishThirdPartyList"`
}
input := map[string]any{
@@ -217,26 +217,26 @@ func TestVendor_PublishVendorList(t *testing.T) {
require.NoError(t, err)
assert.Equal(t,
r1.PublishVendorList.DocumentEdge.Node.ID,
r2.PublishVendorList.DocumentEdge.Node.ID,
r1.PublishThirdPartyList.DocumentEdge.Node.ID,
r2.PublishThirdPartyList.DocumentEdge.Node.ID,
"should reuse same document",
)
assert.Equal(t, 1, r1.PublishVendorList.DocumentVersionEdge.Node.Major)
assert.Equal(t, 2, r2.PublishVendorList.DocumentVersionEdge.Node.Major)
assert.Equal(t, 1, r1.PublishThirdPartyList.DocumentVersionEdge.Node.Major)
assert.Equal(t, 2, r2.PublishThirdPartyList.DocumentVersionEdge.Node.Major)
},
)
t.Run(
"organization vendorsDocument links to published document",
"organization thirdPartiesDocument links to published document",
func(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
factory.CreateVendor(owner, factory.Attrs{"name": "Linked Vendor"})
factory.CreateThirdParty(owner, factory.Attrs{"name": "Linked ThirdParty"})
const publishQuery = `
mutation($input: PublishVendorListInput!) {
publishVendorList(input: $input) {
mutation($input: PublishThirdPartyListInput!) {
publishThirdPartyList(input: $input) {
documentEdge { node { id } }
documentVersionEdge { node { id } }
}
@@ -244,7 +244,7 @@ func TestVendor_PublishVendorList(t *testing.T) {
`
var publishResult struct {
PublishVendorList struct {
PublishThirdPartyList struct {
DocumentEdge struct {
Node struct {
ID string `json:"id"`
@@ -255,7 +255,7 @@ func TestVendor_PublishVendorList(t *testing.T) {
ID string `json:"id"`
} `json:"node"`
} `json:"documentVersionEdge"`
} `json:"publishVendorList"`
} `json:"publishThirdPartyList"`
}
err := owner.Execute(
@@ -270,14 +270,14 @@ func TestVendor_PublishVendorList(t *testing.T) {
)
require.NoError(t, err)
docID := publishResult.PublishVendorList.DocumentEdge.Node.ID
docID := publishResult.PublishThirdPartyList.DocumentEdge.Node.ID
const orgQuery = `
query($id: ID!) {
node(id: $id) {
... on Organization {
id
vendorsDocument { id }
thirdPartiesDocument { id }
}
}
}
@@ -285,10 +285,10 @@ func TestVendor_PublishVendorList(t *testing.T) {
var orgResult struct {
Node struct {
ID string `json:"id"`
VendorsDocument *struct {
ID string `json:"id"`
ThirdPartiesDocument *struct {
ID string `json:"id"`
} `json:"vendorsDocument"`
} `json:"thirdPartiesDocument"`
} `json:"node"`
}
@@ -298,23 +298,23 @@ func TestVendor_PublishVendorList(t *testing.T) {
&orgResult,
)
require.NoError(t, err)
require.NotNil(t, orgResult.Node.VendorsDocument)
assert.Equal(t, docID, orgResult.Node.VendorsDocument.ID)
require.NotNil(t, orgResult.Node.ThirdPartiesDocument)
assert.Equal(t, docID, orgResult.Node.ThirdPartiesDocument.ID)
},
)
}
func TestVendor_PublishVendorList_RBAC(t *testing.T) {
func TestThirdParty_PublishThirdPartyList_RBAC(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
factory.CreateVendor(owner, factory.Attrs{"name": "RBAC Vendor"})
factory.CreateThirdParty(owner, factory.Attrs{"name": "RBAC ThirdParty"})
const query = `
mutation($input: PublishVendorListInput!) {
publishVendorList(input: $input) {
mutation($input: PublishThirdPartyListInput!) {
publishThirdPartyList(input: $input) {
documentEdge { node { id } }
documentVersionEdge { node { id } }
}
@@ -322,7 +322,7 @@ func TestVendor_PublishVendorList_RBAC(t *testing.T) {
`
t.Run(
"viewer cannot publish vendor list",
"viewer cannot publish thirdParty list",
func(t *testing.T) {
t.Parallel()

View File

@@ -22,15 +22,15 @@ import (
"go.probo.inc/probo/e2e/internal/testutil"
)
func TestVendorService_Create(t *testing.T) {
func TestThirdPartyService_Create(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
// Create a vendor first
createVendorMutation := `
mutation CreateVendor($input: CreateVendorInput!) {
createVendor(input: $input) {
vendorEdge {
// Create a thirdParty first
createThirdPartyMutation := `
mutation CreateThirdParty($input: CreateThirdPartyInput!) {
createThirdParty(input: $input) {
thirdPartyEdge {
node {
id
}
@@ -39,129 +39,129 @@ func TestVendorService_Create(t *testing.T) {
}
`
var createVendorResult struct {
CreateVendor struct {
VendorEdge struct {
var createThirdPartyResult struct {
CreateThirdParty struct {
ThirdPartyEdge struct {
Node struct {
ID string `json:"id"`
} `json:"node"`
} `json:"vendorEdge"`
} `json:"createVendor"`
} `json:"thirdPartyEdge"`
} `json:"createThirdParty"`
}
err := owner.Execute(createVendorMutation, map[string]any{
err := owner.Execute(createThirdPartyMutation, map[string]any{
"input": map[string]any{
"organizationId": owner.GetOrganizationID().String(),
"name": "AWS",
"category": "CLOUD_PROVIDER",
},
}, &createVendorResult)
}, &createThirdPartyResult)
require.NoError(t, err)
vendorID := createVendorResult.CreateVendor.VendorEdge.Node.ID
thirdPartyID := createThirdPartyResult.CreateThirdParty.ThirdPartyEdge.Node.ID
tests := []struct {
name string
role testutil.TestRole
variables func() map[string]any
check func(t *testing.T, err error, m *struct {
CreateVendorService struct {
VendorServiceEdge struct {
CreateThirdPartyService struct {
ThirdPartyServiceEdge struct {
Node struct {
ID string `json:"id"`
Name string `json:"name"`
Description *string `json:"description"`
} `json:"node"`
} `json:"vendorServiceEdge"`
} `json:"createVendorService"`
} `json:"thirdPartyServiceEdge"`
} `json:"createThirdPartyService"`
})
}{
{
name: "Owner can create vendor service",
name: "Owner can create thirdParty service",
role: testutil.RoleOwner,
variables: func() map[string]any {
return map[string]any{
"input": map[string]any{
"vendorId": vendorID,
"name": "Amazon S3",
"description": "Simple Storage Service",
"thirdPartyId": thirdPartyID,
"name": "Amazon S3",
"description": "Simple Storage Service",
},
}
},
check: func(t *testing.T, err error, m *struct {
CreateVendorService struct {
VendorServiceEdge struct {
CreateThirdPartyService struct {
ThirdPartyServiceEdge struct {
Node struct {
ID string `json:"id"`
Name string `json:"name"`
Description *string `json:"description"`
} `json:"node"`
} `json:"vendorServiceEdge"`
} `json:"createVendorService"`
} `json:"thirdPartyServiceEdge"`
} `json:"createThirdPartyService"`
}) {
require.NoError(t, err)
assert.NotEmpty(t, m.CreateVendorService.VendorServiceEdge.Node.ID)
assert.Equal(t, "Amazon S3", m.CreateVendorService.VendorServiceEdge.Node.Name)
assert.Equal(t, "Simple Storage Service", *m.CreateVendorService.VendorServiceEdge.Node.Description)
assert.NotEmpty(t, m.CreateThirdPartyService.ThirdPartyServiceEdge.Node.ID)
assert.Equal(t, "Amazon S3", m.CreateThirdPartyService.ThirdPartyServiceEdge.Node.Name)
assert.Equal(t, "Simple Storage Service", *m.CreateThirdPartyService.ThirdPartyServiceEdge.Node.Description)
},
},
{
name: "Admin can create vendor service",
name: "Admin can create thirdParty service",
role: testutil.RoleAdmin,
variables: func() map[string]any {
return map[string]any{
"input": map[string]any{
"vendorId": vendorID,
"name": "Amazon EC2",
"description": "Elastic Compute Cloud",
"thirdPartyId": thirdPartyID,
"name": "Amazon EC2",
"description": "Elastic Compute Cloud",
},
}
},
check: func(t *testing.T, err error, m *struct {
CreateVendorService struct {
VendorServiceEdge struct {
CreateThirdPartyService struct {
ThirdPartyServiceEdge struct {
Node struct {
ID string `json:"id"`
Name string `json:"name"`
Description *string `json:"description"`
} `json:"node"`
} `json:"vendorServiceEdge"`
} `json:"createVendorService"`
} `json:"thirdPartyServiceEdge"`
} `json:"createThirdPartyService"`
}) {
require.NoError(t, err)
},
},
{
name: "Viewer cannot create vendor service",
name: "Viewer cannot create thirdParty service",
role: testutil.RoleViewer,
variables: func() map[string]any {
return map[string]any{
"input": map[string]any{
"vendorId": vendorID,
"name": "Should Fail",
"thirdPartyId": thirdPartyID,
"name": "Should Fail",
},
}
},
check: func(t *testing.T, err error, m *struct {
CreateVendorService struct {
VendorServiceEdge struct {
CreateThirdPartyService struct {
ThirdPartyServiceEdge struct {
Node struct {
ID string `json:"id"`
Name string `json:"name"`
Description *string `json:"description"`
} `json:"node"`
} `json:"vendorServiceEdge"`
} `json:"createVendorService"`
} `json:"thirdPartyServiceEdge"`
} `json:"createThirdPartyService"`
}) {
require.Error(t, err, "Viewer should not be able to create vendor service")
require.Error(t, err, "Viewer should not be able to create thirdParty service")
},
},
}
createVendorServiceMutation := `
mutation CreateVendorService($input: CreateVendorServiceInput!) {
createVendorService(input: $input) {
vendorServiceEdge {
createThirdPartyServiceMutation := `
mutation CreateThirdPartyService($input: CreateThirdPartyServiceInput!) {
createThirdPartyService(input: $input) {
thirdPartyServiceEdge {
node {
id
name
@@ -182,32 +182,32 @@ func TestVendorService_Create(t *testing.T) {
}
var m struct {
CreateVendorService struct {
VendorServiceEdge struct {
CreateThirdPartyService struct {
ThirdPartyServiceEdge struct {
Node struct {
ID string `json:"id"`
Name string `json:"name"`
Description *string `json:"description"`
} `json:"node"`
} `json:"vendorServiceEdge"`
} `json:"createVendorService"`
} `json:"thirdPartyServiceEdge"`
} `json:"createThirdPartyService"`
}
err := client.Execute(createVendorServiceMutation, tt.variables(), &m)
err := client.Execute(createThirdPartyServiceMutation, tt.variables(), &m)
tt.check(t, err, &m)
})
}
}
func TestVendorService_Update(t *testing.T) {
func TestThirdPartyService_Update(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
// Create a vendor first
createVendorMutation := `
mutation CreateVendor($input: CreateVendorInput!) {
createVendor(input: $input) {
vendorEdge {
// Create a thirdParty first
createThirdPartyMutation := `
mutation CreateThirdParty($input: CreateThirdPartyInput!) {
createThirdParty(input: $input) {
thirdPartyEdge {
node {
id
}
@@ -216,32 +216,32 @@ func TestVendorService_Update(t *testing.T) {
}
`
var createVendorResult struct {
CreateVendor struct {
VendorEdge struct {
var createThirdPartyResult struct {
CreateThirdParty struct {
ThirdPartyEdge struct {
Node struct {
ID string `json:"id"`
} `json:"node"`
} `json:"vendorEdge"`
} `json:"createVendor"`
} `json:"thirdPartyEdge"`
} `json:"createThirdParty"`
}
err := owner.Execute(createVendorMutation, map[string]any{
err := owner.Execute(createThirdPartyMutation, map[string]any{
"input": map[string]any{
"organizationId": owner.GetOrganizationID().String(),
"name": "Google Cloud",
"category": "CLOUD_PROVIDER",
},
}, &createVendorResult)
}, &createThirdPartyResult)
require.NoError(t, err)
vendorID := createVendorResult.CreateVendor.VendorEdge.Node.ID
thirdPartyID := createThirdPartyResult.CreateThirdParty.ThirdPartyEdge.Node.ID
// Create a vendor service
// Create a thirdParty service
createServiceMutation := `
mutation CreateVendorService($input: CreateVendorServiceInput!) {
createVendorService(input: $input) {
vendorServiceEdge {
mutation CreateThirdPartyService($input: CreateThirdPartyServiceInput!) {
createThirdPartyService(input: $input) {
thirdPartyServiceEdge {
node {
id
}
@@ -251,42 +251,42 @@ func TestVendorService_Update(t *testing.T) {
`
var createServiceResult struct {
CreateVendorService struct {
VendorServiceEdge struct {
CreateThirdPartyService struct {
ThirdPartyServiceEdge struct {
Node struct {
ID string `json:"id"`
} `json:"node"`
} `json:"vendorServiceEdge"`
} `json:"createVendorService"`
} `json:"thirdPartyServiceEdge"`
} `json:"createThirdPartyService"`
}
err = owner.Execute(createServiceMutation, map[string]any{
"input": map[string]any{
"vendorId": vendorID,
"name": "Cloud Storage",
"description": "Initial description",
"thirdPartyId": thirdPartyID,
"name": "Cloud Storage",
"description": "Initial description",
},
}, &createServiceResult)
require.NoError(t, err)
serviceID := createServiceResult.CreateVendorService.VendorServiceEdge.Node.ID
serviceID := createServiceResult.CreateThirdPartyService.ThirdPartyServiceEdge.Node.ID
tests := []struct {
name string
role testutil.TestRole
variables func() map[string]any
check func(t *testing.T, err error, m *struct {
UpdateVendorService struct {
VendorService struct {
UpdateThirdPartyService struct {
ThirdPartyService struct {
ID string `json:"id"`
Name string `json:"name"`
Description *string `json:"description"`
} `json:"vendorService"`
} `json:"updateVendorService"`
} `json:"thirdPartyService"`
} `json:"updateThirdPartyService"`
})
}{
{
name: "Owner can update vendor service",
name: "Owner can update thirdParty service",
role: testutil.RoleOwner,
variables: func() map[string]any {
return map[string]any{
@@ -298,22 +298,22 @@ func TestVendorService_Update(t *testing.T) {
}
},
check: func(t *testing.T, err error, m *struct {
UpdateVendorService struct {
VendorService struct {
UpdateThirdPartyService struct {
ThirdPartyService struct {
ID string `json:"id"`
Name string `json:"name"`
Description *string `json:"description"`
} `json:"vendorService"`
} `json:"updateVendorService"`
} `json:"thirdPartyService"`
} `json:"updateThirdPartyService"`
}) {
require.NoError(t, err)
assert.Equal(t, serviceID, m.UpdateVendorService.VendorService.ID)
assert.Equal(t, "Updated Cloud Storage", m.UpdateVendorService.VendorService.Name)
assert.Equal(t, "Updated description", *m.UpdateVendorService.VendorService.Description)
assert.Equal(t, serviceID, m.UpdateThirdPartyService.ThirdPartyService.ID)
assert.Equal(t, "Updated Cloud Storage", m.UpdateThirdPartyService.ThirdPartyService.Name)
assert.Equal(t, "Updated description", *m.UpdateThirdPartyService.ThirdPartyService.Description)
},
},
{
name: "Admin can update vendor service",
name: "Admin can update thirdParty service",
role: testutil.RoleAdmin,
variables: func() map[string]any {
return map[string]any{
@@ -324,19 +324,19 @@ func TestVendorService_Update(t *testing.T) {
}
},
check: func(t *testing.T, err error, m *struct {
UpdateVendorService struct {
VendorService struct {
UpdateThirdPartyService struct {
ThirdPartyService struct {
ID string `json:"id"`
Name string `json:"name"`
Description *string `json:"description"`
} `json:"vendorService"`
} `json:"updateVendorService"`
} `json:"thirdPartyService"`
} `json:"updateThirdPartyService"`
}) {
require.NoError(t, err)
},
},
{
name: "Viewer cannot update vendor service",
name: "Viewer cannot update thirdParty service",
role: testutil.RoleViewer,
variables: func() map[string]any {
return map[string]any{
@@ -347,23 +347,23 @@ func TestVendorService_Update(t *testing.T) {
}
},
check: func(t *testing.T, err error, m *struct {
UpdateVendorService struct {
VendorService struct {
UpdateThirdPartyService struct {
ThirdPartyService struct {
ID string `json:"id"`
Name string `json:"name"`
Description *string `json:"description"`
} `json:"vendorService"`
} `json:"updateVendorService"`
} `json:"thirdPartyService"`
} `json:"updateThirdPartyService"`
}) {
require.Error(t, err, "Viewer should not be able to update vendor service")
require.Error(t, err, "Viewer should not be able to update thirdParty service")
},
},
}
updateVendorServiceMutation := `
mutation UpdateVendorService($input: UpdateVendorServiceInput!) {
updateVendorService(input: $input) {
vendorService {
updateThirdPartyServiceMutation := `
mutation UpdateThirdPartyService($input: UpdateThirdPartyServiceInput!) {
updateThirdPartyService(input: $input) {
thirdPartyService {
id
name
description
@@ -382,30 +382,30 @@ func TestVendorService_Update(t *testing.T) {
}
var m struct {
UpdateVendorService struct {
VendorService struct {
UpdateThirdPartyService struct {
ThirdPartyService struct {
ID string `json:"id"`
Name string `json:"name"`
Description *string `json:"description"`
} `json:"vendorService"`
} `json:"updateVendorService"`
} `json:"thirdPartyService"`
} `json:"updateThirdPartyService"`
}
err := client.Execute(updateVendorServiceMutation, tt.variables(), &m)
err := client.Execute(updateThirdPartyServiceMutation, tt.variables(), &m)
tt.check(t, err, &m)
})
}
}
func TestVendorService_Delete(t *testing.T) {
func TestThirdPartyService_Delete(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
// Create a vendor first
createVendorMutation := `
mutation CreateVendor($input: CreateVendorInput!) {
createVendor(input: $input) {
vendorEdge {
// Create a thirdParty first
createThirdPartyMutation := `
mutation CreateThirdParty($input: CreateThirdPartyInput!) {
createThirdParty(input: $input) {
thirdPartyEdge {
node {
id
}
@@ -414,32 +414,32 @@ func TestVendorService_Delete(t *testing.T) {
}
`
var createVendorResult struct {
CreateVendor struct {
VendorEdge struct {
var createThirdPartyResult struct {
CreateThirdParty struct {
ThirdPartyEdge struct {
Node struct {
ID string `json:"id"`
} `json:"node"`
} `json:"vendorEdge"`
} `json:"createVendor"`
} `json:"thirdPartyEdge"`
} `json:"createThirdParty"`
}
err := owner.Execute(createVendorMutation, map[string]any{
err := owner.Execute(createThirdPartyMutation, map[string]any{
"input": map[string]any{
"organizationId": owner.GetOrganizationID().String(),
"name": "Azure",
"category": "CLOUD_PROVIDER",
},
}, &createVendorResult)
}, &createThirdPartyResult)
require.NoError(t, err)
vendorID := createVendorResult.CreateVendor.VendorEdge.Node.ID
thirdPartyID := createThirdPartyResult.CreateThirdParty.ThirdPartyEdge.Node.ID
createService := func() string {
createServiceMutation := `
mutation CreateVendorService($input: CreateVendorServiceInput!) {
createVendorService(input: $input) {
vendorServiceEdge {
mutation CreateThirdPartyService($input: CreateThirdPartyServiceInput!) {
createThirdPartyService(input: $input) {
thirdPartyServiceEdge {
node {
id
}
@@ -449,24 +449,24 @@ func TestVendorService_Delete(t *testing.T) {
`
var m struct {
CreateVendorService struct {
VendorServiceEdge struct {
CreateThirdPartyService struct {
ThirdPartyServiceEdge struct {
Node struct {
ID string `json:"id"`
} `json:"node"`
} `json:"vendorServiceEdge"`
} `json:"createVendorService"`
} `json:"thirdPartyServiceEdge"`
} `json:"createThirdPartyService"`
}
err := owner.Execute(createServiceMutation, map[string]any{
"input": map[string]any{
"vendorId": vendorID,
"name": "Service to delete",
"thirdPartyId": thirdPartyID,
"name": "Service to delete",
},
}, &m)
require.NoError(t, err)
return m.CreateVendorService.VendorServiceEdge.Node.ID
return m.CreateThirdPartyService.ThirdPartyServiceEdge.Node.ID
}
tests := []struct {
@@ -474,73 +474,73 @@ func TestVendorService_Delete(t *testing.T) {
role testutil.TestRole
variables func(serviceID string) map[string]any
check func(t *testing.T, err error, serviceID string, m *struct {
DeleteVendorService struct {
DeletedVendorServiceID string `json:"deletedVendorServiceId"`
} `json:"deleteVendorService"`
DeleteThirdPartyService struct {
DeletedThirdPartyServiceID string `json:"deletedThirdPartyServiceId"`
} `json:"deleteThirdPartyService"`
})
}{
{
name: "Viewer cannot delete vendor service",
name: "Viewer cannot delete thirdParty service",
role: testutil.RoleViewer,
variables: func(serviceID string) map[string]any {
return map[string]any{
"input": map[string]any{
"vendorServiceId": serviceID,
"thirdPartyServiceId": serviceID,
},
}
},
check: func(t *testing.T, err error, serviceID string, m *struct {
DeleteVendorService struct {
DeletedVendorServiceID string `json:"deletedVendorServiceId"`
} `json:"deleteVendorService"`
DeleteThirdPartyService struct {
DeletedThirdPartyServiceID string `json:"deletedThirdPartyServiceId"`
} `json:"deleteThirdPartyService"`
}) {
require.Error(t, err, "Viewer should not be able to delete vendor service")
require.Error(t, err, "Viewer should not be able to delete thirdParty service")
},
},
{
name: "Admin can delete vendor service",
name: "Admin can delete thirdParty service",
role: testutil.RoleAdmin,
variables: func(serviceID string) map[string]any {
return map[string]any{
"input": map[string]any{
"vendorServiceId": serviceID,
"thirdPartyServiceId": serviceID,
},
}
},
check: func(t *testing.T, err error, serviceID string, m *struct {
DeleteVendorService struct {
DeletedVendorServiceID string `json:"deletedVendorServiceId"`
} `json:"deleteVendorService"`
DeleteThirdPartyService struct {
DeletedThirdPartyServiceID string `json:"deletedThirdPartyServiceId"`
} `json:"deleteThirdPartyService"`
}) {
require.NoError(t, err)
assert.Equal(t, serviceID, m.DeleteVendorService.DeletedVendorServiceID)
assert.Equal(t, serviceID, m.DeleteThirdPartyService.DeletedThirdPartyServiceID)
},
},
{
name: "Owner can delete vendor service",
name: "Owner can delete thirdParty service",
role: testutil.RoleOwner,
variables: func(serviceID string) map[string]any {
return map[string]any{
"input": map[string]any{
"vendorServiceId": serviceID,
"thirdPartyServiceId": serviceID,
},
}
},
check: func(t *testing.T, err error, serviceID string, m *struct {
DeleteVendorService struct {
DeletedVendorServiceID string `json:"deletedVendorServiceId"`
} `json:"deleteVendorService"`
DeleteThirdPartyService struct {
DeletedThirdPartyServiceID string `json:"deletedThirdPartyServiceId"`
} `json:"deleteThirdPartyService"`
}) {
require.NoError(t, err)
assert.Equal(t, serviceID, m.DeleteVendorService.DeletedVendorServiceID)
assert.Equal(t, serviceID, m.DeleteThirdPartyService.DeletedThirdPartyServiceID)
},
},
}
deleteVendorServiceMutation := `
mutation DeleteVendorService($input: DeleteVendorServiceInput!) {
deleteVendorService(input: $input) {
deletedVendorServiceId
deleteThirdPartyServiceMutation := `
mutation DeleteThirdPartyService($input: DeleteThirdPartyServiceInput!) {
deleteThirdPartyService(input: $input) {
deletedThirdPartyServiceId
}
}
`
@@ -557,26 +557,26 @@ func TestVendorService_Delete(t *testing.T) {
}
var m struct {
DeleteVendorService struct {
DeletedVendorServiceID string `json:"deletedVendorServiceId"`
} `json:"deleteVendorService"`
DeleteThirdPartyService struct {
DeletedThirdPartyServiceID string `json:"deletedThirdPartyServiceId"`
} `json:"deleteThirdPartyService"`
}
err := client.Execute(deleteVendorServiceMutation, tt.variables(serviceID), &m)
err := client.Execute(deleteThirdPartyServiceMutation, tt.variables(serviceID), &m)
tt.check(t, err, serviceID, &m)
})
}
}
func TestVendorService_List(t *testing.T) {
func TestThirdPartyService_List(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
// Create a vendor first
createVendorMutation := `
mutation CreateVendor($input: CreateVendorInput!) {
createVendor(input: $input) {
vendorEdge {
// Create a thirdParty first
createThirdPartyMutation := `
mutation CreateThirdParty($input: CreateThirdPartyInput!) {
createThirdParty(input: $input) {
thirdPartyEdge {
node {
id
}
@@ -585,32 +585,32 @@ func TestVendorService_List(t *testing.T) {
}
`
var createVendorResult struct {
CreateVendor struct {
VendorEdge struct {
var createThirdPartyResult struct {
CreateThirdParty struct {
ThirdPartyEdge struct {
Node struct {
ID string `json:"id"`
} `json:"node"`
} `json:"vendorEdge"`
} `json:"createVendor"`
} `json:"thirdPartyEdge"`
} `json:"createThirdParty"`
}
err := owner.Execute(createVendorMutation, map[string]any{
err := owner.Execute(createThirdPartyMutation, map[string]any{
"input": map[string]any{
"organizationId": owner.GetOrganizationID().String(),
"name": "Vendor for Services",
"name": "ThirdParty for Services",
"category": "CLOUD_PROVIDER",
},
}, &createVendorResult)
}, &createThirdPartyResult)
require.NoError(t, err)
vendorID := createVendorResult.CreateVendor.VendorEdge.Node.ID
thirdPartyID := createThirdPartyResult.CreateThirdParty.ThirdPartyEdge.Node.ID
// Create multiple services
createServiceMutation := `
mutation CreateVendorService($input: CreateVendorServiceInput!) {
createVendorService(input: $input) {
vendorServiceEdge {
mutation CreateThirdPartyService($input: CreateThirdPartyServiceInput!) {
createThirdPartyService(input: $input) {
thirdPartyServiceEdge {
node {
id
}
@@ -622,19 +622,19 @@ func TestVendorService_List(t *testing.T) {
services := []string{"Service A", "Service B", "Service C"}
for _, name := range services {
var m struct {
CreateVendorService struct {
VendorServiceEdge struct {
CreateThirdPartyService struct {
ThirdPartyServiceEdge struct {
Node struct {
ID string `json:"id"`
} `json:"node"`
} `json:"vendorServiceEdge"`
} `json:"createVendorService"`
} `json:"thirdPartyServiceEdge"`
} `json:"createThirdPartyService"`
}
err := owner.Execute(createServiceMutation, map[string]any{
"input": map[string]any{
"vendorId": vendorID,
"name": name,
"thirdPartyId": thirdPartyID,
"name": name,
},
}, &m)
require.NoError(t, err)
@@ -659,11 +659,11 @@ func TestVendorService_List(t *testing.T) {
})
}{
{
name: "Owner can list vendor services",
name: "Owner can list thirdParty services",
role: testutil.RoleOwner,
variables: func() map[string]any {
return map[string]any{
"id": vendorID,
"id": thirdPartyID,
}
},
check: func(t *testing.T, err error, q *struct {
@@ -684,11 +684,11 @@ func TestVendorService_List(t *testing.T) {
},
},
{
name: "Viewer can list vendor services",
name: "Viewer can list thirdParty services",
role: testutil.RoleViewer,
variables: func() map[string]any {
return map[string]any{
"id": vendorID,
"id": thirdPartyID,
}
},
check: func(t *testing.T, err error, q *struct {
@@ -709,10 +709,10 @@ func TestVendorService_List(t *testing.T) {
},
}
listVendorServicesQuery := `
query ListVendorServices($id: ID!) {
listThirdPartyServicesQuery := `
query ListThirdPartyServices($id: ID!) {
node(id: $id) {
... on Vendor {
... on ThirdParty {
id
services(first: 10) {
edges {
@@ -750,7 +750,7 @@ func TestVendorService_List(t *testing.T) {
} `json:"node"`
}
err := client.Execute(listVendorServicesQuery, tt.variables(), &q)
err := client.Execute(listThirdPartyServicesQuery, tt.variables(), &q)
tt.check(t, err, &q)
})
}

View File

@@ -142,7 +142,7 @@ func CreateUser(c *testutil.Client, attrs ...Attrs) string {
return result.CreateUser.ProfileEdge.Node.ID
}
func CreateVendor(c *testutil.Client, attrs ...Attrs) string {
func CreateThirdParty(c *testutil.Client, attrs ...Attrs) string {
c.T.Helper()
var a Attrs
@@ -151,9 +151,9 @@ func CreateVendor(c *testutil.Client, attrs ...Attrs) string {
}
const query = `
mutation($input: CreateVendorInput!) {
createVendor(input: $input) {
vendorEdge {
mutation($input: CreateThirdPartyInput!) {
createThirdParty(input: $input) {
thirdPartyEdge {
node { id }
}
}
@@ -162,7 +162,7 @@ func CreateVendor(c *testutil.Client, attrs ...Attrs) string {
input := map[string]any{
"organizationId": c.GetOrganizationID().String(),
"name": a.getString("name", SafeName("Vendor")),
"name": a.getString("name", SafeName("ThirdParty")),
}
if desc := a.getStringPtr("description"); desc != nil {
input["description"] = *desc
@@ -175,19 +175,19 @@ func CreateVendor(c *testutil.Client, attrs ...Attrs) string {
}
var result struct {
CreateVendor struct {
VendorEdge struct {
CreateThirdParty struct {
ThirdPartyEdge struct {
Node struct {
ID string `json:"id"`
} `json:"node"`
} `json:"vendorEdge"`
} `json:"createVendor"`
} `json:"thirdPartyEdge"`
} `json:"createThirdParty"`
}
err := c.Execute(query, map[string]any{"input": input}, &result)
require.NoError(c.T, err, "createVendor mutation failed")
require.NoError(c.T, err, "createThirdParty mutation failed")
return result.CreateVendor.VendorEdge.Node.ID
return result.CreateThirdParty.ThirdPartyEdge.Node.ID
}
func CreateFramework(c *testutil.Client, attrs ...Attrs) string {
@@ -414,37 +414,37 @@ func CreateRisk(c *testutil.Client, attrs ...Attrs) string {
return result.CreateRisk.RiskEdge.Node.ID
}
type VendorBuilder struct {
type ThirdPartyBuilder struct {
client *testutil.Client
attrs Attrs
}
func NewVendor(c *testutil.Client) *VendorBuilder {
return &VendorBuilder{client: c, attrs: Attrs{}}
func NewThirdParty(c *testutil.Client) *ThirdPartyBuilder {
return &ThirdPartyBuilder{client: c, attrs: Attrs{}}
}
func (b *VendorBuilder) WithName(name string) *VendorBuilder {
func (b *ThirdPartyBuilder) WithName(name string) *ThirdPartyBuilder {
b.attrs["name"] = name
return b
}
func (b *VendorBuilder) WithDescription(desc string) *VendorBuilder {
func (b *ThirdPartyBuilder) WithDescription(desc string) *ThirdPartyBuilder {
b.attrs["description"] = desc
return b
}
func (b *VendorBuilder) WithWebsiteUrl(url string) *VendorBuilder {
func (b *ThirdPartyBuilder) WithWebsiteUrl(url string) *ThirdPartyBuilder {
b.attrs["websiteUrl"] = url
return b
}
func (b *VendorBuilder) WithCategory(category string) *VendorBuilder {
func (b *ThirdPartyBuilder) WithCategory(category string) *ThirdPartyBuilder {
b.attrs["category"] = category
return b
}
func (b *VendorBuilder) Create() string {
return CreateVendor(b.client, b.attrs)
func (b *ThirdPartyBuilder) Create() string {
return CreateThirdParty(b.client, b.attrs)
}
type FrameworkBuilder struct {

View File

@@ -95,7 +95,7 @@ func TestMCP_AuditLog(t *testing.T) {
orgID := owner.GetOrganizationID().String()
// Creating something generates audit log entries
factory.CreateVendor(owner)
factory.CreateThirdParty(owner)
var listResult struct {
AuditLogEntries []struct {

View File

@@ -0,0 +1,142 @@
// 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.
package mcp_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.probo.inc/probo/e2e/internal/factory"
"go.probo.inc/probo/e2e/internal/testutil"
)
type thirdPartyContact struct {
ID string `json:"id"`
Name string `json:"name"`
Email *string `json:"email"`
Phone *string `json:"phone"`
Role *string `json:"role"`
}
func TestMCP_AddThirdPartyContact(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
mc := testutil.NewMCPClient(t, owner)
thirdPartyID := factory.CreateThirdParty(owner)
var result struct {
ThirdPartyContact thirdPartyContact `json:"thirdPartyContact"`
}
mc.CallToolInto("addThirdPartyContact", map[string]any{
"thirdPartyId": thirdPartyID,
"name": "Alice Smith",
"email": "alice@example.com",
"phone": "+1-555-0100",
"role": "Account Manager",
}, &result)
assert.NotEmpty(t, result.ThirdPartyContact.ID)
assert.Equal(t, "Alice Smith", result.ThirdPartyContact.Name)
}
func TestMCP_UpdateThirdPartyContact(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
mc := testutil.NewMCPClient(t, owner)
thirdPartyID := factory.CreateThirdParty(owner)
// Create
var addResult struct {
ThirdPartyContact thirdPartyContact `json:"thirdPartyContact"`
}
mc.CallToolInto("addThirdPartyContact", map[string]any{
"thirdPartyId": thirdPartyID,
"name": "Bob Jones",
"email": "bob@example.com",
}, &addResult)
require.NotEmpty(t, addResult.ThirdPartyContact.ID)
// Update
var updateResult struct {
ThirdPartyContact thirdPartyContact `json:"thirdPartyContact"`
}
mc.CallToolInto("updateThirdPartyContact", map[string]any{
"id": addResult.ThirdPartyContact.ID,
"name": "Robert Jones",
"role": "CTO",
}, &updateResult)
assert.Equal(t, addResult.ThirdPartyContact.ID, updateResult.ThirdPartyContact.ID)
assert.Equal(t, "Robert Jones", updateResult.ThirdPartyContact.Name)
}
func TestMCP_DeleteThirdPartyContact(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
mc := testutil.NewMCPClient(t, owner)
thirdPartyID := factory.CreateThirdParty(owner)
// Create
var addResult struct {
ThirdPartyContact thirdPartyContact `json:"thirdPartyContact"`
}
mc.CallToolInto("addThirdPartyContact", map[string]any{
"thirdPartyId": thirdPartyID,
"name": "Contact to delete",
}, &addResult)
require.NotEmpty(t, addResult.ThirdPartyContact.ID)
// Delete
var deleteResult struct {
DeletedThirdPartyContactID string `json:"deletedThirdPartyContactId"`
}
mc.CallToolInto("deleteThirdPartyContact", map[string]any{
"id": addResult.ThirdPartyContact.ID,
}, &deleteResult)
assert.Equal(t, addResult.ThirdPartyContact.ID, deleteResult.DeletedThirdPartyContactID)
}
func TestMCP_ListThirdPartyContacts(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
mc := testutil.NewMCPClient(t, owner)
thirdPartyID := factory.CreateThirdParty(owner)
// Create contacts
for i := range 3 {
var result struct {
ThirdPartyContact thirdPartyContact `json:"thirdPartyContact"`
}
mc.CallToolInto("addThirdPartyContact", map[string]any{
"thirdPartyId": thirdPartyID,
"name": factory.SafeName("Contact"),
"email": factory.SafeEmail(),
}, &result)
require.NotEmpty(t, result.ThirdPartyContact.ID)
_ = i
}
// List
var listResult struct {
ThirdPartyContacts []thirdPartyContact `json:"thirdPartyContacts"`
}
mc.CallToolInto("listThirdPartyContacts", map[string]any{
"thirdPartyId": thirdPartyID,
}, &listResult)
assert.GreaterOrEqual(t, len(listResult.ThirdPartyContacts), 3)
}

View File

@@ -0,0 +1,135 @@
// 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.
package mcp_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.probo.inc/probo/e2e/internal/factory"
"go.probo.inc/probo/e2e/internal/testutil"
)
type thirdPartyService struct {
ID string `json:"id"`
Name string `json:"name"`
Description *string `json:"description"`
}
func TestMCP_AddThirdPartyService(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
mc := testutil.NewMCPClient(t, owner)
thirdPartyID := factory.CreateThirdParty(owner)
var result struct {
ThirdPartyService thirdPartyService `json:"thirdPartyService"`
}
mc.CallToolInto("addThirdPartyService", map[string]any{
"thirdPartyId": thirdPartyID,
"name": "Cloud Storage",
"description": "Object storage service",
}, &result)
assert.NotEmpty(t, result.ThirdPartyService.ID)
assert.Equal(t, "Cloud Storage", result.ThirdPartyService.Name)
}
func TestMCP_UpdateThirdPartyService(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
mc := testutil.NewMCPClient(t, owner)
thirdPartyID := factory.CreateThirdParty(owner)
// Create
var addResult struct {
ThirdPartyService thirdPartyService `json:"thirdPartyService"`
}
mc.CallToolInto("addThirdPartyService", map[string]any{
"thirdPartyId": thirdPartyID,
"name": "Original Service",
}, &addResult)
require.NotEmpty(t, addResult.ThirdPartyService.ID)
// Update
var updateResult struct {
ThirdPartyService thirdPartyService `json:"thirdPartyService"`
}
mc.CallToolInto("updateThirdPartyService", map[string]any{
"id": addResult.ThirdPartyService.ID,
"name": "Updated Service",
}, &updateResult)
assert.Equal(t, addResult.ThirdPartyService.ID, updateResult.ThirdPartyService.ID)
assert.Equal(t, "Updated Service", updateResult.ThirdPartyService.Name)
}
func TestMCP_DeleteThirdPartyService(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
mc := testutil.NewMCPClient(t, owner)
thirdPartyID := factory.CreateThirdParty(owner)
// Create
var addResult struct {
ThirdPartyService thirdPartyService `json:"thirdPartyService"`
}
mc.CallToolInto("addThirdPartyService", map[string]any{
"thirdPartyId": thirdPartyID,
"name": "Service to delete",
}, &addResult)
require.NotEmpty(t, addResult.ThirdPartyService.ID)
// Delete
var deleteResult struct {
DeletedThirdPartyServiceID string `json:"deletedThirdPartyServiceId"`
}
mc.CallToolInto("deleteThirdPartyService", map[string]any{
"id": addResult.ThirdPartyService.ID,
}, &deleteResult)
assert.Equal(t, addResult.ThirdPartyService.ID, deleteResult.DeletedThirdPartyServiceID)
}
func TestMCP_ListThirdPartyServices(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
mc := testutil.NewMCPClient(t, owner)
thirdPartyID := factory.CreateThirdParty(owner)
// Create services
for i := range 3 {
var result struct {
ThirdPartyService thirdPartyService `json:"thirdPartyService"`
}
mc.CallToolInto("addThirdPartyService", map[string]any{
"thirdPartyId": thirdPartyID,
"name": factory.SafeName("Service"),
}, &result)
require.NotEmpty(t, result.ThirdPartyService.ID)
_ = i
}
// List
var listResult struct {
ThirdPartyServices []thirdPartyService `json:"thirdPartyServices"`
}
mc.CallToolInto("listThirdPartyServices", map[string]any{
"thirdPartyId": thirdPartyID,
}, &listResult)
assert.GreaterOrEqual(t, len(listResult.ThirdPartyServices), 3)
}

View File

@@ -23,7 +23,7 @@ import (
"go.probo.inc/probo/e2e/internal/testutil"
)
func TestMCP_Vendor_CRUD(t *testing.T) {
func TestMCP_ThirdParty_CRUD(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
mc := testutil.NewMCPClient(t, owner)
@@ -31,67 +31,67 @@ func TestMCP_Vendor_CRUD(t *testing.T) {
// Create
var addResult struct {
Vendor struct {
ThirdParty struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"vendor"`
} `json:"third_party"`
}
name := factory.SafeName("Vendor")
mc.CallToolInto("addVendor", map[string]any{
name := factory.SafeName("ThirdParty")
mc.CallToolInto("addThirdParty", map[string]any{
"organizationId": orgID,
"name": name,
}, &addResult)
require.NotEmpty(t, addResult.Vendor.ID)
assert.Equal(t, name, addResult.Vendor.Name)
require.NotEmpty(t, addResult.ThirdParty.ID)
assert.Equal(t, name, addResult.ThirdParty.Name)
// Update
var updateResult struct {
Vendor struct {
ThirdParty struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"vendor"`
} `json:"third_party"`
}
mc.CallToolInto("updateVendor", map[string]any{
"id": addResult.Vendor.ID,
"name": "Updated Vendor",
mc.CallToolInto("updateThirdParty", map[string]any{
"id": addResult.ThirdParty.ID,
"name": "Updated ThirdParty",
}, &updateResult)
assert.Equal(t, "Updated Vendor", updateResult.Vendor.Name)
assert.Equal(t, "Updated ThirdParty", updateResult.ThirdParty.Name)
// List
var listResult struct {
Vendors []struct {
ThirdParties []struct {
ID string `json:"id"`
} `json:"vendors"`
} `json:"third_parties"`
}
mc.CallToolInto("listVendors", map[string]any{
mc.CallToolInto("listThirdParties", map[string]any{
"organizationId": orgID,
}, &listResult)
assert.NotEmpty(t, listResult.Vendors)
assert.NotEmpty(t, listResult.ThirdParties)
// Delete
var deleteResult struct {
DeletedVendorID string `json:"deletedVendorId"`
DeletedThirdPartyID string `json:"deletedThirdPartyId"`
}
mc.CallToolInto("deleteVendor", map[string]any{
"id": addResult.Vendor.ID,
mc.CallToolInto("deleteThirdParty", map[string]any{
"id": addResult.ThirdParty.ID,
}, &deleteResult)
assert.Equal(t, addResult.Vendor.ID, deleteResult.DeletedVendorID)
assert.Equal(t, addResult.ThirdParty.ID, deleteResult.DeletedThirdPartyID)
// Update deleted vendor returns sanitized not-found error
msg := mc.CallToolExpectToolError("updateVendor", map[string]any{
"id": addResult.Vendor.ID,
// Update deleted thirdParty returns sanitized not-found error
msg := mc.CallToolExpectToolError("updateThirdParty", map[string]any{
"id": addResult.ThirdParty.ID,
"name": "Should Fail",
})
assert.Equal(t, "resource not found", msg)
}
func TestMCP_Vendor_ValidationError(t *testing.T) {
func TestMCP_ThirdParty_ValidationError(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
mc := testutil.NewMCPClient(t, owner)
orgID := owner.GetOrganizationID().String()
msg := mc.CallToolExpectToolError("addVendor", map[string]any{
msg := mc.CallToolExpectToolError("addThirdParty", map[string]any{
"organizationId": orgID,
"name": "",
})
@@ -100,16 +100,16 @@ func TestMCP_Vendor_ValidationError(t *testing.T) {
assert.NotContains(t, msg, "sql:")
}
func TestMCP_Vendor_PermissionDenied(t *testing.T) {
func TestMCP_ThirdParty_PermissionDenied(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
orgID := owner.GetOrganizationID().String()
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
viewerMC := testutil.NewMCPClient(t, viewer)
msg := viewerMC.CallToolExpectToolError("addVendor", map[string]any{
msg := viewerMC.CallToolExpectToolError("addThirdParty", map[string]any{
"organizationId": orgID,
"name": factory.SafeName("Vendor"),
"name": factory.SafeName("ThirdParty"),
})
assert.Contains(t, msg, "permission denied")
assert.NotContains(t, msg, "pq:")

View File

@@ -1,142 +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.
package mcp_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.probo.inc/probo/e2e/internal/factory"
"go.probo.inc/probo/e2e/internal/testutil"
)
type vendorContact struct {
ID string `json:"id"`
Name string `json:"name"`
Email *string `json:"email"`
Phone *string `json:"phone"`
Role *string `json:"role"`
}
func TestMCP_AddVendorContact(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
mc := testutil.NewMCPClient(t, owner)
vendorID := factory.CreateVendor(owner)
var result struct {
VendorContact vendorContact `json:"vendorContact"`
}
mc.CallToolInto("addVendorContact", map[string]any{
"vendorId": vendorID,
"name": "Alice Smith",
"email": "alice@example.com",
"phone": "+1-555-0100",
"role": "Account Manager",
}, &result)
assert.NotEmpty(t, result.VendorContact.ID)
assert.Equal(t, "Alice Smith", result.VendorContact.Name)
}
func TestMCP_UpdateVendorContact(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
mc := testutil.NewMCPClient(t, owner)
vendorID := factory.CreateVendor(owner)
// Create
var addResult struct {
VendorContact vendorContact `json:"vendorContact"`
}
mc.CallToolInto("addVendorContact", map[string]any{
"vendorId": vendorID,
"name": "Bob Jones",
"email": "bob@example.com",
}, &addResult)
require.NotEmpty(t, addResult.VendorContact.ID)
// Update
var updateResult struct {
VendorContact vendorContact `json:"vendorContact"`
}
mc.CallToolInto("updateVendorContact", map[string]any{
"id": addResult.VendorContact.ID,
"name": "Robert Jones",
"role": "CTO",
}, &updateResult)
assert.Equal(t, addResult.VendorContact.ID, updateResult.VendorContact.ID)
assert.Equal(t, "Robert Jones", updateResult.VendorContact.Name)
}
func TestMCP_DeleteVendorContact(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
mc := testutil.NewMCPClient(t, owner)
vendorID := factory.CreateVendor(owner)
// Create
var addResult struct {
VendorContact vendorContact `json:"vendorContact"`
}
mc.CallToolInto("addVendorContact", map[string]any{
"vendorId": vendorID,
"name": "Contact to delete",
}, &addResult)
require.NotEmpty(t, addResult.VendorContact.ID)
// Delete
var deleteResult struct {
DeletedVendorContactID string `json:"deletedVendorContactId"`
}
mc.CallToolInto("deleteVendorContact", map[string]any{
"id": addResult.VendorContact.ID,
}, &deleteResult)
assert.Equal(t, addResult.VendorContact.ID, deleteResult.DeletedVendorContactID)
}
func TestMCP_ListVendorContacts(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
mc := testutil.NewMCPClient(t, owner)
vendorID := factory.CreateVendor(owner)
// Create contacts
for i := range 3 {
var result struct {
VendorContact vendorContact `json:"vendorContact"`
}
mc.CallToolInto("addVendorContact", map[string]any{
"vendorId": vendorID,
"name": factory.SafeName("Contact"),
"email": factory.SafeEmail(),
}, &result)
require.NotEmpty(t, result.VendorContact.ID)
_ = i
}
// List
var listResult struct {
VendorContacts []vendorContact `json:"vendorContacts"`
}
mc.CallToolInto("listVendorContacts", map[string]any{
"vendorId": vendorID,
}, &listResult)
assert.GreaterOrEqual(t, len(listResult.VendorContacts), 3)
}

View File

@@ -1,135 +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.
package mcp_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.probo.inc/probo/e2e/internal/factory"
"go.probo.inc/probo/e2e/internal/testutil"
)
type vendorService struct {
ID string `json:"id"`
Name string `json:"name"`
Description *string `json:"description"`
}
func TestMCP_AddVendorService(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
mc := testutil.NewMCPClient(t, owner)
vendorID := factory.CreateVendor(owner)
var result struct {
VendorService vendorService `json:"vendorService"`
}
mc.CallToolInto("addVendorService", map[string]any{
"vendorId": vendorID,
"name": "Cloud Storage",
"description": "Object storage service",
}, &result)
assert.NotEmpty(t, result.VendorService.ID)
assert.Equal(t, "Cloud Storage", result.VendorService.Name)
}
func TestMCP_UpdateVendorService(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
mc := testutil.NewMCPClient(t, owner)
vendorID := factory.CreateVendor(owner)
// Create
var addResult struct {
VendorService vendorService `json:"vendorService"`
}
mc.CallToolInto("addVendorService", map[string]any{
"vendorId": vendorID,
"name": "Original Service",
}, &addResult)
require.NotEmpty(t, addResult.VendorService.ID)
// Update
var updateResult struct {
VendorService vendorService `json:"vendorService"`
}
mc.CallToolInto("updateVendorService", map[string]any{
"id": addResult.VendorService.ID,
"name": "Updated Service",
}, &updateResult)
assert.Equal(t, addResult.VendorService.ID, updateResult.VendorService.ID)
assert.Equal(t, "Updated Service", updateResult.VendorService.Name)
}
func TestMCP_DeleteVendorService(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
mc := testutil.NewMCPClient(t, owner)
vendorID := factory.CreateVendor(owner)
// Create
var addResult struct {
VendorService vendorService `json:"vendorService"`
}
mc.CallToolInto("addVendorService", map[string]any{
"vendorId": vendorID,
"name": "Service to delete",
}, &addResult)
require.NotEmpty(t, addResult.VendorService.ID)
// Delete
var deleteResult struct {
DeletedVendorServiceID string `json:"deletedVendorServiceId"`
}
mc.CallToolInto("deleteVendorService", map[string]any{
"id": addResult.VendorService.ID,
}, &deleteResult)
assert.Equal(t, addResult.VendorService.ID, deleteResult.DeletedVendorServiceID)
}
func TestMCP_ListVendorServices(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
mc := testutil.NewMCPClient(t, owner)
vendorID := factory.CreateVendor(owner)
// Create services
for i := range 3 {
var result struct {
VendorService vendorService `json:"vendorService"`
}
mc.CallToolInto("addVendorService", map[string]any{
"vendorId": vendorID,
"name": factory.SafeName("Service"),
}, &result)
require.NotEmpty(t, result.VendorService.ID)
_ = i
}
// List
var listResult struct {
VendorServices []vendorService `json:"vendorServices"`
}
mc.CallToolInto("listVendorServices", map[string]any{
"vendorId": vendorID,
}, &listResult)
assert.GreaterOrEqual(t, len(listResult.VendorServices), 3)
}

20
package-lock.json generated
View File

@@ -35,8 +35,8 @@
"@probo/react-lazy": "1.0.0",
"@probo/relay": "^1.0.0",
"@probo/routes": "^1.0.0",
"@probo/third-parties": "0.0.1",
"@probo/ui": "1.0.0",
"@probo/vendors": "0.0.1",
"@tanstack/react-query": "^5.76.1",
"clsx": "^2.1.1",
"react": "^19.1.0",
@@ -3246,6 +3246,10 @@
"resolved": "packages/routes",
"link": true
},
"node_modules/@probo/third-parties": {
"resolved": "packages/third-parties",
"link": true
},
"node_modules/@probo/trust": {
"resolved": "apps/trust",
"link": true
@@ -3258,10 +3262,6 @@
"resolved": "packages/ui",
"link": true
},
"node_modules/@probo/vendors": {
"resolved": "packages/vendors",
"link": true
},
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
@@ -19633,6 +19633,11 @@
"relay-runtime": "^20.1.1"
}
},
"packages/third-parties": {
"name": "@probo/third-parties",
"version": "0.0.1",
"license": "CC BY-SA 4.0"
},
"packages/tsconfig": {
"name": "@probo/tsconfig",
"version": "0.0.1",
@@ -20010,11 +20015,6 @@
"optional": true
}
}
},
"packages/vendors": {
"name": "@probo/vendors",
"version": "0.0.1",
"license": "CC BY-SA 4.0"
}
}
}

View File

@@ -196,6 +196,11 @@ export class Probo implements INodeType {
value: 'task',
description: 'Manage tasks',
},
{
name: 'Third Party',
value: 'thirdParty',
description: 'Manage third parties',
},
{
name: 'TIA',
value: 'tia',
@@ -216,11 +221,6 @@ export class Probo implements INodeType {
value: 'user',
description: 'Manage organization users (profiles)',
},
{
name: 'Vendor',
value: 'vendor',
description: 'Manage vendors',
},
{
name: 'Webhook',
value: 'webhook',

View File

@@ -111,8 +111,8 @@ export const description: INodeProperties[] = [
required: true,
},
{
displayName: 'Vendor IDs',
name: 'vendorIds',
displayName: 'ThirdParty IDs',
name: 'thirdPartyIds',
type: 'string',
displayOptions: {
show: {
@@ -121,7 +121,7 @@ export const description: INodeProperties[] = [
},
},
default: '',
description: 'Comma-separated list of vendor IDs',
description: 'Comma-separated list of thirdParty IDs',
},
{
displayName: 'Options',
@@ -144,11 +144,11 @@ export const description: INodeProperties[] = [
description: 'Whether to include owner details in the response',
},
{
displayName: 'Include Vendors',
name: 'includeVendors',
displayName: 'Include ThirdParties',
name: 'includeThirdParties',
type: 'boolean',
default: false,
description: 'Whether to include vendors in the response',
description: 'Whether to include thirdParties in the response',
},
],
},
@@ -164,10 +164,10 @@ export async function execute(
const ownerId = this.getNodeParameter('ownerId', itemIndex) as string;
const assetType = this.getNodeParameter('assetType', itemIndex) as string;
const dataTypesStored = this.getNodeParameter('dataTypesStored', itemIndex) as string;
const vendorIdsStr = this.getNodeParameter('vendorIds', itemIndex, '') as string;
const thirdPartyIdsStr = this.getNodeParameter('thirdPartyIds', itemIndex, '') as string;
const options = this.getNodeParameter('options', itemIndex, {}) as {
includeOwner?: boolean;
includeVendors?: boolean;
includeThirdParties?: boolean;
};
const ownerFragment = options.includeOwner
@@ -178,8 +178,8 @@ export async function execute(
}`
: '';
const vendorsFragment = options.includeVendors
? `vendors(first: 100) {
const thirdPartiesFragment = options.includeThirdParties
? `thirdParties(first: 100) {
edges {
node {
id
@@ -200,7 +200,7 @@ export async function execute(
assetType
dataTypesStored
${ownerFragment}
${vendorsFragment}
${thirdPartiesFragment}
createdAt
updatedAt
}
@@ -209,7 +209,7 @@ export async function execute(
}
`;
const vendorIds = vendorIdsStr ? vendorIdsStr.split(',').map((id) => id.trim()).filter(Boolean) : undefined;
const thirdPartyIds = thirdPartyIdsStr ? thirdPartyIdsStr.split(',').map((id) => id.trim()).filter(Boolean) : undefined;
const variables = {
input: {
@@ -219,7 +219,7 @@ export async function execute(
ownerId,
assetType,
dataTypesStored,
...(vendorIds && vendorIds.length > 0 && { vendorIds }),
...(thirdPartyIds && thirdPartyIds.length > 0 && { thirdPartyIds }),
},
};

View File

@@ -51,11 +51,11 @@ export const description: INodeProperties[] = [
description: 'Whether to include owner details in the response',
},
{
displayName: 'Include Vendors',
name: 'includeVendors',
displayName: 'Include ThirdParties',
name: 'includeThirdParties',
type: 'boolean',
default: false,
description: 'Whether to include vendors in the response',
description: 'Whether to include thirdParties in the response',
},
],
},
@@ -68,7 +68,7 @@ export async function execute(
const assetId = this.getNodeParameter('assetId', itemIndex) as string;
const options = this.getNodeParameter('options', itemIndex, {}) as {
includeOwner?: boolean;
includeVendors?: boolean;
includeThirdParties?: boolean;
};
const ownerFragment = options.includeOwner
@@ -79,8 +79,8 @@ export async function execute(
}`
: '';
const vendorsFragment = options.includeVendors
? `vendors(first: 100) {
const thirdPartiesFragment = options.includeThirdParties
? `thirdParties(first: 100) {
edges {
node {
id
@@ -100,7 +100,7 @@ export async function execute(
assetType
dataTypesStored
${ownerFragment}
${vendorsFragment}
${thirdPartiesFragment}
createdAt
updatedAt
}

View File

@@ -81,11 +81,11 @@ export const description: INodeProperties[] = [
description: 'Whether to include owner details in the response',
},
{
displayName: 'Include Vendors',
name: 'includeVendors',
displayName: 'Include ThirdParties',
name: 'includeThirdParties',
type: 'boolean',
default: false,
description: 'Whether to include vendors in the response',
description: 'Whether to include thirdParties in the response',
},
],
},
@@ -100,7 +100,7 @@ export async function execute(
const limit = this.getNodeParameter('limit', itemIndex, 50) as number;
const options = this.getNodeParameter('options', itemIndex, {}) as {
includeOwner?: boolean;
includeVendors?: boolean;
includeThirdParties?: boolean;
};
const ownerFragment = options.includeOwner
@@ -111,8 +111,8 @@ export async function execute(
}`
: '';
const vendorsFragment = options.includeVendors
? `vendors(first: 100) {
const thirdPartiesFragment = options.includeThirdParties
? `thirdParties(first: 100) {
edges {
node {
id
@@ -135,7 +135,7 @@ export async function execute(
assetType
dataTypesStored
${ownerFragment}
${vendorsFragment}
${thirdPartiesFragment}
createdAt
updatedAt
}

View File

@@ -106,8 +106,8 @@ export const description: INodeProperties[] = [
description: 'The types of data stored in the asset',
},
{
displayName: 'Vendor IDs',
name: 'vendorIds',
displayName: 'ThirdParty IDs',
name: 'thirdPartyIds',
type: 'string',
displayOptions: {
show: {
@@ -116,7 +116,7 @@ export const description: INodeProperties[] = [
},
},
default: '',
description: 'Comma-separated list of vendor IDs',
description: 'Comma-separated list of thirdParty IDs',
},
{
displayName: 'Options',
@@ -139,11 +139,11 @@ export const description: INodeProperties[] = [
description: 'Whether to include owner details in the response',
},
{
displayName: 'Include Vendors',
name: 'includeVendors',
displayName: 'Include ThirdParties',
name: 'includeThirdParties',
type: 'boolean',
default: false,
description: 'Whether to include vendors in the response',
description: 'Whether to include thirdParties in the response',
},
],
},
@@ -159,10 +159,10 @@ export async function execute(
const ownerId = this.getNodeParameter('ownerId', itemIndex, '') as string;
const assetType = this.getNodeParameter('assetType', itemIndex, '') as string;
const dataTypesStored = this.getNodeParameter('dataTypesStored', itemIndex, '') as string;
const vendorIdsStr = this.getNodeParameter('vendorIds', itemIndex, '') as string;
const thirdPartyIdsStr = this.getNodeParameter('thirdPartyIds', itemIndex, '') as string;
const options = this.getNodeParameter('options', itemIndex, {}) as {
includeOwner?: boolean;
includeVendors?: boolean;
includeThirdParties?: boolean;
};
const ownerFragment = options.includeOwner
@@ -173,8 +173,8 @@ export async function execute(
}`
: '';
const vendorsFragment = options.includeVendors
? `vendors(first: 100) {
const thirdPartiesFragment = options.includeThirdParties
? `thirdParties(first: 100) {
edges {
node {
id
@@ -194,7 +194,7 @@ export async function execute(
assetType
dataTypesStored
${ownerFragment}
${vendorsFragment}
${thirdPartiesFragment}
createdAt
updatedAt
}
@@ -208,9 +208,9 @@ export async function execute(
if (ownerId) input.ownerId = ownerId;
if (assetType) input.assetType = assetType;
if (dataTypesStored) input.dataTypesStored = dataTypesStored;
if (vendorIdsStr) {
const vendorIds = vendorIdsStr.split(',').map((vid) => vid.trim()).filter(Boolean);
if (vendorIds.length > 0) input.vendorIds = vendorIds;
if (thirdPartyIdsStr) {
const thirdPartyIds = thirdPartyIdsStr.split(',').map((vid) => vid.trim()).filter(Boolean);
if (thirdPartyIds.length > 0) input.thirdPartyIds = thirdPartyIds;
}
const responseData = await proboApiRequest.call(this, query, { input });

View File

@@ -91,8 +91,8 @@ export const description: INodeProperties[] = [
required: true,
},
{
displayName: 'Vendor IDs',
name: 'vendorIds',
displayName: 'ThirdParty IDs',
name: 'thirdPartyIds',
type: 'string',
displayOptions: {
show: {
@@ -101,7 +101,7 @@ export const description: INodeProperties[] = [
},
},
default: '',
description: 'Comma-separated list of vendor IDs',
description: 'Comma-separated list of thirdParty IDs',
},
{
displayName: 'Options',
@@ -124,11 +124,11 @@ export const description: INodeProperties[] = [
description: 'Whether to include owner details in the response',
},
{
displayName: 'Include Vendors',
name: 'includeVendors',
displayName: 'Include ThirdParties',
name: 'includeThirdParties',
type: 'boolean',
default: false,
description: 'Whether to include vendors in the response',
description: 'Whether to include thirdParties in the response',
},
],
},
@@ -142,10 +142,10 @@ export async function execute(
const name = this.getNodeParameter('name', itemIndex) as string;
const dataClassification = this.getNodeParameter('dataClassification', itemIndex) as string;
const ownerId = this.getNodeParameter('ownerId', itemIndex) as string;
const vendorIdsStr = this.getNodeParameter('vendorIds', itemIndex, '') as string;
const thirdPartyIdsStr = this.getNodeParameter('thirdPartyIds', itemIndex, '') as string;
const options = this.getNodeParameter('options', itemIndex, {}) as {
includeOwner?: boolean;
includeVendors?: boolean;
includeThirdParties?: boolean;
};
const ownerFragment = options.includeOwner
@@ -156,8 +156,8 @@ export async function execute(
}`
: '';
const vendorsFragment = options.includeVendors
? `vendors(first: 100) {
const thirdPartiesFragment = options.includeThirdParties
? `thirdParties(first: 100) {
edges {
node {
id
@@ -176,7 +176,7 @@ export async function execute(
name
dataClassification
${ownerFragment}
${vendorsFragment}
${thirdPartiesFragment}
createdAt
updatedAt
}
@@ -185,7 +185,7 @@ export async function execute(
}
`;
const vendorIds = vendorIdsStr ? vendorIdsStr.split(',').map((id) => id.trim()).filter(Boolean) : undefined;
const thirdPartyIds = thirdPartyIdsStr ? thirdPartyIdsStr.split(',').map((id) => id.trim()).filter(Boolean) : undefined;
const variables = {
input: {
@@ -193,7 +193,7 @@ export async function execute(
name,
dataClassification,
ownerId,
...(vendorIds && vendorIds.length > 0 && { vendorIds }),
...(thirdPartyIds && thirdPartyIds.length > 0 && { thirdPartyIds }),
},
};

View File

@@ -51,11 +51,11 @@ export const description: INodeProperties[] = [
description: 'Whether to include owner details in the response',
},
{
displayName: 'Include Vendors',
name: 'includeVendors',
displayName: 'Include ThirdParties',
name: 'includeThirdParties',
type: 'boolean',
default: false,
description: 'Whether to include vendors in the response',
description: 'Whether to include thirdParties in the response',
},
],
},
@@ -68,7 +68,7 @@ export async function execute(
const datumId = this.getNodeParameter('datumId', itemIndex) as string;
const options = this.getNodeParameter('options', itemIndex, {}) as {
includeOwner?: boolean;
includeVendors?: boolean;
includeThirdParties?: boolean;
};
const ownerFragment = options.includeOwner
@@ -79,8 +79,8 @@ export async function execute(
}`
: '';
const vendorsFragment = options.includeVendors
? `vendors(first: 100) {
const thirdPartiesFragment = options.includeThirdParties
? `thirdParties(first: 100) {
edges {
node {
id
@@ -98,7 +98,7 @@ export async function execute(
name
dataClassification
${ownerFragment}
${vendorsFragment}
${thirdPartiesFragment}
createdAt
updatedAt
}

View File

@@ -81,11 +81,11 @@ export const description: INodeProperties[] = [
description: 'Whether to include owner details in the response',
},
{
displayName: 'Include Vendors',
name: 'includeVendors',
displayName: 'Include ThirdParties',
name: 'includeThirdParties',
type: 'boolean',
default: false,
description: 'Whether to include vendors in the response',
description: 'Whether to include thirdParties in the response',
},
],
},
@@ -100,7 +100,7 @@ export async function execute(
const limit = this.getNodeParameter('limit', itemIndex, 50) as number;
const options = this.getNodeParameter('options', itemIndex, {}) as {
includeOwner?: boolean;
includeVendors?: boolean;
includeThirdParties?: boolean;
};
const ownerFragment = options.includeOwner
@@ -111,8 +111,8 @@ export async function execute(
}`
: '';
const vendorsFragment = options.includeVendors
? `vendors(first: 100) {
const thirdPartiesFragment = options.includeThirdParties
? `thirdParties(first: 100) {
edges {
node {
id
@@ -133,7 +133,7 @@ export async function execute(
name
dataClassification
${ownerFragment}
${vendorsFragment}
${thirdPartiesFragment}
createdAt
updatedAt
}

View File

@@ -88,8 +88,8 @@ export const description: INodeProperties[] = [
description: 'The ID of the owner (People)',
},
{
displayName: 'Vendor IDs',
name: 'vendorIds',
displayName: 'ThirdParty IDs',
name: 'thirdPartyIds',
type: 'string',
displayOptions: {
show: {
@@ -98,7 +98,7 @@ export const description: INodeProperties[] = [
},
},
default: '',
description: 'Comma-separated list of vendor IDs',
description: 'Comma-separated list of thirdParty IDs',
},
{
displayName: 'Options',
@@ -121,11 +121,11 @@ export const description: INodeProperties[] = [
description: 'Whether to include owner details in the response',
},
{
displayName: 'Include Vendors',
name: 'includeVendors',
displayName: 'Include ThirdParties',
name: 'includeThirdParties',
type: 'boolean',
default: false,
description: 'Whether to include vendors in the response',
description: 'Whether to include thirdParties in the response',
},
],
},
@@ -139,10 +139,10 @@ export async function execute(
const name = this.getNodeParameter('name', itemIndex, '') as string;
const dataClassification = this.getNodeParameter('dataClassification', itemIndex, '') as string;
const ownerId = this.getNodeParameter('ownerId', itemIndex, '') as string;
const vendorIdsStr = this.getNodeParameter('vendorIds', itemIndex, '') as string;
const thirdPartyIdsStr = this.getNodeParameter('thirdPartyIds', itemIndex, '') as string;
const options = this.getNodeParameter('options', itemIndex, {}) as {
includeOwner?: boolean;
includeVendors?: boolean;
includeThirdParties?: boolean;
};
const ownerFragment = options.includeOwner
@@ -153,8 +153,8 @@ export async function execute(
}`
: '';
const vendorsFragment = options.includeVendors
? `vendors(first: 100) {
const thirdPartiesFragment = options.includeThirdParties
? `thirdParties(first: 100) {
edges {
node {
id
@@ -172,7 +172,7 @@ export async function execute(
name
dataClassification
${ownerFragment}
${vendorsFragment}
${thirdPartiesFragment}
createdAt
updatedAt
}
@@ -184,9 +184,9 @@ export async function execute(
if (name) input.name = name;
if (dataClassification) input.dataClassification = dataClassification;
if (ownerId) input.ownerId = ownerId;
if (vendorIdsStr) {
const vendorIds = vendorIdsStr.split(',').map((vid) => vid.trim()).filter(Boolean);
if (vendorIds.length > 0) input.vendorIds = vendorIds;
if (thirdPartyIdsStr) {
const thirdPartyIds = thirdPartyIdsStr.split(',').map((vid) => vid.trim()).filter(Boolean);
if (thirdPartyIds.length > 0) input.thirdPartyIds = thirdPartyIds;
}
const responseData = await proboApiRequest.call(this, query, { input });

View File

@@ -41,7 +41,7 @@ import * as statementOfApplicability from './statementOfApplicability';
import * as task from './task';
import * as tia from './tia';
import * as trustCenter from './trustCenter';
import * as vendor from './vendor';
import * as thirdParty from './thirdParty';
import * as webhook from './webhook';
export interface ResourceModule {
@@ -83,7 +83,7 @@ export const resources: Record<string, ResourceModule> = {
task: task as ResourceModule,
tia: tia as ResourceModule,
trustCenter: trustCenter as ResourceModule,
vendor: vendor as ResourceModule,
thirdParty: thirdParty as ResourceModule,
webhook: webhook as ResourceModule,
};

View File

@@ -22,7 +22,7 @@ export const description: INodeProperties[] = [
type: 'string',
displayOptions: {
show: {
resource: ['vendor'],
resource: ['thirdParty'],
operation: ['create'],
},
},
@@ -36,12 +36,12 @@ export const description: INodeProperties[] = [
type: 'string',
displayOptions: {
show: {
resource: ['vendor'],
resource: ['thirdParty'],
operation: ['create'],
},
},
default: '',
description: 'The name of the vendor',
description: 'The name of the thirdParty',
required: true,
},
{
@@ -53,12 +53,12 @@ export const description: INodeProperties[] = [
},
displayOptions: {
show: {
resource: ['vendor'],
resource: ['thirdParty'],
operation: ['create'],
},
},
default: '',
description: 'The description of the vendor',
description: 'The description of the thirdParty',
},
{
displayName: 'Category',
@@ -66,12 +66,12 @@ export const description: INodeProperties[] = [
type: 'string',
displayOptions: {
show: {
resource: ['vendor'],
resource: ['thirdParty'],
operation: ['create'],
},
},
default: '',
description: 'The category of the vendor',
description: 'The category of the thirdParty',
},
{
displayName: 'Website URL',
@@ -79,12 +79,12 @@ export const description: INodeProperties[] = [
type: 'string',
displayOptions: {
show: {
resource: ['vendor'],
resource: ['thirdParty'],
operation: ['create'],
},
},
default: '',
description: 'The website URL of the vendor',
description: 'The website URL of the thirdParty',
},
{
displayName: 'Legal Name',
@@ -92,12 +92,12 @@ export const description: INodeProperties[] = [
type: 'string',
displayOptions: {
show: {
resource: ['vendor'],
resource: ['thirdParty'],
operation: ['create'],
},
},
default: '',
description: 'The legal name of the vendor',
description: 'The legal name of the thirdParty',
},
{
displayName: 'Headquarter Address',
@@ -105,12 +105,12 @@ export const description: INodeProperties[] = [
type: 'string',
displayOptions: {
show: {
resource: ['vendor'],
resource: ['thirdParty'],
operation: ['create'],
},
},
default: '',
description: 'The headquarter address of the vendor',
description: 'The headquarter address of the thirdParty',
},
{
displayName: 'Business Owner ID',
@@ -118,7 +118,7 @@ export const description: INodeProperties[] = [
type: 'string',
displayOptions: {
show: {
resource: ['vendor'],
resource: ['thirdParty'],
operation: ['create'],
},
},
@@ -131,7 +131,7 @@ export const description: INodeProperties[] = [
type: 'string',
displayOptions: {
show: {
resource: ['vendor'],
resource: ['thirdParty'],
operation: ['create'],
},
},
@@ -146,7 +146,7 @@ export const description: INodeProperties[] = [
default: {},
displayOptions: {
show: {
resource: ['vendor'],
resource: ['thirdParty'],
operation: ['create'],
},
},
@@ -200,7 +200,7 @@ export const description: INodeProperties[] = [
name: 'statusPageUrl',
type: 'string',
default: '',
description: 'The status page URL of the vendor',
description: 'The status page URL of the thirdParty',
},
{
displayName: 'Subprocessors List URL',
@@ -252,9 +252,9 @@ export async function execute(
};
const query = `
mutation CreateVendor($input: CreateVendorInput!) {
createVendor(input: $input) {
vendorEdge {
mutation CreateThirdParty($input: CreateThirdPartyInput!) {
createThirdParty(input: $input) {
thirdPartyEdge {
node {
id
name

View File

@@ -17,17 +17,17 @@ import { proboApiRequest } from '../../GenericFunctions';
export const description: INodeProperties[] = [
{
displayName: 'Vendor ID',
name: 'vendorId',
displayName: 'ThirdParty ID',
name: 'thirdPartyId',
type: 'string',
displayOptions: {
show: {
resource: ['vendor'],
resource: ['thirdParty'],
operation: ['createContact'],
},
},
default: '',
description: 'The ID of the vendor',
description: 'The ID of the thirdParty',
required: true,
},
{
@@ -36,7 +36,7 @@ export const description: INodeProperties[] = [
type: 'string',
displayOptions: {
show: {
resource: ['vendor'],
resource: ['thirdParty'],
operation: ['createContact'],
},
},
@@ -50,7 +50,7 @@ export const description: INodeProperties[] = [
placeholder: 'name@email.com',
displayOptions: {
show: {
resource: ['vendor'],
resource: ['thirdParty'],
operation: ['createContact'],
},
},
@@ -63,7 +63,7 @@ export const description: INodeProperties[] = [
type: 'string',
displayOptions: {
show: {
resource: ['vendor'],
resource: ['thirdParty'],
operation: ['createContact'],
},
},
@@ -76,7 +76,7 @@ export const description: INodeProperties[] = [
type: 'string',
displayOptions: {
show: {
resource: ['vendor'],
resource: ['thirdParty'],
operation: ['createContact'],
},
},
@@ -89,16 +89,16 @@ export async function execute(
this: IExecuteFunctions,
itemIndex: number,
): Promise<INodeExecutionData> {
const vendorId = this.getNodeParameter('vendorId', itemIndex) as string;
const thirdPartyId = this.getNodeParameter('thirdPartyId', itemIndex) as string;
const fullName = this.getNodeParameter('fullName', itemIndex, '') as string;
const email = this.getNodeParameter('email', itemIndex, '') as string;
const phone = this.getNodeParameter('phone', itemIndex, '') as string;
const role = this.getNodeParameter('role', itemIndex, '') as string;
const query = `
mutation CreateVendorContact($input: CreateVendorContactInput!) {
createVendorContact(input: $input) {
vendorContactEdge {
mutation CreateThirdPartyContact($input: CreateThirdPartyContactInput!) {
createThirdPartyContact(input: $input) {
thirdPartyContactEdge {
node {
id
fullName
@@ -113,7 +113,7 @@ export async function execute(
}
`;
const input: Record<string, unknown> = { vendorId };
const input: Record<string, unknown> = { thirdPartyId };
if (fullName) input.fullName = fullName;
if (email) input.email = email;
if (phone) input.phone = phone;

View File

@@ -17,17 +17,17 @@ import { proboApiRequest } from '../../GenericFunctions';
export const description: INodeProperties[] = [
{
displayName: 'Vendor ID',
name: 'vendorId',
displayName: 'ThirdParty ID',
name: 'thirdPartyId',
type: 'string',
displayOptions: {
show: {
resource: ['vendor'],
resource: ['thirdParty'],
operation: ['createRiskAssessment'],
},
},
default: '',
description: 'The ID of the vendor',
description: 'The ID of the thirdParty',
required: true,
},
{
@@ -36,7 +36,7 @@ export const description: INodeProperties[] = [
type: 'dateTime',
displayOptions: {
show: {
resource: ['vendor'],
resource: ['thirdParty'],
operation: ['createRiskAssessment'],
},
},
@@ -50,7 +50,7 @@ export const description: INodeProperties[] = [
type: 'options',
displayOptions: {
show: {
resource: ['vendor'],
resource: ['thirdParty'],
operation: ['createRiskAssessment'],
},
},
@@ -71,7 +71,7 @@ export const description: INodeProperties[] = [
type: 'options',
displayOptions: {
show: {
resource: ['vendor'],
resource: ['thirdParty'],
operation: ['createRiskAssessment'],
},
},
@@ -94,7 +94,7 @@ export const description: INodeProperties[] = [
},
displayOptions: {
show: {
resource: ['vendor'],
resource: ['thirdParty'],
operation: ['createRiskAssessment'],
},
},
@@ -107,7 +107,7 @@ export async function execute(
this: IExecuteFunctions,
itemIndex: number,
): Promise<INodeExecutionData> {
const vendorId = this.getNodeParameter('vendorId', itemIndex) as string;
const thirdPartyId = this.getNodeParameter('thirdPartyId', itemIndex) as string;
const expiresAtRaw = this.getNodeParameter('expiresAt', itemIndex) as string;
const dataSensitivity = this.getNodeParameter('dataSensitivity', itemIndex) as string;
const businessImpact = this.getNodeParameter('businessImpact', itemIndex) as string;
@@ -117,9 +117,9 @@ export async function execute(
const expiresAt = new Date(expiresAtRaw).toISOString();
const query = `
mutation CreateVendorRiskAssessment($input: CreateVendorRiskAssessmentInput!) {
createVendorRiskAssessment(input: $input) {
vendorRiskAssessmentEdge {
mutation CreateThirdPartyRiskAssessment($input: CreateThirdPartyRiskAssessmentInput!) {
createThirdPartyRiskAssessment(input: $input) {
thirdPartyRiskAssessmentEdge {
node {
id
expiresAt
@@ -135,7 +135,7 @@ export async function execute(
`;
const input: Record<string, unknown> = {
vendorId,
thirdPartyId,
expiresAt,
dataSensitivity,
businessImpact,

View File

@@ -17,17 +17,17 @@ import { proboApiRequest } from '../../GenericFunctions';
export const description: INodeProperties[] = [
{
displayName: 'Vendor ID',
name: 'vendorId',
displayName: 'ThirdParty ID',
name: 'thirdPartyId',
type: 'string',
displayOptions: {
show: {
resource: ['vendor'],
resource: ['thirdParty'],
operation: ['createService'],
},
},
default: '',
description: 'The ID of the vendor',
description: 'The ID of the thirdParty',
required: true,
},
{
@@ -36,12 +36,12 @@ export const description: INodeProperties[] = [
type: 'string',
displayOptions: {
show: {
resource: ['vendor'],
resource: ['thirdParty'],
operation: ['createService'],
},
},
default: '',
description: 'The name of the vendor service',
description: 'The name of the thirdParty service',
required: true,
},
{
@@ -53,12 +53,12 @@ export const description: INodeProperties[] = [
},
displayOptions: {
show: {
resource: ['vendor'],
resource: ['thirdParty'],
operation: ['createService'],
},
},
default: '',
description: 'The description of the vendor service',
description: 'The description of the thirdParty service',
},
];
@@ -66,14 +66,14 @@ export async function execute(
this: IExecuteFunctions,
itemIndex: number,
): Promise<INodeExecutionData> {
const vendorId = this.getNodeParameter('vendorId', itemIndex) as string;
const thirdPartyId = this.getNodeParameter('thirdPartyId', itemIndex) as string;
const name = this.getNodeParameter('name', itemIndex) as string;
const description = this.getNodeParameter('description', itemIndex, '') as string;
const query = `
mutation CreateVendorService($input: CreateVendorServiceInput!) {
createVendorService(input: $input) {
vendorServiceEdge {
mutation CreateThirdPartyService($input: CreateThirdPartyServiceInput!) {
createThirdPartyService(input: $input) {
thirdPartyServiceEdge {
node {
id
name
@@ -87,7 +87,7 @@ export async function execute(
`;
const input: Record<string, unknown> = {
vendorId,
thirdPartyId,
name,
};
if (description) input.description = description;

Some files were not shown because too many files have changed in this diff Show More