diff --git a/apps/console/src/components/form/ThirdPartiesMultiSelectField.tsx b/apps/console/src/components/form/ThirdPartiesMultiSelectField.tsx index 08f1814a8..4dd29a166 100644 --- a/apps/console/src/components/form/ThirdPartiesMultiSelectField.tsx +++ b/apps/console/src/components/form/ThirdPartiesMultiSelectField.tsx @@ -24,6 +24,7 @@ type ThirdParty = { id: string; name: string; websiteUrl: string | null | undefined; + firstLevel?: boolean; }; type Props = { @@ -66,7 +67,7 @@ function ThirdPartiesMultiSelectWithQuery( const thirdParties = useThirdParties(organizationId); const [isOpen, setIsOpen] = useState(false); - const allThirdParties = [...thirdParties]; + const allThirdParties: ThirdParty[] = [...thirdParties]; if (props.disabled) { selectedThirdParties.forEach((selectedThirdParty) => { if (!allThirdParties.find(v => v.id === selectedThirdParty.id)) { diff --git a/apps/console/src/hooks/graph/ThirdPartyGraph.ts b/apps/console/src/hooks/graph/ThirdPartyGraph.ts index e92fce314..32cee1d21 100644 --- a/apps/console/src/hooks/graph/ThirdPartyGraph.ts +++ b/apps/console/src/hooks/graph/ThirdPartyGraph.ts @@ -138,6 +138,7 @@ export const paginatedThirdPartiesFragment = graphql` after: { type: "CursorKey", defaultValue: null } before: { type: "CursorKey", defaultValue: null } last: { type: "Int", defaultValue: null } + filter: { type: "ThirdPartyFilter", defaultValue: { firstLevel: true } } ) { thirdParties( first: $first @@ -145,7 +146,8 @@ export const paginatedThirdPartiesFragment = graphql` last: $last before: $before orderBy: $order - ) @connection(key: "ThirdPartiesListQuery_thirdParties") { + filter: $filter + ) @connection(key: "ThirdPartiesListQuery_thirdParties", filters: ["filter"]) { __id edges { node { @@ -182,6 +184,7 @@ export const thirdPartyNodeQuery = graphql` ... on ThirdParty { name websiteUrl + firstLevel canAssess: permission(action: "core:thirdParty:assess") canUpdate: permission(action: "core:thirdParty:update") canDelete: permission(action: "core:thirdParty:delete") @@ -224,6 +227,7 @@ export const thirdPartiesSelectQuery = graphql` id name websiteUrl + firstLevel } } } diff --git a/apps/console/src/pages/organizations/third-parties/ThirdPartiesPage.tsx b/apps/console/src/pages/organizations/third-parties/ThirdPartiesPage.tsx index f3f8c614d..a887c97f5 100644 --- a/apps/console/src/pages/organizations/third-parties/ThirdPartiesPage.tsx +++ b/apps/console/src/pages/organizations/third-parties/ThirdPartiesPage.tsx @@ -26,12 +26,15 @@ import { IconUpload, PageHeader, RiskBadge, + TabItem, + Tabs, Tbody, Td, Th, Thead, Tr, } from "@probo/ui"; +import { useState, useTransition } from "react"; import { type PreloadedQuery, usePaginationFragment, @@ -76,9 +79,21 @@ export default function ThirdPartiesPage(props: Props) { const thirdParties = pagination.data.thirdParties?.edges.map(edge => edge.node); const connectionId = pagination.data.thirdParties.__id; + const [, startTransition] = useTransition(); + const [firstLevelFilter, setFirstLevelFilter] = useState(true); usePageTitle(__("Third parties")); + const handleFilterChange = (firstLevel: boolean | null) => { + setFirstLevelFilter(firstLevel); + startTransition(() => { + pagination.refetch( + { filter: firstLevel !== null ? { firstLevel } : {} }, + { fetchPolicy: "store-and-network" }, + ); + }); + }; + const hasAnyAction = thirdParties.some(({ canUpdate, canDelete }) => canUpdate || canDelete); @@ -129,6 +144,20 @@ export default function ThirdPartiesPage(props: Props) { )} + + handleFilterChange(true)} + > + {__("First Level")} + + handleFilterChange(null)} + > + {__("All")} + + diff --git a/apps/console/src/pages/organizations/third-parties/ThirdPartyDetailPage.tsx b/apps/console/src/pages/organizations/third-parties/ThirdPartyDetailPage.tsx index cb5e74cee..50547f773 100644 --- a/apps/console/src/pages/organizations/third-parties/ThirdPartyDetailPage.tsx +++ b/apps/console/src/pages/organizations/third-parties/ThirdPartyDetailPage.tsx @@ -16,6 +16,7 @@ import { faviconUrl } from "@probo/helpers"; import { useTranslate } from "@probo/i18n"; import { ActionDropdown, + Badge, Breadcrumb, Button, DropdownItem, @@ -91,7 +92,12 @@ export default function ThirdPartyDetailPage(props: Props) { className="shadow-mid rounded-2xl" /> )} -
{thirdParty.name}
+
+
{thirdParty.name}
+ + {thirdParty.firstLevel ? __("First Level") : __("Indirect")} + +
{thirdParty.canAssess && ( @@ -127,6 +133,9 @@ export default function ThirdPartyDetailPage(props: Props) { {__("Risk Assessment")} {__("Contacts")} {__("Services")} + + {__("Third Parties")} + diff --git a/apps/console/src/pages/organizations/third-parties/dialogs/AddChildThirdPartyDialog.tsx b/apps/console/src/pages/organizations/third-parties/dialogs/AddChildThirdPartyDialog.tsx new file mode 100644 index 000000000..1cf0fcb67 --- /dev/null +++ b/apps/console/src/pages/organizations/third-parties/dialogs/AddChildThirdPartyDialog.tsx @@ -0,0 +1,187 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// 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 { faviconUrl } from "@probo/helpers"; +import { useTranslate } from "@probo/i18n"; +import { + Avatar, + Combobox, + ComboboxItem, + Dialog, + DialogContent, + DialogFooter, + useDialogRef, +} from "@probo/ui"; +import { type ReactNode, Suspense, useCallback, useState } from "react"; +import { useMutation, useQueryLoader } from "react-relay"; +import { graphql } from "relay-runtime"; +import { useDebounceCallback } from "usehooks-ts"; + +import type { AddChildThirdPartyDialogCreateMappingMutation } from "#/__generated__/core/AddChildThirdPartyDialogCreateMappingMutation.graphql"; +import type { AddChildThirdPartyDialogCreateMutation } from "#/__generated__/core/AddChildThirdPartyDialogCreateMutation.graphql"; +import type { CommonThirdPartyComboboxQuery } from "#/__generated__/core/CommonThirdPartyComboboxQuery.graphql"; +import type { CreateThirdPartyInput } from "#/__generated__/core/ThirdPartyGraphCreateMutation.graphql"; +import { useThirdParties } from "#/hooks/graph/ThirdPartyGraph"; + +import { commonThirdPartiesQuery, CommonThirdPartyCombobox } from "./CommonThirdPartyCombobox"; + +const createMappingMutation = graphql` + mutation AddChildThirdPartyDialogCreateMappingMutation( + $input: CreateThirdPartyThirdPartyMappingInput! + $connections: [ID!]! + ) { + createThirdPartyThirdPartyMapping(input: $input) { + thirdPartyEdge @prependEdge(connections: $connections) { + node { + id + name + websiteUrl + category + } + } + } + } +`; + +const createThirdPartyMutation = graphql` + mutation AddChildThirdPartyDialogCreateMutation( + $input: CreateThirdPartyInput! + ) { + createThirdParty(input: $input) { + thirdPartyEdge { + node { + id + } + } + } + } +`; + +type Props = { + children: ReactNode; + parentThirdPartyId: string; + organizationId: string; + connectionId: string; + existingChildIds: string[]; +}; + +export function AddChildThirdPartyDialog({ + children, + parentThirdPartyId, + organizationId, + connectionId, + existingChildIds, +}: Props) { + const { __ } = useTranslate(); + const dialogRef = useDialogRef(); + const thirdParties = useThirdParties(organizationId); + const [createMapping] = useMutation(createMappingMutation); + const [createThirdParty] = useMutation(createThirdPartyMutation); + const [searchQuery, setSearchQuery] = useState(""); + const [queryRef, loadQuery] = useQueryLoader(commonThirdPartiesQuery); + + const debouncedLoadQuery = useDebounceCallback( + useCallback( + (name: string) => { + loadQuery({ name }); + }, + [loadQuery], + ), + 500, + ); + + const handleSearch = (name: string) => { + setSearchQuery(name); + const trimmed = name.trim(); + if (trimmed.length >= 2) { + debouncedLoadQuery(trimmed); + } + }; + + const existingThirdParties = thirdParties.filter( + tp => + tp.id !== parentThirdPartyId + && !existingChildIds.includes(tp.id) + && tp.name.toLowerCase().includes(searchQuery.toLowerCase()), + ); + + const handleSelectExisting = (childId: string) => { + createMapping({ + variables: { + input: { + parentThirdPartyId, + childThirdPartyId: childId, + }, + connections: [connectionId], + }, + onCompleted: () => { + dialogRef.current?.close(); + }, + }); + }; + + const handleSelectCommon = (common: Omit) => { + createThirdParty({ + variables: { + input: { + ...common, + organizationId, + firstLevel: false, + }, + }, + onCompleted: (response) => { + const newId = response.createThirdParty.thirdPartyEdge.node.id; + createMapping({ + variables: { + input: { + parentThirdPartyId, + childThirdPartyId: newId, + }, + connections: [connectionId], + }, + onCompleted: () => { + dialogRef.current?.close(); + }, + }); + }, + }); + }; + + const existingNames = new Set(thirdParties.map(tp => tp.name.toLowerCase())); + + return ( + + + + {existingThirdParties.map(tp => ( + handleSelectExisting(tp.id)}> + + {tp.name} + + ))} + {searchQuery.trim().length >= 2 && queryRef && ( + + + + )} + + + + + ); +} diff --git a/apps/console/src/pages/organizations/third-parties/dialogs/CommonThirdPartyCombobox.tsx b/apps/console/src/pages/organizations/third-parties/dialogs/CommonThirdPartyCombobox.tsx index 292737d44..af1e5946c 100644 --- a/apps/console/src/pages/organizations/third-parties/dialogs/CommonThirdPartyCombobox.tsx +++ b/apps/console/src/pages/organizations/third-parties/dialogs/CommonThirdPartyCombobox.tsx @@ -27,7 +27,7 @@ import type { CreateThirdPartyInput } from "#/__generated__/core/ThirdPartyGraph export type CommonThirdPartyRef = CommonThirdPartyCombobox_commonThirdParty$data; -const commonThirdPartyFragment = graphql` +export const commonThirdPartyFragment = graphql` fragment CommonThirdPartyCombobox_commonThirdParty on CommonThirdParty @inline { name logoUrl @@ -57,20 +57,44 @@ export const commonThirdPartiesQuery = graphql` } `; +function toCreateInput(tp: CommonThirdPartyRef): Omit { + return { + name: tp.name, + headquarterAddress: tp.headquarterAddress, + legalName: tp.legalName, + websiteUrl: tp.websiteUrl, + category: tp.category, + privacyPolicyUrl: tp.privacyPolicyUrl, + serviceLevelAgreementUrl: tp.serviceLevelAgreementUrl, + dataProcessingAgreementUrl: tp.dataProcessingAgreementUrl, + certifications: tp.certifications, + securityPageUrl: tp.securityPageUrl, + trustPageUrl: tp.trustPageUrl, + statusPageUrl: tp.statusPageUrl, + termsOfServiceUrl: tp.termsOfServiceUrl, + }; +} + interface CommonThirdPartyComboboxProps { queryRef: PreloadedQuery; - onSelect: (thridParty: Omit) => void; + onSelect: (thirdParty: Omit) => void; + excludeNames?: Set; } export function CommonThirdPartyCombobox({ queryRef, onSelect, + excludeNames, }: CommonThirdPartyComboboxProps) { const data = usePreloadedQuery(commonThirdPartiesQuery, queryRef); + const items = excludeNames + ? data.commonThirdParties.filter(tp => !excludeNames.has(tp.name.toLowerCase())) + : data.commonThirdParties; + return ( <> - {data.commonThirdParties.map(thirdParty => ( + {items.map(thirdParty => ( { @@ -78,21 +102,7 @@ export function CommonThirdPartyCombobox({ commonThirdPartyFragment, thirdParty, ); - onSelect({ - name: tp.name, - headquarterAddress: tp.headquarterAddress, - legalName: tp.legalName, - websiteUrl: tp.websiteUrl, - category: tp.category, - privacyPolicyUrl: tp.privacyPolicyUrl, - serviceLevelAgreementUrl: tp.serviceLevelAgreementUrl, - dataProcessingAgreementUrl: tp.dataProcessingAgreementUrl, - certifications: tp.certifications, - securityPageUrl: tp.securityPageUrl, - trustPageUrl: tp.trustPageUrl, - statusPageUrl: tp.statusPageUrl, - termsOfServiceUrl: tp.termsOfServiceUrl, - }); + onSelect(toCreateInput(tp)); }} > (promoteMutation); + const thirdParties = useThirdParties(organizationId); const dialogRef = useDialogRef(); const [searchQuery, setSearchQuery] = useState(""); const [queryRef, loadQuery] = useQueryLoader(commonThirdPartiesQuery); + const nonFirstLevelByName = new Map( + thirdParties + .filter(tp => !tp.firstLevel) + .map(tp => [tp.name.toLowerCase(), tp]), + ); + + const existingNames = new Set(thirdParties.map(tp => tp.name.toLowerCase())); + const onSelect = async (thirdParty: Omit | string) => { + const name = typeof thirdParty === "string" ? thirdParty : thirdParty.name; + const existing = nonFirstLevelByName.get(name.toLowerCase()); + + if (existing) { + promoteThirdParty({ + variables: { + input: { id: existing.id, firstLevel: true }, + }, + updater: (store) => { + const payload = store.getRootField("updateThirdParty"); + const node = payload?.getLinkedRecord("thirdParty"); + if (!node) return; + + const connectionRecord = store.get(connection); + if (!connectionRecord) return; + + const edge = ConnectionHandler.createEdge(store, connectionRecord, node, "ThirdPartyEdge"); + ConnectionHandler.insertEdgeBefore(connectionRecord, edge); + }, + onCompleted: () => { + dialogRef.current?.close(); + }, + }); + return; + } + const input = typeof thirdParty === "string" ? { @@ -98,10 +149,26 @@ export function CreateThirdPartyDialog({ + {searchQuery.trim().length >= 2 && ( + <> + {thirdParties + .filter(tp => + !tp.firstLevel + && tp.name.toLowerCase().includes(searchQuery.toLowerCase()), + ) + .map(tp => ( + void onSelect(tp.name)}> + + {tp.name} + + ))} + + )} {searchQuery.trim().length >= 2 && queryRef && ( void onSelect(thirdPartyRef)} /> diff --git a/apps/console/src/pages/organizations/third-parties/third-parties/ThirdPartyThirdPartiesPage.tsx b/apps/console/src/pages/organizations/third-parties/third-parties/ThirdPartyThirdPartiesPage.tsx new file mode 100644 index 000000000..3fb7efb08 --- /dev/null +++ b/apps/console/src/pages/organizations/third-parties/third-parties/ThirdPartyThirdPartiesPage.tsx @@ -0,0 +1,237 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// 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 { faviconUrl, formatDate } from "@probo/helpers"; +import { usePageTitle } from "@probo/hooks"; +import { useTranslate } from "@probo/i18n"; +import { + Avatar, + Button, + IconPlusLarge, + IconTrashCan, + PageHeader, + RiskBadge, + Tbody, + Td, + Th, + Thead, + Tr, + useConfirm, +} from "@probo/ui"; +import type { ComponentProps } from "react"; +import { + type PreloadedQuery, + useMutation, + usePaginationFragment, + usePreloadedQuery, +} from "react-relay"; +import { graphql } from "relay-runtime"; + +import type { ThirdPartyThirdPartiesPageDeleteMappingMutation } from "#/__generated__/core/ThirdPartyThirdPartiesPageDeleteMappingMutation.graphql"; +import type { ThirdPartyThirdPartiesPageFragment$key } from "#/__generated__/core/ThirdPartyThirdPartiesPageFragment.graphql"; +import type { ThirdPartyThirdPartiesPagePaginationQuery } from "#/__generated__/core/ThirdPartyThirdPartiesPagePaginationQuery.graphql"; +import type { ThirdPartyThirdPartiesPageQuery } from "#/__generated__/core/ThirdPartyThirdPartiesPageQuery.graphql"; +import { SortableTable, SortableTh } from "#/components/SortableTable"; +import { useOrganizationId } from "#/hooks/useOrganizationId"; + +import { AddChildThirdPartyDialog } from "../dialogs/AddChildThirdPartyDialog"; + +export const thirdPartyThirdPartiesPageQuery = graphql` + query ThirdPartyThirdPartiesPageQuery($thirdPartyId: ID!) { + node(id: $thirdPartyId) { + __typename + ... on ThirdParty { + id + name + canUpdate: permission(action: "core:thirdParty:update") + ...ThirdPartyThirdPartiesPageFragment + } + } + } +`; + +const paginatedFragment = graphql` + fragment ThirdPartyThirdPartiesPageFragment on ThirdParty + @refetchable(queryName: "ThirdPartyThirdPartiesPagePaginationQuery") + @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 } + ) { + childThirdParties( + first: $first + after: $after + last: $last + before: $before + orderBy: $order + ) @connection(key: "ThirdPartyThirdPartiesPageFragment_childThirdParties", filters: []) { + __id + edges { + node { + id + name + websiteUrl + riskAssessments( + first: 1 + orderBy: { direction: DESC, field: CREATED_AT } + ) { + edges { + node { + id + createdAt + dataSensitivity + businessImpact + } + } + } + } + } + } + } +`; + +const deleteMappingMutation = graphql` + mutation ThirdPartyThirdPartiesPageDeleteMappingMutation( + $input: DeleteThirdPartyThirdPartyMappingInput! + $connections: [ID!]! + ) { + deleteThirdPartyThirdPartyMapping(input: $input) { + removedThirdPartyId @deleteEdge(connections: $connections) + } + } +`; + +interface Props { + queryRef: PreloadedQuery; +} + +export default function ThirdPartyThirdPartiesPage({ queryRef }: Props) { + const { node } = usePreloadedQuery(thirdPartyThirdPartiesPageQuery, queryRef); + const thirdParty = node.__typename === "ThirdParty" ? node : null; + const { __ } = useTranslate(); + const organizationId = useOrganizationId(); + const confirm = useConfirm(); + + const pagination = usePaginationFragment< + ThirdPartyThirdPartiesPagePaginationQuery, + ThirdPartyThirdPartiesPageFragment$key + >(paginatedFragment, thirdParty as ThirdPartyThirdPartiesPageFragment$key); + const [deleteMapping] = useMutation(deleteMappingMutation); + + usePageTitle((thirdParty?.name ?? "") + " - " + __("Third Parties")); + + if (!thirdParty) { + return null; + } + + const connectionId = pagination.data.childThirdParties.__id; + const childThirdParties = pagination.data.childThirdParties.edges.map(edge => edge.node); + + const handleRemove = (childId: string, childName: string) => { + confirm( + () => + new Promise((resolve, reject) => { + deleteMapping({ + variables: { + input: { + parentThirdPartyId: thirdParty.id, + childThirdPartyId: childId, + }, + connections: [connectionId], + }, + onCompleted: () => resolve(), + onError: err => reject(err), + }); + }), + { + message: `${__("Remove")} "${childName}" ${__("from this third party?")}`, + }, + ); + }; + + return ( +
+ + {thirdParty.canUpdate && ( + c.id)} + > + + + )} + + + ["refetch"]} + > + + + {__("Third party")} + {__("Accessed At")} + {__("Data Risk")} + {__("Business Risk")} + + + + + {childThirdParties.map((child) => { + const latestAssessment = child.riskAssessments?.edges[0]?.node; + + return ( + + +
+ +
{child.name}
+
+ + + {latestAssessment?.createdAt + ? formatDate(latestAssessment.createdAt) + : __("Not assessed")} + + + + + + + + + {thirdParty.canUpdate && ( +
+ ); +} diff --git a/apps/console/src/pages/organizations/third-parties/third-parties/ThirdPartyThirdPartiesPageLoader.tsx b/apps/console/src/pages/organizations/third-parties/third-parties/ThirdPartyThirdPartiesPageLoader.tsx new file mode 100644 index 000000000..11920971a --- /dev/null +++ b/apps/console/src/pages/organizations/third-parties/third-parties/ThirdPartyThirdPartiesPageLoader.tsx @@ -0,0 +1,43 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// 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 { Suspense, useEffect } from "react"; +import { useQueryLoader } from "react-relay"; +import { useParams } from "react-router"; + +import type { ThirdPartyThirdPartiesPageQuery } from "#/__generated__/core/ThirdPartyThirdPartiesPageQuery.graphql"; +import { PageSkeleton } from "#/components/skeletons/PageSkeleton"; + +import ThirdPartyThirdPartiesPage, { thirdPartyThirdPartiesPageQuery } from "./ThirdPartyThirdPartiesPage"; + +export default function ThirdPartyThirdPartiesPageLoader() { + const { thirdPartyId } = useParams<{ thirdPartyId: string }>(); + const [queryRef, loadQuery] = useQueryLoader(thirdPartyThirdPartiesPageQuery); + + useEffect(() => { + if (thirdPartyId) { + loadQuery({ thirdPartyId }); + } + }, [loadQuery, thirdPartyId]); + + if (!queryRef) { + return ; + } + + return ( + }> + + + ); +} diff --git a/apps/console/src/routes/thirdPartyRoutes.ts b/apps/console/src/routes/thirdPartyRoutes.ts index 1996f3af1..36c23c9e9 100644 --- a/apps/console/src/routes/thirdPartyRoutes.ts +++ b/apps/console/src/routes/thirdPartyRoutes.ts @@ -97,6 +97,14 @@ export const thirdPartyRoutes = [ () => import("../pages/organizations/third-parties/tabs/ThirdPartyServicesTab"), ), }, + { + path: "third-parties", + Fallback: LinkCardSkeleton, + Component: lazy( + () => + import("../pages/organizations/third-parties/third-parties/ThirdPartyThirdPartiesPageLoader"), + ), + }, ], }, ] satisfies AppRoute[]; diff --git a/e2e/console/third_party_relation_test.go b/e2e/console/third_party_relation_test.go new file mode 100644 index 000000000..02d487a4b --- /dev/null +++ b/e2e/console/third_party_relation_test.go @@ -0,0 +1,508 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// 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 console_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" +) + +func TestThirdPartyRelation_AddAndList(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + parentID := factory.NewThirdParty(owner).WithName("Parent Corp").Create() + childID := factory.NewThirdParty(owner).WithName("Child Corp").Create() + + addRelation(t, owner, parentID, childID) + + t.Run("list child third parties", func(t *testing.T) { + t.Parallel() + + const query = ` + query($id: ID!) { + node(id: $id) { + ... on ThirdParty { + childThirdParties(first: 10) { + totalCount + edges { + node { + id + name + } + } + } + } + } + } + ` + + var result struct { + Node struct { + ChildThirdParties struct { + TotalCount int `json:"totalCount"` + Edges []struct { + Node struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"node"` + } `json:"edges"` + } `json:"childThirdParties"` + } `json:"node"` + } + + err := owner.Execute(query, map[string]any{"id": parentID}, &result) + + require.NoError(t, err) + assert.Equal(t, 1, result.Node.ChildThirdParties.TotalCount) + require.Len(t, result.Node.ChildThirdParties.Edges, 1) + assert.Equal(t, childID, result.Node.ChildThirdParties.Edges[0].Node.ID) + }) +} + +func TestThirdPartyRelation_Remove(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + parentID := factory.NewThirdParty(owner).WithName("Parent Remove").Create() + childID := factory.NewThirdParty(owner).WithName("Child Remove").Create() + + addRelation(t, owner, parentID, childID) + + const removeQuery = ` + mutation($input: DeleteThirdPartyThirdPartyMappingInput!) { + deleteThirdPartyThirdPartyMapping(input: $input) { + removedThirdPartyId + } + } + ` + + var result struct { + DeleteThirdPartyThirdPartyMapping struct { + RemovedThirdPartyID string `json:"removedThirdPartyId"` + } `json:"deleteThirdPartyThirdPartyMapping"` + } + + err := owner.Execute(removeQuery, map[string]any{ + "input": map[string]any{ + "parentThirdPartyId": parentID, + "childThirdPartyId": childID, + }, + }, &result) + + require.NoError(t, err) + assert.Equal(t, childID, result.DeleteThirdPartyThirdPartyMapping.RemovedThirdPartyID) + + count := countChildThirdParties(t, owner, parentID) + assert.Equal(t, 0, count) +} + +func TestThirdPartyRelation_Bidirectional(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + aID := factory.NewThirdParty(owner).WithName("Company A").Create() + bID := factory.NewThirdParty(owner).WithName("Company B").Create() + + addRelation(t, owner, aID, bID) + addRelation(t, owner, bID, aID) + + t.Run("A has B as child", func(t *testing.T) { + t.Parallel() + count := countChildThirdParties(t, owner, aID) + assert.Equal(t, 1, count) + }) + + t.Run("B has A as child", func(t *testing.T) { + t.Parallel() + count := countChildThirdParties(t, owner, bID) + assert.Equal(t, 1, count) + }) +} + +func TestThirdPartyRelation_Idempotent(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + parentID := factory.NewThirdParty(owner).WithName("Idempotent Parent").Create() + childID := factory.NewThirdParty(owner).WithName("Idempotent Child").Create() + + addRelation(t, owner, parentID, childID) + addRelation(t, owner, parentID, childID) + + count := countChildThirdParties(t, owner, parentID) + assert.Equal(t, 1, count) +} + +func TestThirdPartyRelation_CascadeOnDelete(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + parentID := factory.NewThirdParty(owner).WithName("Cascade Parent").Create() + childID := factory.NewThirdParty(owner).WithName("Cascade Child").Create() + + addRelation(t, owner, parentID, childID) + + const deleteQuery = ` + mutation($input: DeleteThirdPartyInput!) { + deleteThirdParty(input: $input) { + deletedThirdPartyId + } + } + ` + + var result struct { + DeleteThirdParty struct { + DeletedThirdPartyID string `json:"deletedThirdPartyId"` + } `json:"deleteThirdParty"` + } + + err := owner.Execute(deleteQuery, map[string]any{ + "input": map[string]any{ + "thirdPartyId": childID, + }, + }, &result) + require.NoError(t, err) + + count := countChildThirdParties(t, owner, parentID) + assert.Equal(t, 0, count) +} + +func TestThirdPartyRelation_Authorization(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner) + + parentID := factory.NewThirdParty(owner).WithName("Auth Parent").Create() + childID := factory.NewThirdParty(owner).WithName("Auth Child").Create() + + t.Run("viewer cannot add relation", func(t *testing.T) { + t.Parallel() + + const query = ` + mutation($input: CreateThirdPartyThirdPartyMappingInput!) { + createThirdPartyThirdPartyMapping(input: $input) { + thirdPartyEdge { + node { id } + } + } + } + ` + + _, err := viewer.Do(query, map[string]any{ + "input": map[string]any{ + "parentThirdPartyId": parentID, + "childThirdPartyId": childID, + }, + }) + testutil.RequireForbiddenError(t, err) + }) + + t.Run("viewer cannot remove relation", func(t *testing.T) { + t.Parallel() + + addRelation(t, owner, parentID, childID) + + const query = ` + mutation($input: DeleteThirdPartyThirdPartyMappingInput!) { + deleteThirdPartyThirdPartyMapping(input: $input) { + removedThirdPartyId + } + } + ` + + _, err := viewer.Do(query, map[string]any{ + "input": map[string]any{ + "parentThirdPartyId": parentID, + "childThirdPartyId": childID, + }, + }) + testutil.RequireForbiddenError(t, err) + }) + + t.Run("viewer can list child third parties", func(t *testing.T) { + t.Parallel() + + count := countChildThirdParties(t, viewer, parentID) + assert.GreaterOrEqual(t, count, 0) + }) +} + +func TestThirdPartyRelation_TenantIsolation(t *testing.T) { + t.Parallel() + + org1Owner := testutil.NewClient(t, testutil.RoleOwner) + org2Owner := testutil.NewClient(t, testutil.RoleOwner) + + parentID := factory.NewThirdParty(org1Owner).WithName("Org1 Parent").Create() + childID := factory.NewThirdParty(org1Owner).WithName("Org1 Child").Create() + org2ChildID := factory.NewThirdParty(org2Owner).WithName("Org2 Child").Create() + + addRelation(t, org1Owner, parentID, childID) + + t.Run("cannot add cross-org relation", func(t *testing.T) { + t.Parallel() + + const query = ` + mutation($input: CreateThirdPartyThirdPartyMappingInput!) { + createThirdPartyThirdPartyMapping(input: $input) { + thirdPartyEdge { + node { id } + } + } + } + ` + + _, err := org1Owner.Do(query, map[string]any{ + "input": map[string]any{ + "parentThirdPartyId": parentID, + "childThirdPartyId": org2ChildID, + }, + }) + require.Error(t, err) + }) + + t.Run("cannot list children of other org third party", func(t *testing.T) { + t.Parallel() + + const query = ` + query($id: ID!) { + node(id: $id) { + ... on ThirdParty { + childThirdParties(first: 10) { + totalCount + } + } + } + } + ` + + var result struct { + Node *struct { + ChildThirdParties *struct { + TotalCount int `json:"totalCount"` + } `json:"childThirdParties"` + } `json:"node"` + } + + err := org2Owner.Execute(query, map[string]any{"id": parentID}, &result) + + nodeInaccessible := err != nil || result.Node == nil || result.Node.ChildThirdParties == nil + emptyResult := result.Node != nil && result.Node.ChildThirdParties != nil && result.Node.ChildThirdParties.TotalCount == 0 + assert.True(t, nodeInaccessible || emptyResult, "expected either inaccessible node or zero children") + }) +} + +func TestThirdParty_DirectFilter(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + factory.NewThirdParty(owner).WithName("Direct TP").Create() + + const createNonDirect = ` + mutation($input: CreateThirdPartyInput!) { + createThirdParty(input: $input) { + thirdPartyEdge { + node { + id + firstLevel + } + } + } + } + ` + + var createResult struct { + CreateThirdParty struct { + ThirdPartyEdge struct { + Node struct { + ID string `json:"id"` + FirstLevel bool `json:"firstLevel"` + } `json:"node"` + } `json:"thirdPartyEdge"` + } `json:"createThirdParty"` + } + + err := owner.Execute(createNonDirect, map[string]any{ + "input": map[string]any{ + "organizationId": owner.GetOrganizationID().String(), + "name": factory.SafeName("NonDirect TP"), + "firstLevel": false, + }, + }, &createResult) + require.NoError(t, err) + assert.False(t, createResult.CreateThirdParty.ThirdPartyEdge.Node.FirstLevel) + + t.Run("filter firstLevel only", func(t *testing.T) { + t.Parallel() + + const query = ` + query($orgId: ID!) { + node(id: $orgId) { + ... on Organization { + thirdParties(first: 100, filter: { firstLevel: true }) { + edges { + node { + id + firstLevel + } + } + } + } + } + } + ` + + var result struct { + Node struct { + ThirdParties struct { + Edges []struct { + Node struct { + ID string `json:"id"` + FirstLevel bool `json:"firstLevel"` + } `json:"node"` + } `json:"edges"` + } `json:"thirdParties"` + } `json:"node"` + } + + err := owner.Execute(query, map[string]any{ + "orgId": owner.GetOrganizationID().String(), + }, &result) + require.NoError(t, err) + + for _, edge := range result.Node.ThirdParties.Edges { + assert.True(t, edge.Node.FirstLevel, "expected all third parties to be firstLevel when filtering direct=true") + } + }) + + t.Run("filter all", func(t *testing.T) { + t.Parallel() + + const query = ` + query($orgId: ID!) { + node(id: $orgId) { + ... on Organization { + thirdParties(first: 100) { + edges { + node { + id + firstLevel + } + } + } + } + } + } + ` + + var result struct { + Node struct { + ThirdParties struct { + Edges []struct { + Node struct { + ID string `json:"id"` + FirstLevel bool `json:"firstLevel"` + } `json:"node"` + } `json:"edges"` + } `json:"thirdParties"` + } `json:"node"` + } + + err := owner.Execute(query, map[string]any{ + "orgId": owner.GetOrganizationID().String(), + }, &result) + require.NoError(t, err) + + hasFirstLevel := false + hasNonFirstLevel := false + + for _, edge := range result.Node.ThirdParties.Edges { + if edge.Node.FirstLevel { + hasFirstLevel = true + } else { + hasNonFirstLevel = true + } + } + + assert.True(t, hasFirstLevel, "expected at least one first-level third party") + assert.True(t, hasNonFirstLevel, "expected at least one non-first-level third party") + }) +} + +func addRelation(t *testing.T, c *testutil.Client, parentID, childID string) { + t.Helper() + + const query = ` + mutation($input: CreateThirdPartyThirdPartyMappingInput!) { + createThirdPartyThirdPartyMapping(input: $input) { + thirdPartyEdge { + node { id } + } + } + } + ` + + var result struct { + CreateThirdPartyThirdPartyMapping struct { + ThirdPartyEdge struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"thirdPartyEdge"` + } `json:"createThirdPartyThirdPartyMapping"` + } + + err := c.Execute(query, map[string]any{ + "input": map[string]any{ + "parentThirdPartyId": parentID, + "childThirdPartyId": childID, + }, + }, &result) + require.NoError(t, err) +} + +func countChildThirdParties(t *testing.T, c *testutil.Client, parentID string) int { + t.Helper() + + const query = ` + query($id: ID!) { + node(id: $id) { + ... on ThirdParty { + childThirdParties(first: 1) { + totalCount + } + } + } + } + ` + + var result struct { + Node struct { + ChildThirdParties struct { + TotalCount int `json:"totalCount"` + } `json:"childThirdParties"` + } `json:"node"` + } + + err := c.Execute(query, map[string]any{"id": parentID}, &result) + require.NoError(t, err) + + return result.Node.ChildThirdParties.TotalCount +} diff --git a/packages/n8n-node/nodes/Probo/actions/thirdParty/create.operation.ts b/packages/n8n-node/nodes/Probo/actions/thirdParty/create.operation.ts index e3ff76ca8..54dc6b9e7 100644 --- a/packages/n8n-node/nodes/Probo/actions/thirdParty/create.operation.ts +++ b/packages/n8n-node/nodes/Probo/actions/thirdParty/create.operation.ts @@ -1,4 +1,4 @@ -// Copyright (c) 2025 Probo Inc . +// Copyright (c) 2025-2026 Probo Inc . // // Permission to use, copy, modify, and/or distribute this software for any // purpose with or without fee is hereby granted, provided that the above @@ -183,6 +183,13 @@ export const description: INodeProperties[] = [ type: 'string', default: '', }, + { + displayName: 'Root', + name: 'firstLevel', + type: 'boolean', + default: false, + description: 'Whether this is a first-level third party', + }, { displayName: 'Security Page URL', name: 'securityPageUrl', @@ -249,6 +256,7 @@ export async function execute( trustPageUrl?: string; certifications?: string; countries?: string; + firstLevel?: boolean; }; const query = ` @@ -275,6 +283,7 @@ export async function execute( certifications countries showOnTrustCenter + firstLevel createdAt updatedAt } @@ -303,6 +312,7 @@ export async function execute( if (additionalFields.subprocessorsListUrl) input.subprocessorsListUrl = additionalFields.subprocessorsListUrl; if (additionalFields.securityPageUrl) input.securityPageUrl = additionalFields.securityPageUrl; if (additionalFields.trustPageUrl) input.trustPageUrl = additionalFields.trustPageUrl; + if (additionalFields.firstLevel !== undefined) input.firstLevel = additionalFields.firstLevel; if (additionalFields.certifications) { input.certifications = additionalFields.certifications.split(',').map((c) => c.trim()).filter(Boolean); } diff --git a/packages/n8n-node/nodes/Probo/actions/thirdParty/getAll.operation.ts b/packages/n8n-node/nodes/Probo/actions/thirdParty/getAll.operation.ts index 87afdd073..df7500d76 100644 --- a/packages/n8n-node/nodes/Probo/actions/thirdParty/getAll.operation.ts +++ b/packages/n8n-node/nodes/Probo/actions/thirdParty/getAll.operation.ts @@ -1,4 +1,4 @@ -// Copyright (c) 2025 Probo Inc . +// Copyright (c) 2025-2026 Probo Inc . // // Permission to use, copy, modify, and/or distribute this software for any // purpose with or without fee is hereby granted, provided that the above @@ -73,6 +73,13 @@ export const description: INodeProperties[] = [ }, }, options: [ + { + displayName: 'Filter by Root', + name: 'filterFirstLevel', + type: 'boolean', + default: false, + description: 'Whether to filter by first-level third parties only', + }, { displayName: 'Include Organization', name: 'includeOrganization', @@ -106,6 +113,7 @@ export async function execute( const returnAll = this.getNodeParameter('returnAll', itemIndex) as boolean; const limit = this.getNodeParameter('limit', itemIndex, 50) as number; const options = this.getNodeParameter('options', itemIndex, {}) as { + filterFirstLevel?: boolean; includeOrganization?: boolean; includeBusinessOwner?: boolean; includeSecurityOwner?: boolean; @@ -134,11 +142,14 @@ export async function execute( }` : ''; + const filterVariable = options.filterFirstLevel !== undefined ? ', $filter: ThirdPartyFilter' : ''; + const filterArgument = options.filterFirstLevel !== undefined ? ', filter: $filter' : ''; + const query = ` - query GetThirdParties($organizationId: ID!, $first: Int, $after: CursorKey) { + query GetThirdParties($organizationId: ID!, $first: Int, $after: CursorKey${filterVariable}) { node(id: $organizationId) { ... on Organization { - thirdParties(first: $first, after: $after) { + thirdParties(first: $first, after: $after${filterArgument}) { edges { node { id @@ -160,6 +171,7 @@ export async function execute( certifications countries showOnTrustCenter + firstLevel ${organizationFragment} ${businessOwnerFragment} ${securityOwnerFragment} @@ -177,10 +189,15 @@ export async function execute( } `; + const variables: IDataObject = { organizationId }; + if (options.filterFirstLevel) { + variables.filter = { firstLevel: true }; + } + const thirdParties = await proboApiRequestAllItems.call( this, query, - { organizationId }, + variables, (response) => { const data = response?.data as IDataObject | undefined; const node = data?.node as IDataObject | undefined; diff --git a/packages/n8n-node/nodes/Probo/actions/thirdParty/index.ts b/packages/n8n-node/nodes/Probo/actions/thirdParty/index.ts index d9447a5d9..2f4fdedf9 100644 --- a/packages/n8n-node/nodes/Probo/actions/thirdParty/index.ts +++ b/packages/n8n-node/nodes/Probo/actions/thirdParty/index.ts @@ -39,6 +39,9 @@ import * as updateBusinessAssociateAgreementOp from './updateBusinessAssociateAg import * as getDataPrivacyAgreementOp from './getDataPrivacyAgreement.operation'; import * as deleteDataPrivacyAgreementOp from './deleteDataPrivacyAgreement.operation'; import * as updateDataPrivacyAgreementOp from './updateDataPrivacyAgreement.operation'; +import * as linkThirdPartyOp from './linkThirdParty.operation'; +import * as unlinkThirdPartyOp from './unlinkThirdParty.operation'; +import * as listChildThirdPartiesOp from './listChildThirdParties.operation'; import * as publishOp from './publish.operation'; export const description: INodeProperties[] = [ @@ -143,6 +146,12 @@ export const description: INodeProperties[] = [ description: 'Get many third parties', action: 'Get many third parties', }, + { + name: 'Get Many Child Third Parties', + value: 'listChildThirdParties', + description: 'Get child third parties linked to a parent', + action: 'Get many child third parties', + }, { name: 'Get Many Compliance Reports', value: 'getAllComplianceReports', @@ -179,12 +188,24 @@ export const description: INodeProperties[] = [ description: 'Get a third party service', action: 'Get a third party service', }, + { + name: 'Link Third Party', + value: 'linkThirdParty', + description: 'Link a child third party to a parent third party', + action: 'Link a child third party', + }, { name: 'Publish List', value: 'publish', description: 'Publish the third party register as a document version', action: 'Publish the third party register', }, + { + name: 'Unlink Third Party', + value: 'unlinkThirdParty', + description: 'Unlink a child third party from a parent third party', + action: 'Unlink a child third party', + }, { name: 'Update', value: 'update', @@ -244,6 +265,9 @@ export const description: INodeProperties[] = [ ...getDataPrivacyAgreementOp.description, ...deleteDataPrivacyAgreementOp.description, ...updateDataPrivacyAgreementOp.description, + ...linkThirdPartyOp.description, + ...unlinkThirdPartyOp.description, + ...listChildThirdPartiesOp.description, ...publishOp.description, ]; @@ -274,5 +298,8 @@ export { getDataPrivacyAgreementOp as getDataPrivacyAgreement, deleteDataPrivacyAgreementOp as deleteDataPrivacyAgreement, updateDataPrivacyAgreementOp as updateDataPrivacyAgreement, + linkThirdPartyOp as linkThirdParty, + unlinkThirdPartyOp as unlinkThirdParty, + listChildThirdPartiesOp as listChildThirdParties, publishOp as publish, }; diff --git a/packages/n8n-node/nodes/Probo/actions/thirdParty/linkThirdParty.operation.ts b/packages/n8n-node/nodes/Probo/actions/thirdParty/linkThirdParty.operation.ts new file mode 100644 index 000000000..16380b45a --- /dev/null +++ b/packages/n8n-node/nodes/Probo/actions/thirdParty/linkThirdParty.operation.ts @@ -0,0 +1,75 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// 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 type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; +import { proboApiRequest } from '../../GenericFunctions'; + +export const description: INodeProperties[] = [ + { + displayName: 'Parent Third Party ID', + name: 'parentThirdPartyId', + type: 'string', + displayOptions: { + show: { + resource: ['thirdParty'], + operation: ['linkThirdParty'], + }, + }, + default: '', + description: 'The ID of the parent third party', + required: true, + }, + { + displayName: 'Child Third Party ID', + name: 'childThirdPartyId', + type: 'string', + displayOptions: { + show: { + resource: ['thirdParty'], + operation: ['linkThirdParty'], + }, + }, + default: '', + description: 'The ID of the child third party to link', + required: true, + }, +]; + +export async function execute( + this: IExecuteFunctions, + itemIndex: number, +): Promise { + const parentThirdPartyId = this.getNodeParameter('parentThirdPartyId', itemIndex) as string; + const childThirdPartyId = this.getNodeParameter('childThirdPartyId', itemIndex) as string; + + const query = ` + mutation CreateThirdPartyThirdPartyMapping($input: CreateThirdPartyThirdPartyMappingInput!) { + createThirdPartyThirdPartyMapping(input: $input) { + thirdPartyEdge { + node { + id + name + } + } + } + } + `; + + const responseData = await proboApiRequest.call(this, query, { input: { parentThirdPartyId, childThirdPartyId } }); + + return { + json: responseData, + pairedItem: { item: itemIndex }, + }; +} diff --git a/packages/n8n-node/nodes/Probo/actions/thirdParty/listChildThirdParties.operation.ts b/packages/n8n-node/nodes/Probo/actions/thirdParty/listChildThirdParties.operation.ts new file mode 100644 index 000000000..08dc25c92 --- /dev/null +++ b/packages/n8n-node/nodes/Probo/actions/thirdParty/listChildThirdParties.operation.ts @@ -0,0 +1,118 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// 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 type { INodeProperties, IExecuteFunctions, INodeExecutionData, IDataObject } from 'n8n-workflow'; +import { proboApiRequestAllItems } from '../../GenericFunctions'; + +export const description: INodeProperties[] = [ + { + displayName: 'Third Party ID', + name: 'thirdPartyId', + type: 'string', + displayOptions: { + show: { + resource: ['thirdParty'], + operation: ['listChildThirdParties'], + }, + }, + default: '', + description: 'The ID of the parent third party', + required: true, + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + resource: ['thirdParty'], + operation: ['listChildThirdParties'], + }, + }, + default: false, + description: 'Whether to return all results or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + resource: ['thirdParty'], + operation: ['listChildThirdParties'], + returnAll: [false], + }, + }, + typeOptions: { + minValue: 1, + }, + default: 50, + description: 'Max number of results to return', + }, +]; + +export async function execute( + this: IExecuteFunctions, + itemIndex: number, +): Promise { + const thirdPartyId = this.getNodeParameter('thirdPartyId', itemIndex) as string; + const returnAll = this.getNodeParameter('returnAll', itemIndex) as boolean; + const limit = this.getNodeParameter('limit', itemIndex, 50) as number; + + const query = ` + query GetChildThirdParties($thirdPartyId: ID!, $first: Int, $after: CursorKey) { + node(id: $thirdPartyId) { + ... on ThirdParty { + childThirdParties(first: $first, after: $after) { + edges { + node { + id + name + description + category + websiteUrl + legalName + headquarterAddress + createdAt + updatedAt + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + } + `; + + const childThirdParties = await proboApiRequestAllItems.call( + this, + query, + { thirdPartyId }, + (response) => { + const data = response?.data as IDataObject | undefined; + const node = data?.node as IDataObject | undefined; + return node?.childThirdParties as IDataObject | undefined; + }, + returnAll, + limit, + ); + + return { + json: { childThirdParties }, + pairedItem: { item: itemIndex }, + }; +} diff --git a/packages/n8n-node/nodes/Probo/actions/thirdParty/unlinkThirdParty.operation.ts b/packages/n8n-node/nodes/Probo/actions/thirdParty/unlinkThirdParty.operation.ts new file mode 100644 index 000000000..a7950ed3d --- /dev/null +++ b/packages/n8n-node/nodes/Probo/actions/thirdParty/unlinkThirdParty.operation.ts @@ -0,0 +1,70 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// 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 type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; +import { proboApiRequest } from '../../GenericFunctions'; + +export const description: INodeProperties[] = [ + { + displayName: 'Parent Third Party ID', + name: 'parentThirdPartyId', + type: 'string', + displayOptions: { + show: { + resource: ['thirdParty'], + operation: ['unlinkThirdParty'], + }, + }, + default: '', + description: 'The ID of the parent third party', + required: true, + }, + { + displayName: 'Child Third Party ID', + name: 'childThirdPartyId', + type: 'string', + displayOptions: { + show: { + resource: ['thirdParty'], + operation: ['unlinkThirdParty'], + }, + }, + default: '', + description: 'The ID of the child third party to unlink', + required: true, + }, +]; + +export async function execute( + this: IExecuteFunctions, + itemIndex: number, +): Promise { + const parentThirdPartyId = this.getNodeParameter('parentThirdPartyId', itemIndex) as string; + const childThirdPartyId = this.getNodeParameter('childThirdPartyId', itemIndex) as string; + + const query = ` + mutation DeleteThirdPartyThirdPartyMapping($input: DeleteThirdPartyThirdPartyMappingInput!) { + deleteThirdPartyThirdPartyMapping(input: $input) { + removedThirdPartyId + } + } + `; + + const responseData = await proboApiRequest.call(this, query, { input: { parentThirdPartyId, childThirdPartyId } }); + + return { + json: responseData, + pairedItem: { item: itemIndex }, + }; +} diff --git a/pkg/cmd/thirdpartymgmt/link/link.go b/pkg/cmd/thirdpartymgmt/link/link.go new file mode 100644 index 000000000..7117dfe5f --- /dev/null +++ b/pkg/cmd/thirdpartymgmt/link/link.go @@ -0,0 +1,108 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// 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 link + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const linkMutation = ` +mutation($input: CreateThirdPartyThirdPartyMappingInput!) { + createThirdPartyThirdPartyMapping(input: $input) { + thirdPartyEdge { + node { + id + name + } + } + } +} +` + +type linkResponse struct { + CreateThirdPartyThirdPartyMapping struct { + ThirdPartyEdge struct { + Node struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"node"` + } `json:"thirdPartyEdge"` + } `json:"createThirdPartyThirdPartyMapping"` +} + +func NewCmdLink(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "link ", + Short: "Link a child thirdParty to a parent thirdParty", + Example: ` # Link a child third_party to a parent + prb thirdParty link `, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + cmdutil.TokenRefreshOption(cfg, host, hc), + ) + + data, err := client.Do( + linkMutation, + map[string]any{ + "input": map[string]any{ + "parentThirdPartyId": args[0], + "childThirdPartyId": args[1], + }, + }, + ) + if err != nil { + return err + } + + var resp linkResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("cannot parse response: %w", err) + } + + v := resp.CreateThirdPartyThirdPartyMapping.ThirdPartyEdge.Node + _, _ = fmt.Fprintf( + f.IOStreams.Out, + "Linked thirdParty %s (%s) as child of %s\n", + v.ID, + v.Name, + args[0], + ) + + return nil + }, + } + + return cmd +} diff --git a/pkg/cmd/thirdpartymgmt/list/list.go b/pkg/cmd/thirdpartymgmt/list/list.go index 1ff1ee4a0..de5747aaa 100644 --- a/pkg/cmd/thirdpartymgmt/list/list.go +++ b/pkg/cmd/thirdpartymgmt/list/list.go @@ -24,11 +24,11 @@ import ( ) const listQuery = ` -query($id: ID!, $first: Int, $after: CursorKey, $orderBy: ThirdPartyOrder) { +query($id: ID!, $first: Int, $after: CursorKey, $orderBy: ThirdPartyOrder, $filter: ThirdPartyFilter) { node(id: $id) { __typename ... on Organization { - third_parties(first: $first, after: $after, orderBy: $orderBy) { + third_parties(first: $first, after: $after, orderBy: $orderBy, filter: $filter) { totalCount edges { node { @@ -55,11 +55,12 @@ type thirdParty struct { func NewCmdList(f *cmdutil.Factory) *cobra.Command { var ( - flagOrg string - flagLimit int - flagOrderBy string - flagOrderDir string - flagOutput *string + flagOrg string + flagLimit int + flagOrderBy string + flagOrderDir string + flagFirstLevel bool + flagOutput *string ) cmd := &cobra.Command{ @@ -107,6 +108,12 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { "id": flagOrg, } + if cmd.Flags().Changed("first-level") { + variables["filter"] = map[string]any{ + "first-level": flagFirstLevel, + } + } + if flagOrderBy != "" { if err := cmdutil.ValidateEnum("order-by", flagOrderBy, []string{"NAME", "CREATED_AT", "UPDATED_AT"}); err != nil { return err @@ -188,6 +195,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { cmd.Flags().IntVarP(&flagLimit, "limit", "L", 30, "Maximum number of thirdParties to list") cmd.Flags().StringVar(&flagOrderBy, "order-by", "", "Order by field (NAME, CREATED_AT, UPDATED_AT)") cmd.Flags().StringVar(&flagOrderDir, "order-direction", "DESC", "Sort direction (ASC, DESC)") + cmd.Flags().BoolVar(&flagFirstLevel, "first-level", false, "Filter by first-level thirdParties only") flagOutput = cmdutil.AddOutputFlag(cmd) return cmd diff --git a/pkg/cmd/thirdpartymgmt/thirdpartymgmt.go b/pkg/cmd/thirdpartymgmt/thirdpartymgmt.go index 3a76eb06f..928eb2dbe 100644 --- a/pkg/cmd/thirdpartymgmt/thirdpartymgmt.go +++ b/pkg/cmd/thirdpartymgmt/thirdpartymgmt.go @@ -20,8 +20,10 @@ import ( "go.probo.inc/probo/pkg/cmd/thirdpartymgmt/assess" "go.probo.inc/probo/pkg/cmd/thirdpartymgmt/create" "go.probo.inc/probo/pkg/cmd/thirdpartymgmt/delete" + "go.probo.inc/probo/pkg/cmd/thirdpartymgmt/link" "go.probo.inc/probo/pkg/cmd/thirdpartymgmt/list" "go.probo.inc/probo/pkg/cmd/thirdpartymgmt/publish" + "go.probo.inc/probo/pkg/cmd/thirdpartymgmt/unlink" "go.probo.inc/probo/pkg/cmd/thirdpartymgmt/update" "go.probo.inc/probo/pkg/cmd/thirdpartymgmt/view" ) @@ -39,6 +41,8 @@ func NewCmdThirdParty(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(delete.NewCmdDelete(f)) cmd.AddCommand(assess.NewCmdAssess(f)) cmd.AddCommand(publish.NewCmdPublish(f)) + cmd.AddCommand(link.NewCmdLink(f)) + cmd.AddCommand(unlink.NewCmdUnlink(f)) return cmd } diff --git a/pkg/cmd/thirdpartymgmt/unlink/unlink.go b/pkg/cmd/thirdpartymgmt/unlink/unlink.go new file mode 100644 index 000000000..0f755b0aa --- /dev/null +++ b/pkg/cmd/thirdpartymgmt/unlink/unlink.go @@ -0,0 +1,84 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// 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 unlink + +import ( + "fmt" + + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const unlinkMutation = ` +mutation($input: UncreateThirdPartyThirdPartyMappingInput!) { + uncreateThirdPartyThirdPartyMapping(input: $input) { + removedThirdPartyId + } +} +` + +func NewCmdUnlink(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "unlink ", + Short: "Unlink a child thirdParty from a parent thirdParty", + Example: ` # Unlink a child third_party from a parent + prb thirdParty unlink `, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + cmdutil.TokenRefreshOption(cfg, host, hc), + ) + + _, err = client.Do( + unlinkMutation, + map[string]any{ + "input": map[string]any{ + "parentThirdPartyId": args[0], + "childThirdPartyId": args[1], + }, + }, + ) + if err != nil { + return err + } + + _, _ = fmt.Fprintf( + f.IOStreams.Out, + "Unlinked thirdParty %s from parent %s\n", + args[1], + args[0], + ) + + return nil + }, + } + + return cmd +} diff --git a/pkg/coredata/migrations/20260519T100000Z.sql b/pkg/coredata/migrations/20260519T100000Z.sql new file mode 100644 index 000000000..d29e888b0 --- /dev/null +++ b/pkg/coredata/migrations/20260519T100000Z.sql @@ -0,0 +1,25 @@ +-- Copyright (c) 2025-2026 Probo Inc . +-- +-- 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. + +ALTER TABLE third_parties ADD COLUMN first_level boolean NOT NULL DEFAULT true; +ALTER TABLE third_parties ALTER COLUMN first_level DROP DEFAULT; + +CREATE TABLE third_party_third_parties ( + parent_third_party_id text NOT NULL REFERENCES third_parties(id) ON DELETE CASCADE, + child_third_party_id text NOT NULL REFERENCES third_parties(id) ON DELETE CASCADE, + tenant_id bytea NOT NULL, + created_at timestamptz NOT NULL, + PRIMARY KEY (parent_third_party_id, child_third_party_id), + CHECK (parent_third_party_id <> child_third_party_id) +); diff --git a/pkg/coredata/third_party.go b/pkg/coredata/third_party.go index 81dc53bb4..638152aff 100644 --- a/pkg/coredata/third_party.go +++ b/pkg/coredata/third_party.go @@ -162,6 +162,7 @@ type ( SecurityPageURL *string `db:"security_page_url"` TrustPageURL *string `db:"trust_page_url"` ShowOnTrustCenter bool `db:"show_on_trust_center"` + FirstLevel bool `db:"first_level"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` } @@ -253,6 +254,7 @@ SELECT security_page_url, trust_page_url, show_on_trust_center, + first_level, created_at, updated_at FROM @@ -320,6 +322,7 @@ SELECT security_page_url, trust_page_url, show_on_trust_center, + first_level, created_at, updated_at FROM @@ -381,6 +384,7 @@ INSERT INTO security_page_url, trust_page_url, show_on_trust_center, + first_level, created_at, updated_at ) @@ -409,6 +413,7 @@ VALUES ( @security_page_url, @trust_page_url, @show_on_trust_center, + @first_level, @created_at, @updated_at ) @@ -439,6 +444,7 @@ VALUES ( "security_page_url": v.SecurityPageURL, "trust_page_url": v.TrustPageURL, "show_on_trust_center": v.ShowOnTrustCenter, + "first_level": v.FirstLevel, "created_at": v.CreatedAt, "updated_at": v.UpdatedAt, } @@ -534,6 +540,7 @@ SELECT security_page_url, trust_page_url, show_on_trust_center, + first_level, created_at, updated_at FROM @@ -597,6 +604,7 @@ SELECT security_page_url, trust_page_url, show_on_trust_center, + first_level, created_at, updated_at FROM @@ -657,6 +665,7 @@ SET business_owner_profile_id = @business_owner_profile_id, security_owner_profile_id = @security_owner_profile_id, show_on_trust_center = @show_on_trust_center, + first_level = @first_level, updated_at = @updated_at WHERE %s AND id = @third_party_id @@ -686,6 +695,7 @@ WHERE %s "business_owner_profile_id": v.BusinessOwnerID, "security_owner_profile_id": v.SecurityOwnerID, "show_on_trust_center": v.ShowOnTrustCenter, + "first_level": v.FirstLevel, } maps.Copy(args, scope.SQLArguments()) @@ -803,6 +813,7 @@ WITH vend AS ( v.security_page_url, v.trust_page_url, v.show_on_trust_center, + v.first_level, v.created_at, v.updated_at FROM @@ -837,6 +848,7 @@ SELECT security_page_url, trust_page_url, show_on_trust_center, + first_level, created_at, updated_at FROM @@ -938,6 +950,7 @@ WITH vend AS ( v.security_page_url, v.trust_page_url, v.show_on_trust_center, + v.first_level, v.created_at, v.updated_at FROM @@ -972,6 +985,7 @@ SELECT security_page_url, trust_page_url, show_on_trust_center, + first_level, created_at, updated_at FROM @@ -1033,6 +1047,7 @@ WITH vend AS ( v.security_page_url, v.trust_page_url, v.show_on_trust_center, + v.first_level, v.created_at, v.updated_at FROM @@ -1067,6 +1082,7 @@ SELECT security_page_url, trust_page_url, show_on_trust_center, + first_level, created_at, updated_at FROM @@ -1129,6 +1145,7 @@ WITH vend AS ( v.security_page_url, v.trust_page_url, v.show_on_trust_center, + v.first_level, v.created_at, v.updated_at FROM @@ -1163,6 +1180,7 @@ SELECT security_page_url, trust_page_url, show_on_trust_center, + first_level, created_at, updated_at FROM @@ -1293,6 +1311,7 @@ WITH vend AS ( v.security_page_url, v.trust_page_url, v.show_on_trust_center, + v.first_level, v.created_at, v.updated_at FROM @@ -1327,6 +1346,7 @@ SELECT security_page_url, trust_page_url, show_on_trust_center, + first_level, created_at, updated_at FROM @@ -1387,6 +1407,7 @@ SELECT security_page_url, trust_page_url, show_on_trust_center, + first_level, created_at, updated_at FROM diff --git a/pkg/coredata/third_party_filter.go b/pkg/coredata/third_party_filter.go index 141c73d66..aa50eda8f 100644 --- a/pkg/coredata/third_party_filter.go +++ b/pkg/coredata/third_party_filter.go @@ -21,12 +21,14 @@ import ( type ( ThirdPartyFilter struct { showOnTrustCenter *bool + firstLevel *bool } ) -func NewThirdPartyFilter(showOnTrustCenter *bool) *ThirdPartyFilter { +func NewThirdPartyFilter(showOnTrustCenter *bool, firstLevel *bool) *ThirdPartyFilter { return &ThirdPartyFilter{ showOnTrustCenter: showOnTrustCenter, + firstLevel: firstLevel, } } @@ -39,6 +41,12 @@ func (f *ThirdPartyFilter) SQLArguments() pgx.StrictNamedArgs { args["show_on_trust_center"] = nil } + if f.firstLevel != nil { + args["first_level"] = *f.firstLevel + } else { + args["first_level"] = nil + } + return args } @@ -50,5 +58,13 @@ func (f *ThirdPartyFilter) SQLFragment() string { show_on_trust_center = @show_on_trust_center::boolean ELSE TRUE END +) +AND +( + CASE + WHEN @first_level::boolean IS NOT NULL THEN + first_level = @first_level::boolean + ELSE TRUE + END )` } diff --git a/pkg/coredata/third_party_third_party.go b/pkg/coredata/third_party_third_party.go new file mode 100644 index 000000000..ed0ed23f6 --- /dev/null +++ b/pkg/coredata/third_party_third_party.go @@ -0,0 +1,226 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// 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 coredata + +import ( + "context" + "fmt" + "maps" + "time" + + "github.com/jackc/pgx/v5" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/page" +) + +type ( + ThirdPartyThirdParty struct { + ParentThirdPartyID gid.GID `db:"parent_third_party_id"` + ChildThirdPartyID gid.GID `db:"child_third_party_id"` + TenantID gid.TenantID `db:"tenant_id"` + CreatedAt time.Time `db:"created_at"` + } + + ThirdPartyThirdParties []*ThirdPartyThirdParty +) + +func (r *ThirdPartyThirdParty) Insert(ctx context.Context, conn pg.Tx, scope Scoper) error { + q := ` +INSERT INTO third_party_third_parties ( + parent_third_party_id, + child_third_party_id, + tenant_id, + created_at +) VALUES ( + @parent_third_party_id, + @child_third_party_id, + @tenant_id, + @created_at +) +ON CONFLICT (parent_third_party_id, child_third_party_id) DO NOTHING +` + + args := pgx.StrictNamedArgs{ + "parent_third_party_id": r.ParentThirdPartyID, + "child_third_party_id": r.ChildThirdPartyID, + "tenant_id": scope.GetTenantID(), + "created_at": r.CreatedAt, + } + + _, err := conn.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot insert third party third party: %w", err) + } + + return nil +} + +func (r *ThirdPartyThirdParty) Delete(ctx context.Context, conn pg.Tx, scope Scoper) error { + q := ` +DELETE FROM third_party_third_parties +WHERE %s + AND parent_third_party_id = @parent_third_party_id + AND child_third_party_id = @child_third_party_id +` + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "parent_third_party_id": r.ParentThirdPartyID, + "child_third_party_id": r.ChildThirdPartyID, + } + maps.Copy(args, scope.SQLArguments()) + + _, err := conn.Exec(ctx, q, args) + + return err +} + +func (v *ThirdParties) CountByParentThirdPartyID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + parentThirdPartyID gid.GID, +) (int, error) { + q := ` +WITH children AS ( + SELECT + tp.id, + tp.tenant_id + FROM + third_parties tp + INNER JOIN + third_party_third_parties tpr ON tp.id = tpr.child_third_party_id + WHERE + tpr.parent_third_party_id = @parent_third_party_id +) +SELECT + COUNT(id) +FROM + children +WHERE %s +` + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"parent_third_party_id": parentThirdPartyID} + maps.Copy(args, scope.SQLArguments()) + + var count int + + err := conn.QueryRow(ctx, q, args).Scan(&count) + if err != nil { + return 0, fmt.Errorf("cannot count child third parties: %w", err) + } + + return count, nil +} + +func (v *ThirdParties) LoadByParentThirdPartyID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + parentThirdPartyID gid.GID, + cursor *page.Cursor[ThirdPartyOrderField], +) error { + q := ` +WITH children AS ( + SELECT + tp.id, + tp.tenant_id, + tp.organization_id, + tp.common_third_party_id, + tp.name, + tp.description, + tp.category, + tp.headquarter_address, + tp.legal_name, + tp.website_url, + tp.privacy_policy_url, + tp.service_level_agreement_url, + tp.data_processing_agreement_url, + tp.business_associate_agreement_url, + tp.subprocessors_list_url, + tp.certifications, + tp.countries, + tp.business_owner_profile_id, + tp.security_owner_profile_id, + tp.status_page_url, + tp.terms_of_service_url, + tp.security_page_url, + tp.trust_page_url, + tp.show_on_trust_center, + tp.first_level, + tp.created_at, + tp.updated_at + FROM + third_parties tp + INNER JOIN + third_party_third_parties tpr ON tp.id = tpr.child_third_party_id + WHERE + tpr.parent_third_party_id = @parent_third_party_id +) +SELECT + id, + tenant_id, + organization_id, + common_third_party_id, + name, + description, + category, + headquarter_address, + legal_name, + website_url, + privacy_policy_url, + service_level_agreement_url, + data_processing_agreement_url, + business_associate_agreement_url, + subprocessors_list_url, + certifications, + countries, + business_owner_profile_id, + security_owner_profile_id, + status_page_url, + terms_of_service_url, + security_page_url, + trust_page_url, + show_on_trust_center, + first_level, + created_at, + updated_at +FROM + children +WHERE %s + AND %s +` + q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment()) + + args := pgx.StrictNamedArgs{"parent_third_party_id": parentThirdPartyID} + maps.Copy(args, scope.SQLArguments()) + maps.Copy(args, cursor.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query child third parties: %w", err) + } + + thirdParties, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[ThirdParty]) + if err != nil { + return fmt.Errorf("cannot collect child third parties: %w", err) + } + + *v = thirdParties + + return nil +} diff --git a/pkg/probo/actions.go b/pkg/probo/actions.go index f770276d9..9df8bc8b5 100644 --- a/pkg/probo/actions.go +++ b/pkg/probo/actions.go @@ -92,6 +92,11 @@ const ( ActionThirdPartyAssess = "core:thirdParty:assess" ActionThirdPartyPublish = "core:thirdParty:publish" + // ThirdPartyRelation actions + ActionThirdPartyRelationCreate = "core:thirdParty-relation:create" + ActionThirdPartyRelationDelete = "core:thirdParty-relation:delete" + ActionThirdPartyRelationList = "core:thirdParty-relation:list" + // ThirdPartyContact actions ActionThirdPartyContactGet = "core:thirdParty-contact:get" ActionThirdPartyContactList = "core:thirdParty-contact:list" diff --git a/pkg/probo/policies.go b/pkg/probo/policies.go index 7ef61738d..146e32541 100644 --- a/pkg/probo/policies.go +++ b/pkg/probo/policies.go @@ -55,6 +55,7 @@ var ViewerPolicy = policy.NewPolicy( ActionThirdPartyBusinessAssociateAgreementGet, ActionThirdPartyDataPrivacyAgreementGet, ActionThirdPartyRiskAssessmentList, + ActionThirdPartyRelationList, ActionFrameworkGet, ActionFrameworkList, ActionControlGet, ActionControlList, ActionMeasureGet, ActionMeasureList, @@ -140,6 +141,7 @@ var AuditorPolicy = policy.NewPolicy( ActionThirdPartyBusinessAssociateAgreementGet, ActionThirdPartyDataPrivacyAgreementGet, ActionThirdPartyRiskAssessmentList, + ActionThirdPartyRelationList, ActionFrameworkGet, ActionFrameworkList, ActionControlGet, ActionControlList, ActionMeasureGet, ActionMeasureList, diff --git a/pkg/probo/third_party_service.go b/pkg/probo/third_party_service.go index cd51214e5..79eb1b2cd 100644 --- a/pkg/probo/third_party_service.go +++ b/pkg/probo/third_party_service.go @@ -92,6 +92,7 @@ type ( StatusPageURL *string BusinessOwnerID *gid.GID SecurityOwnerID *gid.GID + FirstLevel *bool } UpdateThirdPartyRequest struct { @@ -116,6 +117,7 @@ type ( BusinessOwnerID **gid.GID SecurityOwnerID **gid.GID ShowOnTrustCenter *bool + FirstLevel *bool } AssessThirdPartyRequest struct { @@ -386,6 +388,10 @@ func (s ThirdPartyService) Update( thirdParty.ShowOnTrustCenter = *req.ShowOnTrustCenter } + if req.FirstLevel != nil { + thirdParty.FirstLevel = *req.FirstLevel + } + if req.TrustPageURL != nil { thirdParty.TrustPageURL = *req.TrustPageURL } @@ -573,6 +579,11 @@ func (s ThirdPartyService) Create( StatusPageURL: req.StatusPageURL, TermsOfServiceURL: req.TermsOfServiceURL, ShowOnTrustCenter: false, + FirstLevel: true, + } + + if req.FirstLevel != nil { + thirdParty.FirstLevel = *req.FirstLevel } err := s.svc.pg.WithTx( @@ -947,3 +958,116 @@ func (s ThirdPartyService) Assess( Subprocessors: subprocessors, }, nil } + +func (s ThirdPartyService) CreateThirdPartyMapping( + ctx context.Context, + scope coredata.Scoper, + parentThirdPartyID gid.GID, + childThirdPartyID gid.GID, +) (*coredata.ThirdParty, error) { + childThirdParty := &coredata.ThirdParty{} + + err := s.svc.pg.WithTx( + ctx, + func(ctx context.Context, conn pg.Tx) error { + parentThirdParty := &coredata.ThirdParty{} + if err := parentThirdParty.LoadByID(ctx, conn, scope, parentThirdPartyID); err != nil { + return fmt.Errorf("cannot load parent third party: %w", err) + } + + if err := childThirdParty.LoadByID(ctx, conn, scope, childThirdPartyID); err != nil { + return fmt.Errorf("cannot load child third party: %w", err) + } + + if parentThirdParty.OrganizationID != childThirdParty.OrganizationID { + return fmt.Errorf("cannot create mapping for third parties from different organizations: %w", coredata.ErrResourceNotFound) + } + + relation := &coredata.ThirdPartyThirdParty{ + ParentThirdPartyID: parentThirdPartyID, + ChildThirdPartyID: childThirdPartyID, + CreatedAt: time.Now(), + } + if err := relation.Insert(ctx, conn, scope); err != nil { + return fmt.Errorf("cannot create third party mapping: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return childThirdParty, nil +} + +func (s ThirdPartyService) DeleteThirdPartyMapping( + ctx context.Context, + scope coredata.Scoper, + parentThirdPartyID gid.GID, + childThirdPartyID gid.GID, +) error { + return s.svc.pg.WithTx( + ctx, + func(ctx context.Context, conn pg.Tx) error { + relation := &coredata.ThirdPartyThirdParty{ + ParentThirdPartyID: parentThirdPartyID, + ChildThirdPartyID: childThirdPartyID, + } + if err := relation.Delete(ctx, conn, scope); err != nil { + return fmt.Errorf("cannot delete third party mapping: %w", err) + } + + return nil + }, + ) +} + +func (s ThirdPartyService) CountForParentThirdPartyID( + ctx context.Context, + scope coredata.Scoper, + parentThirdPartyID gid.GID, +) (int, error) { + var count int + + err := s.svc.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) (err error) { + thirdParties := coredata.ThirdParties{} + + count, err = thirdParties.CountByParentThirdPartyID(ctx, conn, scope, parentThirdPartyID) + if err != nil { + return fmt.Errorf("cannot count child third parties: %w", err) + } + + return nil + }, + ) + if err != nil { + return 0, err + } + + return count, nil +} + +func (s ThirdPartyService) ListForParentThirdPartyID( + ctx context.Context, + scope coredata.Scoper, + parentThirdPartyID gid.GID, + cursor *page.Cursor[coredata.ThirdPartyOrderField], +) (*page.Page[*coredata.ThirdParty, coredata.ThirdPartyOrderField], error) { + var thirdParties coredata.ThirdParties + + err := s.svc.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + return thirdParties.LoadByParentThirdPartyID(ctx, conn, scope, parentThirdPartyID, cursor) + }, + ) + if err != nil { + return nil, err + } + + return page.NewPage(thirdParties, cursor), nil +} diff --git a/pkg/server/api/console/v1/graphql/organization.graphql b/pkg/server/api/console/v1/graphql/organization.graphql index 8706ea258..3cee21148 100644 --- a/pkg/server/api/console/v1/graphql/organization.graphql +++ b/pkg/server/api/console/v1/graphql/organization.graphql @@ -331,6 +331,7 @@ type Organization implements Node { last: Int before: CursorKey orderBy: ThirdPartyOrder + filter: ThirdPartyFilter ): ThirdPartyConnection! @goField(forceResolver: true) thirdPartiesDocument: Document @goField(forceResolver: true) diff --git a/pkg/server/api/console/v1/graphql/third_party.graphql b/pkg/server/api/console/v1/graphql/third_party.graphql index 512b50bab..85aed15b6 100644 --- a/pkg/server/api/console/v1/graphql/third_party.graphql +++ b/pkg/server/api/console/v1/graphql/third_party.graphql @@ -177,6 +177,10 @@ input ThirdPartyOrder field: ThirdPartyOrderField! } +input ThirdPartyFilter { + firstLevel: Boolean +} + input ThirdPartyComplianceReportOrder @goModel( model: "go.probo.inc/probo/pkg/server/api/console/v1/types.ThirdPartyComplianceReportOrderBy" @@ -269,6 +273,16 @@ type ThirdParty implements Node { legalName: String websiteUrl: String showOnTrustCenter: Boolean! + firstLevel: Boolean! + + childThirdParties( + first: Int + after: CursorKey + last: Int + before: CursorKey + orderBy: ThirdPartyOrder + ): ThirdPartyConnection! @goField(forceResolver: true) + createdAt: Datetime! updatedAt: Datetime! @@ -460,6 +474,12 @@ extend type Mutation { publishThirdPartyList( input: PublishThirdPartyListInput! ): PublishThirdPartyListPayload! + createThirdPartyThirdPartyMapping( + input: CreateThirdPartyThirdPartyMappingInput! + ): CreateThirdPartyThirdPartyMappingPayload! + deleteThirdPartyThirdPartyMapping( + input: DeleteThirdPartyThirdPartyMappingInput! + ): DeleteThirdPartyThirdPartyMappingPayload! } input PublishThirdPartyListInput { @@ -494,6 +514,7 @@ input CreateThirdPartyInput { termsOfServiceUrl: String businessOwnerId: ID securityOwnerId: ID + firstLevel: Boolean } input UpdateThirdPartyInput { @@ -518,6 +539,7 @@ input UpdateThirdPartyInput { businessOwnerId: ID @goField(omittable: true) securityOwnerId: ID @goField(omittable: true) showOnTrustCenter: Boolean + firstLevel: Boolean } input DeleteThirdPartyInput { @@ -709,3 +731,21 @@ type AssessThirdPartyPayload { report: String! subprocessors: [ThirdPartySubprocessor!]! } + +input CreateThirdPartyThirdPartyMappingInput { + parentThirdPartyId: ID! + childThirdPartyId: ID! +} + +type CreateThirdPartyThirdPartyMappingPayload { + thirdPartyEdge: ThirdPartyEdge! +} + +input DeleteThirdPartyThirdPartyMappingInput { + parentThirdPartyId: ID! + childThirdPartyId: ID! +} + +type DeleteThirdPartyThirdPartyMappingPayload { + removedThirdPartyId: ID! +} diff --git a/pkg/server/api/console/v1/organization_resolvers.go b/pkg/server/api/console/v1/organization_resolvers.go index c2ed3c229..34cccce52 100644 --- a/pkg/server/api/console/v1/organization_resolvers.go +++ b/pkg/server/api/console/v1/organization_resolvers.go @@ -1271,7 +1271,7 @@ func (r *organizationResolver) CookieBanners(ctx context.Context, obj *types.Org } // ThirdParties is the resolver for the thirdParties field. -func (r *organizationResolver) ThirdParties(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ThirdPartyOrderBy) (*types.ThirdPartyConnection, error) { +func (r *organizationResolver) ThirdParties(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ThirdPartyOrderBy, filter *types.ThirdPartyFilter) (*types.ThirdPartyConnection, error) { scope, err := r.authorize(ctx, obj.ID, probo.ActionThirdPartyList) if err != nil { return nil, err @@ -1291,7 +1291,12 @@ func (r *organizationResolver) ThirdParties(ctx context.Context, obj *types.Orga cursor := types.NewCursor(first, after, last, before, pageOrderBy) - thirdPartyFilter := coredata.NewThirdPartyFilter(nil) + var firstLevel *bool + if filter != nil { + firstLevel = filter.FirstLevel + } + + thirdPartyFilter := coredata.NewThirdPartyFilter(nil, firstLevel) page, err := r.probo.ThirdParties.ListForOrganizationID(ctx, scope, obj.ID, cursor, thirdPartyFilter) if err != nil { diff --git a/pkg/server/api/console/v1/third_party_resolvers.go b/pkg/server/api/console/v1/third_party_resolvers.go index 4d384118e..227da8a71 100644 --- a/pkg/server/api/console/v1/third_party_resolvers.go +++ b/pkg/server/api/console/v1/third_party_resolvers.go @@ -55,6 +55,7 @@ func (r *mutationResolver) CreateThirdParty(ctx context.Context, input types.Cre BusinessOwnerID: input.BusinessOwnerID, SecurityOwnerID: input.SecurityOwnerID, Countries: input.Countries, + FirstLevel: input.FirstLevel, }, ) if err != nil { @@ -106,6 +107,7 @@ func (r *mutationResolver) UpdateThirdParty(ctx context.Context, input types.Upd BusinessOwnerID: gqlutils.UnwrapOmittable(input.BusinessOwnerID), SecurityOwnerID: gqlutils.UnwrapOmittable(input.SecurityOwnerID), ShowOnTrustCenter: input.ShowOnTrustCenter, + FirstLevel: input.FirstLevel, Countries: input.Countries, }, ) @@ -594,6 +596,46 @@ func (r *mutationResolver) PublishThirdPartyList(ctx context.Context, input type }, nil } +// CreateThirdPartyThirdPartyMapping is the resolver for the linkThirdPartyThirdParty field. +func (r *mutationResolver) CreateThirdPartyThirdPartyMapping(ctx context.Context, input types.CreateThirdPartyThirdPartyMappingInput) (*types.CreateThirdPartyThirdPartyMappingPayload, error) { + scope, err := r.authorize(ctx, input.ParentThirdPartyID, probo.ActionThirdPartyRelationCreate) + if err != nil { + return nil, err + } + + childThirdParty, err := r.probo.ThirdParties.CreateThirdPartyMapping(ctx, scope, input.ParentThirdPartyID, input.ChildThirdPartyID) + if err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + + r.logger.ErrorCtx(ctx, "cannot create third party mapping", log.Error(err)) + + return nil, gqlutils.Internal(ctx) + } + + return &types.CreateThirdPartyThirdPartyMappingPayload{ + ThirdPartyEdge: types.NewThirdPartyEdge(childThirdParty, coredata.ThirdPartyOrderFieldName), + }, nil +} + +// DeleteThirdPartyThirdPartyMapping is the resolver for the deleteThirdPartyThirdPartyMapping field. +func (r *mutationResolver) DeleteThirdPartyThirdPartyMapping(ctx context.Context, input types.DeleteThirdPartyThirdPartyMappingInput) (*types.DeleteThirdPartyThirdPartyMappingPayload, error) { + scope, err := r.authorize(ctx, input.ParentThirdPartyID, probo.ActionThirdPartyRelationDelete) + if err != nil { + return nil, err + } + + if err := r.probo.ThirdParties.DeleteThirdPartyMapping(ctx, scope, input.ParentThirdPartyID, input.ChildThirdPartyID); err != nil { + r.logger.ErrorCtx(ctx, "cannot delete third party mapping", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return &types.DeleteThirdPartyThirdPartyMappingPayload{ + RemovedThirdPartyID: input.ChildThirdPartyID, + }, nil +} + // Organization is the resolver for the organization field. func (r *thirdPartyResolver) Organization(ctx context.Context, obj *types.ThirdParty) (*types.Organization, error) { if _, err := r.authorize(ctx, obj.ID, probo.ActionOrganizationGet); err != nil { @@ -830,6 +872,35 @@ func (r *thirdPartyResolver) SecurityOwner(ctx context.Context, obj *types.Third return types.NewProfile(securityOwner), nil } +// ChildThirdParties is the resolver for the childThirdParties field. +func (r *thirdPartyResolver) ChildThirdParties(ctx context.Context, obj *types.ThirdParty, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ThirdPartyOrderBy) (*types.ThirdPartyConnection, error) { + scope, err := r.authorize(ctx, obj.ID, probo.ActionThirdPartyRelationList) + if err != nil { + return nil, err + } + + pageOrderBy := page.OrderBy[coredata.ThirdPartyOrderField]{ + Field: coredata.ThirdPartyOrderFieldName, + Direction: page.OrderDirectionAsc, + } + if orderBy != nil { + pageOrderBy = page.OrderBy[coredata.ThirdPartyOrderField]{ + Field: orderBy.Field, + Direction: orderBy.Direction, + } + } + + cursor := types.NewCursor(first, after, last, before, pageOrderBy) + + page, err := r.probo.ThirdParties.ListForParentThirdPartyID(ctx, scope, obj.ID, cursor) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot list child third parties", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return types.NewThirdPartyConnection(page, r, obj.ID), nil +} + // Permission is the resolver for the permission field. func (r *thirdPartyResolver) Permission(ctx context.Context, obj *types.ThirdParty, action string) (bool, error) { return r.Resolver.Permission(ctx, obj, action) @@ -963,6 +1034,19 @@ func (r *thirdPartyConnectionResolver) TotalCount(ctx context.Context, obj *type return 0, gqlutils.Internal(ctx) } + return count, nil + case *thirdPartyResolver: + if _, err := r.authorize(ctx, obj.ParentID, probo.ActionThirdPartyRelationList); err != nil { + return 0, err + } + + count, err := r.probo.ThirdParties.CountForParentThirdPartyID(ctx, scope, obj.ParentID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot count child third parties", log.Error(err)) + + return 0, gqlutils.Internal(ctx) + } + return count, nil } @@ -1145,3 +1229,15 @@ type thirdPartyContactResolver struct{ *Resolver } type thirdPartyDataPrivacyAgreementResolver struct{ *Resolver } type thirdPartyRiskAssessmentResolver struct{ *Resolver } type thirdPartyServiceResolver struct{ *Resolver } + +// !!! WARNING !!! +// The code below was going to be deleted when updating resolvers. It has been copied here so you have +// one last chance to move it out of harms way if you want. There are two reasons this happens: +// - When renaming or deleting a resolver the old code will be put in here. You can safely delete +// it when you're done. +// - You have helper methods in this file. Move them out to keep these resolver files clean. +/* + func (r *mutationResolver) UncreateThirdPartyThirdPartyMapping(ctx context.Context, input types.UncreateThirdPartyThirdPartyMappingInput) (*types.UncreateThirdPartyThirdPartyMappingPayload, error) { + panic(fmt.Errorf("not implemented: UncreateThirdPartyThirdPartyMapping - uncreateThirdPartyThirdPartyMapping")) +} +*/ diff --git a/pkg/server/api/console/v1/types/third_party.go b/pkg/server/api/console/v1/types/third_party.go index 2eee7825a..6cbefbfd9 100644 --- a/pkg/server/api/console/v1/types/third_party.go +++ b/pkg/server/api/console/v1/types/third_party.go @@ -84,6 +84,7 @@ func NewThirdParty(v *coredata.ThirdParty) *ThirdParty { WebsiteURL: v.WebsiteURL, Category: v.Category, ShowOnTrustCenter: v.ShowOnTrustCenter, + FirstLevel: v.FirstLevel, Countries: v.Countries, UpdatedAt: v.UpdatedAt, CreatedAt: v.CreatedAt, diff --git a/pkg/server/api/mcp/v1/schema.resolvers.go b/pkg/server/api/mcp/v1/schema.resolvers.go index 556ba347e..25323ebfb 100644 --- a/pkg/server/api/mcp/v1/schema.resolvers.go +++ b/pkg/server/api/mcp/v1/schema.resolvers.go @@ -70,7 +70,7 @@ func (r *Resolver) ListThirdPartiesTool(ctx context.Context, req *mcp.CallToolRe cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy) - thirdPartyFilter := coredata.NewThirdPartyFilter(nil) + thirdPartyFilter := coredata.NewThirdPartyFilter(nil, input.FirstLevel) page, err := prb.ThirdParties.ListForOrganizationID(ctx, scope, input.OrganizationID, cursor, thirdPartyFilter) if err != nil { @@ -6148,6 +6148,59 @@ func (r *Resolver) MoveTrackerResourceToCategoryTool(ctx context.Context, req *m return nil, types.MoveTrackerResourceToCategoryOutput{TrackerResource: types.NewTrackerResource(result.TrackerResource)}, nil } +func (r *Resolver) CreateThirdPartyThirdPartyMappingTool(ctx context.Context, req *mcp.CallToolRequest, input *types.CreateThirdPartyThirdPartyMappingInput) (*mcp.CallToolResult, types.CreateThirdPartyThirdPartyMappingOutput, error) { + scope, err := r.Authorize(ctx, input.ParentThirdPartyID, probo.ActionThirdPartyRelationCreate) + if err != nil { + return nil, types.CreateThirdPartyThirdPartyMappingOutput{}, err + } + + if _, err := r.proboSvc.ThirdParties.CreateThirdPartyMapping(ctx, scope, input.ParentThirdPartyID, input.ChildThirdPartyID); err != nil { + return nil, types.CreateThirdPartyThirdPartyMappingOutput{}, fmt.Errorf("cannot create third party mapping: %w", err) + } + + return nil, types.CreateThirdPartyThirdPartyMappingOutput{}, nil +} + +func (r *Resolver) DeleteThirdPartyThirdPartyMappingTool(ctx context.Context, req *mcp.CallToolRequest, input *types.DeleteThirdPartyThirdPartyMappingInput) (*mcp.CallToolResult, types.DeleteThirdPartyThirdPartyMappingOutput, error) { + scope, err := r.Authorize(ctx, input.ParentThirdPartyID, probo.ActionThirdPartyRelationDelete) + if err != nil { + return nil, types.DeleteThirdPartyThirdPartyMappingOutput{}, err + } + + if err := r.proboSvc.ThirdParties.DeleteThirdPartyMapping(ctx, scope, input.ParentThirdPartyID, input.ChildThirdPartyID); err != nil { + return nil, types.DeleteThirdPartyThirdPartyMappingOutput{}, fmt.Errorf("cannot delete third party mapping: %w", err) + } + + return nil, types.DeleteThirdPartyThirdPartyMappingOutput{}, nil +} + +func (r *Resolver) ListChildThirdPartiesTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListChildThirdPartiesInput) (*mcp.CallToolResult, types.ListChildThirdPartiesOutput, error) { + scope, err := r.Authorize(ctx, input.ParentThirdPartyID, probo.ActionThirdPartyRelationList) + if err != nil { + return nil, types.ListChildThirdPartiesOutput{}, err + } + + pageOrderBy := page.OrderBy[coredata.ThirdPartyOrderField]{ + Field: coredata.ThirdPartyOrderFieldCreatedAt, + Direction: page.OrderDirectionDesc, + } + if input.OrderBy != nil { + pageOrderBy = page.OrderBy[coredata.ThirdPartyOrderField]{ + Field: input.OrderBy.Field, + Direction: input.OrderBy.Direction, + } + } + + cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy) + + page, err := r.proboSvc.ThirdParties.ListForParentThirdPartyID(ctx, scope, input.ParentThirdPartyID, cursor) + if err != nil { + panic(fmt.Errorf("cannot list child third parties: %w", err)) + } + + return nil, types.NewListChildThirdPartiesOutput(page), nil +} + func (r *Resolver) ListRiskAssessmentsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListRiskAssessmentsInput) (*mcp.CallToolResult, types.ListRiskAssessmentsOutput, error) { scope, err := r.Authorize(ctx, input.OrganizationID, probo.ActionRiskAssessmentList) if err != nil { diff --git a/pkg/server/api/mcp/v1/specification.yaml b/pkg/server/api/mcp/v1/specification.yaml index a58c6b61d..adc721aad 100644 --- a/pkg/server/api/mcp/v1/specification.yaml +++ b/pkg/server/api/mcp/v1/specification.yaml @@ -627,6 +627,9 @@ components: organization_id: $ref: "#/components/schemas/GID" description: Organization ID + first_level: + type: boolean + description: Filter by first-level status order_by: $ref: "#/components/schemas/ThirdPartyOrderBy" description: ThirdParty order by @@ -650,6 +653,69 @@ components: items: $ref: "#/components/schemas/ThirdParty" + CreateThirdPartyThirdPartyMappingInput: + type: object + required: + - parent_third_party_id + - child_third_party_id + properties: + parent_third_party_id: + $ref: "#/components/schemas/GID" + description: Parent third party ID + child_third_party_id: + $ref: "#/components/schemas/GID" + description: Child third party ID + + CreateThirdPartyThirdPartyMappingOutput: + type: object + + DeleteThirdPartyThirdPartyMappingInput: + type: object + required: + - parent_third_party_id + - child_third_party_id + properties: + parent_third_party_id: + $ref: "#/components/schemas/GID" + description: Parent third party ID + child_third_party_id: + $ref: "#/components/schemas/GID" + description: Child third party ID + + DeleteThirdPartyThirdPartyMappingOutput: + type: object + + ListChildThirdPartiesInput: + type: object + required: + - parent_third_party_id + properties: + parent_third_party_id: + $ref: "#/components/schemas/GID" + description: Parent third party ID + order_by: + $ref: "#/components/schemas/ThirdPartyOrderBy" + description: ThirdParty order by + size: + type: integer + description: Page size + cursor: + $ref: "#/components/schemas/CursorKey" + description: Page cursor + + ListChildThirdPartiesOutput: + type: object + required: + - thirdParties + properties: + next_cursor: + $ref: "#/components/schemas/CursorKey" + description: Next cursor + thirdParties: + type: array + items: + $ref: "#/components/schemas/ThirdParty" + ThirdParty: type: object required: @@ -657,6 +723,7 @@ components: - name - organization_id - category + - first_level - created_at - updated_at properties: @@ -780,6 +847,9 @@ components: - string - "null" description: Trust page URL + first_level: + type: boolean + description: Whether this is a first-level third party created_at: type: string format: date-time @@ -11788,6 +11858,31 @@ tools: $ref: "#/components/schemas/ListThirdPartiesInput" outputSchema: $ref: "#/components/schemas/ListThirdPartiesOutput" + - name: createThirdPartyThirdPartyMapping + description: Link a child third party to a parent third party + hints: + readonly: false + inputSchema: + $ref: "#/components/schemas/CreateThirdPartyThirdPartyMappingInput" + outputSchema: + $ref: "#/components/schemas/CreateThirdPartyThirdPartyMappingOutput" + - name: deleteThirdPartyThirdPartyMapping + description: Unlink a child third party from a parent third party + hints: + readonly: false + inputSchema: + $ref: "#/components/schemas/DeleteThirdPartyThirdPartyMappingInput" + outputSchema: + $ref: "#/components/schemas/DeleteThirdPartyThirdPartyMappingOutput" + - name: listChildThirdParties + description: List child third parties linked to a parent third party + hints: + readonly: true + idempotent: true + inputSchema: + $ref: "#/components/schemas/ListChildThirdPartiesInput" + outputSchema: + $ref: "#/components/schemas/ListChildThirdPartiesOutput" - name: listUsers description: List all users for the organization hints: diff --git a/pkg/server/api/mcp/v1/types/third_party.go b/pkg/server/api/mcp/v1/types/third_party.go index 83e4862b3..9832961bb 100644 --- a/pkg/server/api/mcp/v1/types/third_party.go +++ b/pkg/server/api/mcp/v1/types/third_party.go @@ -87,6 +87,7 @@ func NewThirdParty(v *coredata.ThirdParty) *ThirdParty { TermsOfServiceURL: v.TermsOfServiceURL, SecurityPageURL: v.SecurityPageURL, TrustPageURL: v.TrustPageURL, + FirstLevel: v.FirstLevel, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, } @@ -111,6 +112,25 @@ func NewListThirdPartiesOutput(thirdPartyPage *page.Page[*coredata.ThirdParty, c } } +func NewListChildThirdPartiesOutput(thirdPartyPage *page.Page[*coredata.ThirdParty, coredata.ThirdPartyOrderField]) ListChildThirdPartiesOutput { + thirdParties := make([]*ThirdParty, 0, len(thirdPartyPage.Data)) + for _, v := range thirdPartyPage.Data { + thirdParties = append(thirdParties, NewThirdParty(v)) + } + + var nextCursor *page.CursorKey + + if len(thirdPartyPage.Data) > 0 { + cursorKey := thirdPartyPage.Data[len(thirdPartyPage.Data)-1].CursorKey(thirdPartyPage.Cursor.OrderBy.Field) + nextCursor = &cursorKey + } + + return ListChildThirdPartiesOutput{ + NextCursor: nextCursor, + ThirdParties: thirdParties, + } +} + func NewAddThirdPartyOutput(v *coredata.ThirdParty) AddThirdPartyOutput { return AddThirdPartyOutput{ ThirdParty: NewThirdParty(v), diff --git a/pkg/trust/third_party_service.go b/pkg/trust/third_party_service.go index 3e70130d4..9b01ddb51 100644 --- a/pkg/trust/third_party_service.go +++ b/pkg/trust/third_party_service.go @@ -65,7 +65,7 @@ func (s ThirdPartyService) ListForOrganizationId( ctx, func(ctx context.Context, conn pg.Querier) error { showOnTrustCenter := true - filter := coredata.NewThirdPartyFilter(&showOnTrustCenter) + filter := coredata.NewThirdPartyFilter(&showOnTrustCenter, nil) err := thirdParties.LoadByOrganizationID(ctx, conn, scope, organizationID, cursor, filter) if err != nil { @@ -99,7 +99,7 @@ func (s ThirdPartyService) CountForTrustCenterId( thirdParties := &coredata.ThirdParties{} showOnTrustCenter := true - filter := coredata.NewThirdPartyFilter(&showOnTrustCenter) + filter := coredata.NewThirdPartyFilter(&showOnTrustCenter, nil) count, err = thirdParties.CountByOrganizationID(ctx, conn, scope, trustCenter.OrganizationID, filter) if err != nil {