Add third-party self-referential relations

Introduce a self-referential many-to-many relation table so a
third party can have child third parties. Each relation is
directional (parent to child); both directions can coexist as
independent rows.

Add a first_level boolean on third_parties (default true) with
a filter on the list page that defaults to showing only
first-level third parties.

Frontend adds a "Third Parties" tab on the detail page where
users can link existing third parties or create new ones from
the common third party catalog (created as non-first-level).
The list page gets a First Level/All toggle filter.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-05-19 19:13:16 +02:00
parent 8ff68798fb
commit b6b1e801b1
37 changed files with 2399 additions and 45 deletions

View File

@@ -24,6 +24,7 @@ type ThirdParty = {
id: string; id: string;
name: string; name: string;
websiteUrl: string | null | undefined; websiteUrl: string | null | undefined;
firstLevel?: boolean;
}; };
type Props<T extends FieldValues = FieldValues> = { type Props<T extends FieldValues = FieldValues> = {
@@ -66,7 +67,7 @@ function ThirdPartiesMultiSelectWithQuery<T extends FieldValues = FieldValues>(
const thirdParties = useThirdParties(organizationId); const thirdParties = useThirdParties(organizationId);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const allThirdParties = [...thirdParties]; const allThirdParties: ThirdParty[] = [...thirdParties];
if (props.disabled) { if (props.disabled) {
selectedThirdParties.forEach((selectedThirdParty) => { selectedThirdParties.forEach((selectedThirdParty) => {
if (!allThirdParties.find(v => v.id === selectedThirdParty.id)) { if (!allThirdParties.find(v => v.id === selectedThirdParty.id)) {

View File

@@ -138,6 +138,7 @@ export const paginatedThirdPartiesFragment = graphql`
after: { type: "CursorKey", defaultValue: null } after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null } before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null } last: { type: "Int", defaultValue: null }
filter: { type: "ThirdPartyFilter", defaultValue: { firstLevel: true } }
) { ) {
thirdParties( thirdParties(
first: $first first: $first
@@ -145,7 +146,8 @@ export const paginatedThirdPartiesFragment = graphql`
last: $last last: $last
before: $before before: $before
orderBy: $order orderBy: $order
) @connection(key: "ThirdPartiesListQuery_thirdParties") { filter: $filter
) @connection(key: "ThirdPartiesListQuery_thirdParties", filters: ["filter"]) {
__id __id
edges { edges {
node { node {
@@ -182,6 +184,7 @@ export const thirdPartyNodeQuery = graphql`
... on ThirdParty { ... on ThirdParty {
name name
websiteUrl websiteUrl
firstLevel
canAssess: permission(action: "core:thirdParty:assess") canAssess: permission(action: "core:thirdParty:assess")
canUpdate: permission(action: "core:thirdParty:update") canUpdate: permission(action: "core:thirdParty:update")
canDelete: permission(action: "core:thirdParty:delete") canDelete: permission(action: "core:thirdParty:delete")
@@ -224,6 +227,7 @@ export const thirdPartiesSelectQuery = graphql`
id id
name name
websiteUrl websiteUrl
firstLevel
} }
} }
} }

View File

@@ -26,12 +26,15 @@ import {
IconUpload, IconUpload,
PageHeader, PageHeader,
RiskBadge, RiskBadge,
TabItem,
Tabs,
Tbody, Tbody,
Td, Td,
Th, Th,
Thead, Thead,
Tr, Tr,
} from "@probo/ui"; } from "@probo/ui";
import { useState, useTransition } from "react";
import { import {
type PreloadedQuery, type PreloadedQuery,
usePaginationFragment, usePaginationFragment,
@@ -76,9 +79,21 @@ export default function ThirdPartiesPage(props: Props) {
const thirdParties = pagination.data.thirdParties?.edges.map(edge => edge.node); const thirdParties = pagination.data.thirdParties?.edges.map(edge => edge.node);
const connectionId = pagination.data.thirdParties.__id; const connectionId = pagination.data.thirdParties.__id;
const [, startTransition] = useTransition();
const [firstLevelFilter, setFirstLevelFilter] = useState<boolean | null>(true);
usePageTitle(__("Third parties")); usePageTitle(__("Third parties"));
const handleFilterChange = (firstLevel: boolean | null) => {
setFirstLevelFilter(firstLevel);
startTransition(() => {
pagination.refetch(
{ filter: firstLevel !== null ? { firstLevel } : {} },
{ fetchPolicy: "store-and-network" },
);
});
};
const hasAnyAction const hasAnyAction
= thirdParties.some(({ canUpdate, canDelete }) => canUpdate || canDelete); = thirdParties.some(({ canUpdate, canDelete }) => canUpdate || canDelete);
@@ -129,6 +144,20 @@ export default function ThirdPartiesPage(props: Props) {
)} )}
</div> </div>
</PageHeader> </PageHeader>
<Tabs>
<TabItem
active={firstLevelFilter === true}
onClick={() => handleFilterChange(true)}
>
{__("First Level")}
</TabItem>
<TabItem
active={firstLevelFilter === null}
onClick={() => handleFilterChange(null)}
>
{__("All")}
</TabItem>
</Tabs>
<SortableTable {...pagination}> <SortableTable {...pagination}>
<Thead> <Thead>
<Tr> <Tr>

View File

@@ -16,6 +16,7 @@ import { faviconUrl } from "@probo/helpers";
import { useTranslate } from "@probo/i18n"; import { useTranslate } from "@probo/i18n";
import { import {
ActionDropdown, ActionDropdown,
Badge,
Breadcrumb, Breadcrumb,
Button, Button,
DropdownItem, DropdownItem,
@@ -91,7 +92,12 @@ export default function ThirdPartyDetailPage(props: Props) {
className="shadow-mid rounded-2xl" className="shadow-mid rounded-2xl"
/> />
)} )}
<div className="flex items-center gap-3">
<div className="text-2xl">{thirdParty.name}</div> <div className="text-2xl">{thirdParty.name}</div>
<Badge variant={thirdParty.firstLevel ? "info" : "neutral"}>
{thirdParty.firstLevel ? __("First Level") : __("Indirect")}
</Badge>
</div>
</div> </div>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
{thirdParty.canAssess && ( {thirdParty.canAssess && (
@@ -127,6 +133,9 @@ export default function ThirdPartyDetailPage(props: Props) {
<TabLink to={`${baseThirdPartyUrl}/risks`}>{__("Risk Assessment")}</TabLink> <TabLink to={`${baseThirdPartyUrl}/risks`}>{__("Risk Assessment")}</TabLink>
<TabLink to={`${baseThirdPartyUrl}/contacts`}>{__("Contacts")}</TabLink> <TabLink to={`${baseThirdPartyUrl}/contacts`}>{__("Contacts")}</TabLink>
<TabLink to={`${baseThirdPartyUrl}/services`}>{__("Services")}</TabLink> <TabLink to={`${baseThirdPartyUrl}/services`}>{__("Services")}</TabLink>
<TabLink to={`${baseThirdPartyUrl}/third-parties`}>
{__("Third Parties")}
</TabLink>
</Tabs> </Tabs>
<Outlet context={{ thirdParty }} /> <Outlet context={{ thirdParty }} />

View File

@@ -0,0 +1,187 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { 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<AddChildThirdPartyDialogCreateMappingMutation>(createMappingMutation);
const [createThirdParty] = useMutation<AddChildThirdPartyDialogCreateMutation>(createThirdPartyMutation);
const [searchQuery, setSearchQuery] = useState("");
const [queryRef, loadQuery] = useQueryLoader<CommonThirdPartyComboboxQuery>(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<CreateThirdPartyInput, "organizationId">) => {
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 (
<Dialog ref={dialogRef} trigger={children} title={__("Add a third party")}>
<DialogContent className="p-6">
<Combobox onSearch={handleSearch} placeholder={__("Type third party's name")}>
{existingThirdParties.map(tp => (
<ComboboxItem key={tp.id} onClick={() => handleSelectExisting(tp.id)}>
<Avatar name={tp.name} src={faviconUrl(tp.websiteUrl)} size="s" />
{tp.name}
</ComboboxItem>
))}
{searchQuery.trim().length >= 2 && queryRef && (
<Suspense>
<CommonThirdPartyCombobox
queryRef={queryRef}
excludeNames={existingNames}
onSelect={handleSelectCommon}
/>
</Suspense>
)}
</Combobox>
</DialogContent>
<DialogFooter />
</Dialog>
);
}

View File

@@ -27,7 +27,7 @@ import type { CreateThirdPartyInput } from "#/__generated__/core/ThirdPartyGraph
export type CommonThirdPartyRef export type CommonThirdPartyRef
= CommonThirdPartyCombobox_commonThirdParty$data; = CommonThirdPartyCombobox_commonThirdParty$data;
const commonThirdPartyFragment = graphql` export const commonThirdPartyFragment = graphql`
fragment CommonThirdPartyCombobox_commonThirdParty on CommonThirdParty @inline { fragment CommonThirdPartyCombobox_commonThirdParty on CommonThirdParty @inline {
name name
logoUrl logoUrl
@@ -57,28 +57,8 @@ export const commonThirdPartiesQuery = graphql`
} }
`; `;
interface CommonThirdPartyComboboxProps { function toCreateInput(tp: CommonThirdPartyRef): Omit<CreateThirdPartyInput, "organizationId"> {
queryRef: PreloadedQuery<CommonThirdPartyComboboxQuery>; return {
onSelect: (thridParty: Omit<CreateThirdPartyInput, "organizationId">) => void;
}
export function CommonThirdPartyCombobox({
queryRef,
onSelect,
}: CommonThirdPartyComboboxProps) {
const data = usePreloadedQuery(commonThirdPartiesQuery, queryRef);
return (
<>
{data.commonThirdParties.map(thirdParty => (
<ComboboxItem
key={thirdParty.id}
onClick={() => {
const tp = readInlineData<CommonThirdPartyCombobox_commonThirdParty$key>(
commonThirdPartyFragment,
thirdParty,
);
onSelect({
name: tp.name, name: tp.name,
headquarterAddress: tp.headquarterAddress, headquarterAddress: tp.headquarterAddress,
legalName: tp.legalName, legalName: tp.legalName,
@@ -92,7 +72,37 @@ export function CommonThirdPartyCombobox({
trustPageUrl: tp.trustPageUrl, trustPageUrl: tp.trustPageUrl,
statusPageUrl: tp.statusPageUrl, statusPageUrl: tp.statusPageUrl,
termsOfServiceUrl: tp.termsOfServiceUrl, termsOfServiceUrl: tp.termsOfServiceUrl,
}); };
}
interface CommonThirdPartyComboboxProps {
queryRef: PreloadedQuery<CommonThirdPartyComboboxQuery>;
onSelect: (thirdParty: Omit<CreateThirdPartyInput, "organizationId">) => void;
excludeNames?: Set<string>;
}
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 (
<>
{items.map(thirdParty => (
<ComboboxItem
key={thirdParty.id}
onClick={() => {
const tp = readInlineData<CommonThirdPartyCombobox_commonThirdParty$key>(
commonThirdPartyFragment,
thirdParty,
);
onSelect(toCreateInput(tp));
}} }}
> >
<Avatar <Avatar

View File

@@ -12,8 +12,10 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE. // PERFORMANCE OF THIS SOFTWARE.
import { faviconUrl } from "@probo/helpers";
import { useTranslate } from "@probo/i18n"; import { useTranslate } from "@probo/i18n";
import { import {
Avatar,
Combobox, Combobox,
ComboboxItem, ComboboxItem,
Dialog, Dialog,
@@ -23,17 +25,30 @@ import {
useDialogRef, useDialogRef,
} from "@probo/ui"; } from "@probo/ui";
import { type ReactNode, Suspense, useCallback, useState } from "react"; import { type ReactNode, Suspense, useCallback, useState } from "react";
import { useQueryLoader } from "react-relay"; import { useMutation, useQueryLoader } from "react-relay";
import { ConnectionHandler, graphql } from "relay-runtime";
import { useDebounceCallback } from "usehooks-ts"; import { useDebounceCallback } from "usehooks-ts";
import type { CommonThirdPartyComboboxQuery } from "#/__generated__/core/CommonThirdPartyComboboxQuery.graphql"; import type { CommonThirdPartyComboboxQuery } from "#/__generated__/core/CommonThirdPartyComboboxQuery.graphql";
import type { CreateThirdPartyDialogPromoteMutation } from "#/__generated__/core/CreateThirdPartyDialogPromoteMutation.graphql";
import type { CreateThirdPartyInput } from "#/__generated__/core/ThirdPartyGraphCreateMutation.graphql"; import type { CreateThirdPartyInput } from "#/__generated__/core/ThirdPartyGraphCreateMutation.graphql";
import { useCreateThirdPartyMutation } from "#/hooks/graph/ThirdPartyGraph"; import { useCreateThirdPartyMutation, useThirdParties } from "#/hooks/graph/ThirdPartyGraph";
import { import { commonThirdPartiesQuery, CommonThirdPartyCombobox } from "./CommonThirdPartyCombobox";
commonThirdPartiesQuery,
CommonThirdPartyCombobox, const promoteMutation = graphql`
} from "./CommonThirdPartyCombobox"; mutation CreateThirdPartyDialogPromoteMutation(
$input: UpdateThirdPartyInput!
) {
updateThirdParty(input: $input) {
thirdParty {
id
name
firstLevel
}
}
}
`;
type Props = { type Props = {
children: ReactNode; children: ReactNode;
@@ -48,12 +63,48 @@ export function CreateThirdPartyDialog({
}: Props) { }: Props) {
const { __ } = useTranslate(); const { __ } = useTranslate();
const [createThirdParty] = useCreateThirdPartyMutation(); const [createThirdParty] = useCreateThirdPartyMutation();
const [promoteThirdParty] = useMutation<CreateThirdPartyDialogPromoteMutation>(promoteMutation);
const thirdParties = useThirdParties(organizationId);
const dialogRef = useDialogRef(); const dialogRef = useDialogRef();
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [queryRef, loadQuery] const [queryRef, loadQuery]
= useQueryLoader<CommonThirdPartyComboboxQuery>(commonThirdPartiesQuery); = useQueryLoader<CommonThirdPartyComboboxQuery>(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<CreateThirdPartyInput, "organizationId"> | string) => { const onSelect = async (thirdParty: Omit<CreateThirdPartyInput, "organizationId"> | 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 const input
= typeof thirdParty === "string" = typeof thirdParty === "string"
? { ? {
@@ -98,10 +149,26 @@ export function CreateThirdPartyDialog({
<Dialog ref={dialogRef} trigger={children} title={__("Add a third party")}> <Dialog ref={dialogRef} trigger={children} title={__("Add a third party")}>
<DialogContent className="p-6"> <DialogContent className="p-6">
<Combobox onSearch={handleSearch} placeholder={__("Type third party's name")}> <Combobox onSearch={handleSearch} placeholder={__("Type third party's name")}>
{searchQuery.trim().length >= 2 && (
<>
{thirdParties
.filter(tp =>
!tp.firstLevel
&& tp.name.toLowerCase().includes(searchQuery.toLowerCase()),
)
.map(tp => (
<ComboboxItem key={tp.id} onClick={() => void onSelect(tp.name)}>
<Avatar name={tp.name} src={faviconUrl(tp.websiteUrl)} size="s" />
{tp.name}
</ComboboxItem>
))}
</>
)}
{searchQuery.trim().length >= 2 && queryRef && ( {searchQuery.trim().length >= 2 && queryRef && (
<Suspense> <Suspense>
<CommonThirdPartyCombobox <CommonThirdPartyCombobox
queryRef={queryRef} queryRef={queryRef}
excludeNames={existingNames}
onSelect={thirdPartyRef => void onSelect(thirdPartyRef)} onSelect={thirdPartyRef => void onSelect(thirdPartyRef)}
/> />
</Suspense> </Suspense>

View File

@@ -0,0 +1,237 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { 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<ThirdPartyThirdPartiesPageQuery>;
}
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<ThirdPartyThirdPartiesPageDeleteMappingMutation>(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<void>((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 (
<div className="space-y-6">
<PageHeader
title={__("Third Parties")}
description={__("Manage third parties linked to this third party.")}
>
{thirdParty.canUpdate && (
<AddChildThirdPartyDialog
parentThirdPartyId={thirdParty.id}
organizationId={organizationId}
connectionId={connectionId}
existingChildIds={childThirdParties.map(c => c.id)}
>
<Button icon={IconPlusLarge}>{__("Add third party")}</Button>
</AddChildThirdPartyDialog>
)}
</PageHeader>
<SortableTable
refetch={pagination.refetch as ComponentProps<typeof SortableTable>["refetch"]}
>
<Thead>
<Tr>
<SortableTh field="NAME">{__("Third party")}</SortableTh>
<Th>{__("Accessed At")}</Th>
<Th>{__("Data Risk")}</Th>
<Th>{__("Business Risk")}</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{childThirdParties.map((child) => {
const latestAssessment = child.riskAssessments?.edges[0]?.node;
return (
<Tr
key={child.id}
to={`/organizations/${organizationId}/third-parties/${child.id}/overview`}
>
<Td>
<div className="flex gap-2 items-center">
<Avatar name={child.name} src={faviconUrl(child.websiteUrl)} />
<div>{child.name}</div>
</div>
</Td>
<Td>
{latestAssessment?.createdAt
? formatDate(latestAssessment.createdAt)
: __("Not assessed")}
</Td>
<Td>
<RiskBadge level={latestAssessment?.dataSensitivity ?? "NONE"} />
</Td>
<Td>
<RiskBadge level={latestAssessment?.businessImpact ?? "NONE"} />
</Td>
<Td noLink width={50} className="text-end">
{thirdParty.canUpdate && (
<Button
variant="tertiary"
icon={IconTrashCan}
onClick={() => handleRemove(child.id, child.name)}
/>
)}
</Td>
</Tr>
);
})}
</Tbody>
</SortableTable>
</div>
);
}

View File

@@ -0,0 +1,43 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { 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>(thirdPartyThirdPartiesPageQuery);
useEffect(() => {
if (thirdPartyId) {
loadQuery({ thirdPartyId });
}
}, [loadQuery, thirdPartyId]);
if (!queryRef) {
return <PageSkeleton />;
}
return (
<Suspense fallback={<PageSkeleton />}>
<ThirdPartyThirdPartiesPage queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -97,6 +97,14 @@ export const thirdPartyRoutes = [
() => import("../pages/organizations/third-parties/tabs/ThirdPartyServicesTab"), () => import("../pages/organizations/third-parties/tabs/ThirdPartyServicesTab"),
), ),
}, },
{
path: "third-parties",
Fallback: LinkCardSkeleton,
Component: lazy(
() =>
import("../pages/organizations/third-parties/third-parties/ThirdPartyThirdPartiesPageLoader"),
),
},
], ],
}, },
] satisfies AppRoute[]; ] satisfies AppRoute[];

View File

@@ -0,0 +1,508 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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
}

View File

@@ -1,4 +1,4 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>. // Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
// //
// Permission to use, copy, modify, and/or distribute this software for any // Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above // purpose with or without fee is hereby granted, provided that the above
@@ -183,6 +183,13 @@ export const description: INodeProperties[] = [
type: 'string', type: 'string',
default: '', default: '',
}, },
{
displayName: 'Root',
name: 'firstLevel',
type: 'boolean',
default: false,
description: 'Whether this is a first-level third party',
},
{ {
displayName: 'Security Page URL', displayName: 'Security Page URL',
name: 'securityPageUrl', name: 'securityPageUrl',
@@ -249,6 +256,7 @@ export async function execute(
trustPageUrl?: string; trustPageUrl?: string;
certifications?: string; certifications?: string;
countries?: string; countries?: string;
firstLevel?: boolean;
}; };
const query = ` const query = `
@@ -275,6 +283,7 @@ export async function execute(
certifications certifications
countries countries
showOnTrustCenter showOnTrustCenter
firstLevel
createdAt createdAt
updatedAt updatedAt
} }
@@ -303,6 +312,7 @@ export async function execute(
if (additionalFields.subprocessorsListUrl) input.subprocessorsListUrl = additionalFields.subprocessorsListUrl; if (additionalFields.subprocessorsListUrl) input.subprocessorsListUrl = additionalFields.subprocessorsListUrl;
if (additionalFields.securityPageUrl) input.securityPageUrl = additionalFields.securityPageUrl; if (additionalFields.securityPageUrl) input.securityPageUrl = additionalFields.securityPageUrl;
if (additionalFields.trustPageUrl) input.trustPageUrl = additionalFields.trustPageUrl; if (additionalFields.trustPageUrl) input.trustPageUrl = additionalFields.trustPageUrl;
if (additionalFields.firstLevel !== undefined) input.firstLevel = additionalFields.firstLevel;
if (additionalFields.certifications) { if (additionalFields.certifications) {
input.certifications = additionalFields.certifications.split(',').map((c) => c.trim()).filter(Boolean); input.certifications = additionalFields.certifications.split(',').map((c) => c.trim()).filter(Boolean);
} }

View File

@@ -1,4 +1,4 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>. // Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
// //
// Permission to use, copy, modify, and/or distribute this software for any // Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above // purpose with or without fee is hereby granted, provided that the above
@@ -73,6 +73,13 @@ export const description: INodeProperties[] = [
}, },
}, },
options: [ options: [
{
displayName: 'Filter by Root',
name: 'filterFirstLevel',
type: 'boolean',
default: false,
description: 'Whether to filter by first-level third parties only',
},
{ {
displayName: 'Include Organization', displayName: 'Include Organization',
name: 'includeOrganization', name: 'includeOrganization',
@@ -106,6 +113,7 @@ export async function execute(
const returnAll = this.getNodeParameter('returnAll', itemIndex) as boolean; const returnAll = this.getNodeParameter('returnAll', itemIndex) as boolean;
const limit = this.getNodeParameter('limit', itemIndex, 50) as number; const limit = this.getNodeParameter('limit', itemIndex, 50) as number;
const options = this.getNodeParameter('options', itemIndex, {}) as { const options = this.getNodeParameter('options', itemIndex, {}) as {
filterFirstLevel?: boolean;
includeOrganization?: boolean; includeOrganization?: boolean;
includeBusinessOwner?: boolean; includeBusinessOwner?: boolean;
includeSecurityOwner?: 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 = ` const query = `
query GetThirdParties($organizationId: ID!, $first: Int, $after: CursorKey) { query GetThirdParties($organizationId: ID!, $first: Int, $after: CursorKey${filterVariable}) {
node(id: $organizationId) { node(id: $organizationId) {
... on Organization { ... on Organization {
thirdParties(first: $first, after: $after) { thirdParties(first: $first, after: $after${filterArgument}) {
edges { edges {
node { node {
id id
@@ -160,6 +171,7 @@ export async function execute(
certifications certifications
countries countries
showOnTrustCenter showOnTrustCenter
firstLevel
${organizationFragment} ${organizationFragment}
${businessOwnerFragment} ${businessOwnerFragment}
${securityOwnerFragment} ${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( const thirdParties = await proboApiRequestAllItems.call(
this, this,
query, query,
{ organizationId }, variables,
(response) => { (response) => {
const data = response?.data as IDataObject | undefined; const data = response?.data as IDataObject | undefined;
const node = data?.node as IDataObject | undefined; const node = data?.node as IDataObject | undefined;

View File

@@ -39,6 +39,9 @@ import * as updateBusinessAssociateAgreementOp from './updateBusinessAssociateAg
import * as getDataPrivacyAgreementOp from './getDataPrivacyAgreement.operation'; import * as getDataPrivacyAgreementOp from './getDataPrivacyAgreement.operation';
import * as deleteDataPrivacyAgreementOp from './deleteDataPrivacyAgreement.operation'; import * as deleteDataPrivacyAgreementOp from './deleteDataPrivacyAgreement.operation';
import * as updateDataPrivacyAgreementOp from './updateDataPrivacyAgreement.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'; import * as publishOp from './publish.operation';
export const description: INodeProperties[] = [ export const description: INodeProperties[] = [
@@ -143,6 +146,12 @@ export const description: INodeProperties[] = [
description: 'Get many third parties', description: 'Get many third parties',
action: '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', name: 'Get Many Compliance Reports',
value: 'getAllComplianceReports', value: 'getAllComplianceReports',
@@ -179,12 +188,24 @@ export const description: INodeProperties[] = [
description: 'Get a third party service', description: 'Get a third party service',
action: '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', name: 'Publish List',
value: 'publish', value: 'publish',
description: 'Publish the third party register as a document version', description: 'Publish the third party register as a document version',
action: 'Publish the third party register', 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', name: 'Update',
value: 'update', value: 'update',
@@ -244,6 +265,9 @@ export const description: INodeProperties[] = [
...getDataPrivacyAgreementOp.description, ...getDataPrivacyAgreementOp.description,
...deleteDataPrivacyAgreementOp.description, ...deleteDataPrivacyAgreementOp.description,
...updateDataPrivacyAgreementOp.description, ...updateDataPrivacyAgreementOp.description,
...linkThirdPartyOp.description,
...unlinkThirdPartyOp.description,
...listChildThirdPartiesOp.description,
...publishOp.description, ...publishOp.description,
]; ];
@@ -274,5 +298,8 @@ export {
getDataPrivacyAgreementOp as getDataPrivacyAgreement, getDataPrivacyAgreementOp as getDataPrivacyAgreement,
deleteDataPrivacyAgreementOp as deleteDataPrivacyAgreement, deleteDataPrivacyAgreementOp as deleteDataPrivacyAgreement,
updateDataPrivacyAgreementOp as updateDataPrivacyAgreement, updateDataPrivacyAgreementOp as updateDataPrivacyAgreement,
linkThirdPartyOp as linkThirdParty,
unlinkThirdPartyOp as unlinkThirdParty,
listChildThirdPartiesOp as listChildThirdParties,
publishOp as publish, publishOp as publish,
}; };

View File

@@ -0,0 +1,75 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import 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<INodeExecutionData> {
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 },
};
}

View File

@@ -0,0 +1,118 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import 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<INodeExecutionData> {
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 },
};
}

View File

@@ -0,0 +1,70 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import 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<INodeExecutionData> {
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 },
};
}

View File

@@ -0,0 +1,108 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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 <parent-id> <child-id>",
Short: "Link a child thirdParty to a parent thirdParty",
Example: ` # Link a child third_party to a parent
prb thirdParty link <parent-id> <child-id>`,
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
}

View File

@@ -24,11 +24,11 @@ import (
) )
const listQuery = ` 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) { node(id: $id) {
__typename __typename
... on Organization { ... on Organization {
third_parties(first: $first, after: $after, orderBy: $orderBy) { third_parties(first: $first, after: $after, orderBy: $orderBy, filter: $filter) {
totalCount totalCount
edges { edges {
node { node {
@@ -59,6 +59,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
flagLimit int flagLimit int
flagOrderBy string flagOrderBy string
flagOrderDir string flagOrderDir string
flagFirstLevel bool
flagOutput *string flagOutput *string
) )
@@ -107,6 +108,12 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
"id": flagOrg, "id": flagOrg,
} }
if cmd.Flags().Changed("first-level") {
variables["filter"] = map[string]any{
"first-level": flagFirstLevel,
}
}
if flagOrderBy != "" { if flagOrderBy != "" {
if err := cmdutil.ValidateEnum("order-by", flagOrderBy, []string{"NAME", "CREATED_AT", "UPDATED_AT"}); err != nil { if err := cmdutil.ValidateEnum("order-by", flagOrderBy, []string{"NAME", "CREATED_AT", "UPDATED_AT"}); err != nil {
return err 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().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(&flagOrderBy, "order-by", "", "Order by field (NAME, CREATED_AT, UPDATED_AT)")
cmd.Flags().StringVar(&flagOrderDir, "order-direction", "DESC", "Sort direction (ASC, DESC)") 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) flagOutput = cmdutil.AddOutputFlag(cmd)
return cmd return cmd

View File

@@ -20,8 +20,10 @@ import (
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/assess" "go.probo.inc/probo/pkg/cmd/thirdpartymgmt/assess"
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/create" "go.probo.inc/probo/pkg/cmd/thirdpartymgmt/create"
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/delete" "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/list"
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/publish" "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/update"
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/view" "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(delete.NewCmdDelete(f))
cmd.AddCommand(assess.NewCmdAssess(f)) cmd.AddCommand(assess.NewCmdAssess(f))
cmd.AddCommand(publish.NewCmdPublish(f)) cmd.AddCommand(publish.NewCmdPublish(f))
cmd.AddCommand(link.NewCmdLink(f))
cmd.AddCommand(unlink.NewCmdUnlink(f))
return cmd return cmd
} }

View File

@@ -0,0 +1,84 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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 <parent-id> <child-id>",
Short: "Unlink a child thirdParty from a parent thirdParty",
Example: ` # Unlink a child third_party from a parent
prb thirdParty unlink <parent-id> <child-id>`,
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
}

View File

@@ -0,0 +1,25 @@
-- Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
--
-- Permission to use, copy, modify, and/or distribute this software for any
-- purpose with or without fee is hereby granted, provided that the above
-- copyright notice and this permission notice appear in all copies.
--
-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
-- PERFORMANCE OF THIS SOFTWARE.
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)
);

View File

@@ -162,6 +162,7 @@ type (
SecurityPageURL *string `db:"security_page_url"` SecurityPageURL *string `db:"security_page_url"`
TrustPageURL *string `db:"trust_page_url"` TrustPageURL *string `db:"trust_page_url"`
ShowOnTrustCenter bool `db:"show_on_trust_center"` ShowOnTrustCenter bool `db:"show_on_trust_center"`
FirstLevel bool `db:"first_level"`
CreatedAt time.Time `db:"created_at"` CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"` UpdatedAt time.Time `db:"updated_at"`
} }
@@ -253,6 +254,7 @@ SELECT
security_page_url, security_page_url,
trust_page_url, trust_page_url,
show_on_trust_center, show_on_trust_center,
first_level,
created_at, created_at,
updated_at updated_at
FROM FROM
@@ -320,6 +322,7 @@ SELECT
security_page_url, security_page_url,
trust_page_url, trust_page_url,
show_on_trust_center, show_on_trust_center,
first_level,
created_at, created_at,
updated_at updated_at
FROM FROM
@@ -381,6 +384,7 @@ INSERT INTO
security_page_url, security_page_url,
trust_page_url, trust_page_url,
show_on_trust_center, show_on_trust_center,
first_level,
created_at, created_at,
updated_at updated_at
) )
@@ -409,6 +413,7 @@ VALUES (
@security_page_url, @security_page_url,
@trust_page_url, @trust_page_url,
@show_on_trust_center, @show_on_trust_center,
@first_level,
@created_at, @created_at,
@updated_at @updated_at
) )
@@ -439,6 +444,7 @@ VALUES (
"security_page_url": v.SecurityPageURL, "security_page_url": v.SecurityPageURL,
"trust_page_url": v.TrustPageURL, "trust_page_url": v.TrustPageURL,
"show_on_trust_center": v.ShowOnTrustCenter, "show_on_trust_center": v.ShowOnTrustCenter,
"first_level": v.FirstLevel,
"created_at": v.CreatedAt, "created_at": v.CreatedAt,
"updated_at": v.UpdatedAt, "updated_at": v.UpdatedAt,
} }
@@ -534,6 +540,7 @@ SELECT
security_page_url, security_page_url,
trust_page_url, trust_page_url,
show_on_trust_center, show_on_trust_center,
first_level,
created_at, created_at,
updated_at updated_at
FROM FROM
@@ -597,6 +604,7 @@ SELECT
security_page_url, security_page_url,
trust_page_url, trust_page_url,
show_on_trust_center, show_on_trust_center,
first_level,
created_at, created_at,
updated_at updated_at
FROM FROM
@@ -657,6 +665,7 @@ SET
business_owner_profile_id = @business_owner_profile_id, business_owner_profile_id = @business_owner_profile_id,
security_owner_profile_id = @security_owner_profile_id, security_owner_profile_id = @security_owner_profile_id,
show_on_trust_center = @show_on_trust_center, show_on_trust_center = @show_on_trust_center,
first_level = @first_level,
updated_at = @updated_at updated_at = @updated_at
WHERE %s WHERE %s
AND id = @third_party_id AND id = @third_party_id
@@ -686,6 +695,7 @@ WHERE %s
"business_owner_profile_id": v.BusinessOwnerID, "business_owner_profile_id": v.BusinessOwnerID,
"security_owner_profile_id": v.SecurityOwnerID, "security_owner_profile_id": v.SecurityOwnerID,
"show_on_trust_center": v.ShowOnTrustCenter, "show_on_trust_center": v.ShowOnTrustCenter,
"first_level": v.FirstLevel,
} }
maps.Copy(args, scope.SQLArguments()) maps.Copy(args, scope.SQLArguments())
@@ -803,6 +813,7 @@ WITH vend AS (
v.security_page_url, v.security_page_url,
v.trust_page_url, v.trust_page_url,
v.show_on_trust_center, v.show_on_trust_center,
v.first_level,
v.created_at, v.created_at,
v.updated_at v.updated_at
FROM FROM
@@ -837,6 +848,7 @@ SELECT
security_page_url, security_page_url,
trust_page_url, trust_page_url,
show_on_trust_center, show_on_trust_center,
first_level,
created_at, created_at,
updated_at updated_at
FROM FROM
@@ -938,6 +950,7 @@ WITH vend AS (
v.security_page_url, v.security_page_url,
v.trust_page_url, v.trust_page_url,
v.show_on_trust_center, v.show_on_trust_center,
v.first_level,
v.created_at, v.created_at,
v.updated_at v.updated_at
FROM FROM
@@ -972,6 +985,7 @@ SELECT
security_page_url, security_page_url,
trust_page_url, trust_page_url,
show_on_trust_center, show_on_trust_center,
first_level,
created_at, created_at,
updated_at updated_at
FROM FROM
@@ -1033,6 +1047,7 @@ WITH vend AS (
v.security_page_url, v.security_page_url,
v.trust_page_url, v.trust_page_url,
v.show_on_trust_center, v.show_on_trust_center,
v.first_level,
v.created_at, v.created_at,
v.updated_at v.updated_at
FROM FROM
@@ -1067,6 +1082,7 @@ SELECT
security_page_url, security_page_url,
trust_page_url, trust_page_url,
show_on_trust_center, show_on_trust_center,
first_level,
created_at, created_at,
updated_at updated_at
FROM FROM
@@ -1129,6 +1145,7 @@ WITH vend AS (
v.security_page_url, v.security_page_url,
v.trust_page_url, v.trust_page_url,
v.show_on_trust_center, v.show_on_trust_center,
v.first_level,
v.created_at, v.created_at,
v.updated_at v.updated_at
FROM FROM
@@ -1163,6 +1180,7 @@ SELECT
security_page_url, security_page_url,
trust_page_url, trust_page_url,
show_on_trust_center, show_on_trust_center,
first_level,
created_at, created_at,
updated_at updated_at
FROM FROM
@@ -1293,6 +1311,7 @@ WITH vend AS (
v.security_page_url, v.security_page_url,
v.trust_page_url, v.trust_page_url,
v.show_on_trust_center, v.show_on_trust_center,
v.first_level,
v.created_at, v.created_at,
v.updated_at v.updated_at
FROM FROM
@@ -1327,6 +1346,7 @@ SELECT
security_page_url, security_page_url,
trust_page_url, trust_page_url,
show_on_trust_center, show_on_trust_center,
first_level,
created_at, created_at,
updated_at updated_at
FROM FROM
@@ -1387,6 +1407,7 @@ SELECT
security_page_url, security_page_url,
trust_page_url, trust_page_url,
show_on_trust_center, show_on_trust_center,
first_level,
created_at, created_at,
updated_at updated_at
FROM FROM

View File

@@ -21,12 +21,14 @@ import (
type ( type (
ThirdPartyFilter struct { ThirdPartyFilter struct {
showOnTrustCenter *bool showOnTrustCenter *bool
firstLevel *bool
} }
) )
func NewThirdPartyFilter(showOnTrustCenter *bool) *ThirdPartyFilter { func NewThirdPartyFilter(showOnTrustCenter *bool, firstLevel *bool) *ThirdPartyFilter {
return &ThirdPartyFilter{ return &ThirdPartyFilter{
showOnTrustCenter: showOnTrustCenter, showOnTrustCenter: showOnTrustCenter,
firstLevel: firstLevel,
} }
} }
@@ -39,6 +41,12 @@ func (f *ThirdPartyFilter) SQLArguments() pgx.StrictNamedArgs {
args["show_on_trust_center"] = nil args["show_on_trust_center"] = nil
} }
if f.firstLevel != nil {
args["first_level"] = *f.firstLevel
} else {
args["first_level"] = nil
}
return args return args
} }
@@ -50,5 +58,13 @@ func (f *ThirdPartyFilter) SQLFragment() string {
show_on_trust_center = @show_on_trust_center::boolean show_on_trust_center = @show_on_trust_center::boolean
ELSE TRUE ELSE TRUE
END END
)
AND
(
CASE
WHEN @first_level::boolean IS NOT NULL THEN
first_level = @first_level::boolean
ELSE TRUE
END
)` )`
} }

View File

@@ -0,0 +1,226 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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
}

View File

@@ -92,6 +92,11 @@ const (
ActionThirdPartyAssess = "core:thirdParty:assess" ActionThirdPartyAssess = "core:thirdParty:assess"
ActionThirdPartyPublish = "core:thirdParty:publish" ActionThirdPartyPublish = "core:thirdParty:publish"
// ThirdPartyRelation actions
ActionThirdPartyRelationCreate = "core:thirdParty-relation:create"
ActionThirdPartyRelationDelete = "core:thirdParty-relation:delete"
ActionThirdPartyRelationList = "core:thirdParty-relation:list"
// ThirdPartyContact actions // ThirdPartyContact actions
ActionThirdPartyContactGet = "core:thirdParty-contact:get" ActionThirdPartyContactGet = "core:thirdParty-contact:get"
ActionThirdPartyContactList = "core:thirdParty-contact:list" ActionThirdPartyContactList = "core:thirdParty-contact:list"

View File

@@ -55,6 +55,7 @@ var ViewerPolicy = policy.NewPolicy(
ActionThirdPartyBusinessAssociateAgreementGet, ActionThirdPartyBusinessAssociateAgreementGet,
ActionThirdPartyDataPrivacyAgreementGet, ActionThirdPartyDataPrivacyAgreementGet,
ActionThirdPartyRiskAssessmentList, ActionThirdPartyRiskAssessmentList,
ActionThirdPartyRelationList,
ActionFrameworkGet, ActionFrameworkList, ActionFrameworkGet, ActionFrameworkList,
ActionControlGet, ActionControlList, ActionControlGet, ActionControlList,
ActionMeasureGet, ActionMeasureList, ActionMeasureGet, ActionMeasureList,
@@ -140,6 +141,7 @@ var AuditorPolicy = policy.NewPolicy(
ActionThirdPartyBusinessAssociateAgreementGet, ActionThirdPartyBusinessAssociateAgreementGet,
ActionThirdPartyDataPrivacyAgreementGet, ActionThirdPartyDataPrivacyAgreementGet,
ActionThirdPartyRiskAssessmentList, ActionThirdPartyRiskAssessmentList,
ActionThirdPartyRelationList,
ActionFrameworkGet, ActionFrameworkList, ActionFrameworkGet, ActionFrameworkList,
ActionControlGet, ActionControlList, ActionControlGet, ActionControlList,
ActionMeasureGet, ActionMeasureList, ActionMeasureGet, ActionMeasureList,

View File

@@ -92,6 +92,7 @@ type (
StatusPageURL *string StatusPageURL *string
BusinessOwnerID *gid.GID BusinessOwnerID *gid.GID
SecurityOwnerID *gid.GID SecurityOwnerID *gid.GID
FirstLevel *bool
} }
UpdateThirdPartyRequest struct { UpdateThirdPartyRequest struct {
@@ -116,6 +117,7 @@ type (
BusinessOwnerID **gid.GID BusinessOwnerID **gid.GID
SecurityOwnerID **gid.GID SecurityOwnerID **gid.GID
ShowOnTrustCenter *bool ShowOnTrustCenter *bool
FirstLevel *bool
} }
AssessThirdPartyRequest struct { AssessThirdPartyRequest struct {
@@ -386,6 +388,10 @@ func (s ThirdPartyService) Update(
thirdParty.ShowOnTrustCenter = *req.ShowOnTrustCenter thirdParty.ShowOnTrustCenter = *req.ShowOnTrustCenter
} }
if req.FirstLevel != nil {
thirdParty.FirstLevel = *req.FirstLevel
}
if req.TrustPageURL != nil { if req.TrustPageURL != nil {
thirdParty.TrustPageURL = *req.TrustPageURL thirdParty.TrustPageURL = *req.TrustPageURL
} }
@@ -573,6 +579,11 @@ func (s ThirdPartyService) Create(
StatusPageURL: req.StatusPageURL, StatusPageURL: req.StatusPageURL,
TermsOfServiceURL: req.TermsOfServiceURL, TermsOfServiceURL: req.TermsOfServiceURL,
ShowOnTrustCenter: false, ShowOnTrustCenter: false,
FirstLevel: true,
}
if req.FirstLevel != nil {
thirdParty.FirstLevel = *req.FirstLevel
} }
err := s.svc.pg.WithTx( err := s.svc.pg.WithTx(
@@ -947,3 +958,116 @@ func (s ThirdPartyService) Assess(
Subprocessors: subprocessors, Subprocessors: subprocessors,
}, nil }, 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
}

View File

@@ -331,6 +331,7 @@ type Organization implements Node {
last: Int last: Int
before: CursorKey before: CursorKey
orderBy: ThirdPartyOrder orderBy: ThirdPartyOrder
filter: ThirdPartyFilter
): ThirdPartyConnection! @goField(forceResolver: true) ): ThirdPartyConnection! @goField(forceResolver: true)
thirdPartiesDocument: Document @goField(forceResolver: true) thirdPartiesDocument: Document @goField(forceResolver: true)

View File

@@ -177,6 +177,10 @@ input ThirdPartyOrder
field: ThirdPartyOrderField! field: ThirdPartyOrderField!
} }
input ThirdPartyFilter {
firstLevel: Boolean
}
input ThirdPartyComplianceReportOrder input ThirdPartyComplianceReportOrder
@goModel( @goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.ThirdPartyComplianceReportOrderBy" model: "go.probo.inc/probo/pkg/server/api/console/v1/types.ThirdPartyComplianceReportOrderBy"
@@ -269,6 +273,16 @@ type ThirdParty implements Node {
legalName: String legalName: String
websiteUrl: String websiteUrl: String
showOnTrustCenter: Boolean! showOnTrustCenter: Boolean!
firstLevel: Boolean!
childThirdParties(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: ThirdPartyOrder
): ThirdPartyConnection! @goField(forceResolver: true)
createdAt: Datetime! createdAt: Datetime!
updatedAt: Datetime! updatedAt: Datetime!
@@ -460,6 +474,12 @@ extend type Mutation {
publishThirdPartyList( publishThirdPartyList(
input: PublishThirdPartyListInput! input: PublishThirdPartyListInput!
): PublishThirdPartyListPayload! ): PublishThirdPartyListPayload!
createThirdPartyThirdPartyMapping(
input: CreateThirdPartyThirdPartyMappingInput!
): CreateThirdPartyThirdPartyMappingPayload!
deleteThirdPartyThirdPartyMapping(
input: DeleteThirdPartyThirdPartyMappingInput!
): DeleteThirdPartyThirdPartyMappingPayload!
} }
input PublishThirdPartyListInput { input PublishThirdPartyListInput {
@@ -494,6 +514,7 @@ input CreateThirdPartyInput {
termsOfServiceUrl: String termsOfServiceUrl: String
businessOwnerId: ID businessOwnerId: ID
securityOwnerId: ID securityOwnerId: ID
firstLevel: Boolean
} }
input UpdateThirdPartyInput { input UpdateThirdPartyInput {
@@ -518,6 +539,7 @@ input UpdateThirdPartyInput {
businessOwnerId: ID @goField(omittable: true) businessOwnerId: ID @goField(omittable: true)
securityOwnerId: ID @goField(omittable: true) securityOwnerId: ID @goField(omittable: true)
showOnTrustCenter: Boolean showOnTrustCenter: Boolean
firstLevel: Boolean
} }
input DeleteThirdPartyInput { input DeleteThirdPartyInput {
@@ -709,3 +731,21 @@ type AssessThirdPartyPayload {
report: String! report: String!
subprocessors: [ThirdPartySubprocessor!]! subprocessors: [ThirdPartySubprocessor!]!
} }
input CreateThirdPartyThirdPartyMappingInput {
parentThirdPartyId: ID!
childThirdPartyId: ID!
}
type CreateThirdPartyThirdPartyMappingPayload {
thirdPartyEdge: ThirdPartyEdge!
}
input DeleteThirdPartyThirdPartyMappingInput {
parentThirdPartyId: ID!
childThirdPartyId: ID!
}
type DeleteThirdPartyThirdPartyMappingPayload {
removedThirdPartyId: ID!
}

View File

@@ -1271,7 +1271,7 @@ func (r *organizationResolver) CookieBanners(ctx context.Context, obj *types.Org
} }
// ThirdParties is the resolver for the thirdParties field. // 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) scope, err := r.authorize(ctx, obj.ID, probo.ActionThirdPartyList)
if err != nil { if err != nil {
return nil, err 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) 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) page, err := r.probo.ThirdParties.ListForOrganizationID(ctx, scope, obj.ID, cursor, thirdPartyFilter)
if err != nil { if err != nil {

View File

@@ -55,6 +55,7 @@ func (r *mutationResolver) CreateThirdParty(ctx context.Context, input types.Cre
BusinessOwnerID: input.BusinessOwnerID, BusinessOwnerID: input.BusinessOwnerID,
SecurityOwnerID: input.SecurityOwnerID, SecurityOwnerID: input.SecurityOwnerID,
Countries: input.Countries, Countries: input.Countries,
FirstLevel: input.FirstLevel,
}, },
) )
if err != nil { if err != nil {
@@ -106,6 +107,7 @@ func (r *mutationResolver) UpdateThirdParty(ctx context.Context, input types.Upd
BusinessOwnerID: gqlutils.UnwrapOmittable(input.BusinessOwnerID), BusinessOwnerID: gqlutils.UnwrapOmittable(input.BusinessOwnerID),
SecurityOwnerID: gqlutils.UnwrapOmittable(input.SecurityOwnerID), SecurityOwnerID: gqlutils.UnwrapOmittable(input.SecurityOwnerID),
ShowOnTrustCenter: input.ShowOnTrustCenter, ShowOnTrustCenter: input.ShowOnTrustCenter,
FirstLevel: input.FirstLevel,
Countries: input.Countries, Countries: input.Countries,
}, },
) )
@@ -594,6 +596,46 @@ func (r *mutationResolver) PublishThirdPartyList(ctx context.Context, input type
}, nil }, 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. // Organization is the resolver for the organization field.
func (r *thirdPartyResolver) Organization(ctx context.Context, obj *types.ThirdParty) (*types.Organization, error) { func (r *thirdPartyResolver) Organization(ctx context.Context, obj *types.ThirdParty) (*types.Organization, error) {
if _, err := r.authorize(ctx, obj.ID, probo.ActionOrganizationGet); err != nil { 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 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. // Permission is the resolver for the permission field.
func (r *thirdPartyResolver) Permission(ctx context.Context, obj *types.ThirdParty, action string) (bool, error) { func (r *thirdPartyResolver) Permission(ctx context.Context, obj *types.ThirdParty, action string) (bool, error) {
return r.Resolver.Permission(ctx, obj, action) 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 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 return count, nil
} }
@@ -1145,3 +1229,15 @@ type thirdPartyContactResolver struct{ *Resolver }
type thirdPartyDataPrivacyAgreementResolver struct{ *Resolver } type thirdPartyDataPrivacyAgreementResolver struct{ *Resolver }
type thirdPartyRiskAssessmentResolver struct{ *Resolver } type thirdPartyRiskAssessmentResolver struct{ *Resolver }
type thirdPartyServiceResolver 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"))
}
*/

View File

@@ -84,6 +84,7 @@ func NewThirdParty(v *coredata.ThirdParty) *ThirdParty {
WebsiteURL: v.WebsiteURL, WebsiteURL: v.WebsiteURL,
Category: v.Category, Category: v.Category,
ShowOnTrustCenter: v.ShowOnTrustCenter, ShowOnTrustCenter: v.ShowOnTrustCenter,
FirstLevel: v.FirstLevel,
Countries: v.Countries, Countries: v.Countries,
UpdatedAt: v.UpdatedAt, UpdatedAt: v.UpdatedAt,
CreatedAt: v.CreatedAt, CreatedAt: v.CreatedAt,

View File

@@ -70,7 +70,7 @@ func (r *Resolver) ListThirdPartiesTool(ctx context.Context, req *mcp.CallToolRe
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy) 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) page, err := prb.ThirdParties.ListForOrganizationID(ctx, scope, input.OrganizationID, cursor, thirdPartyFilter)
if err != nil { 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 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) { 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) scope, err := r.Authorize(ctx, input.OrganizationID, probo.ActionRiskAssessmentList)
if err != nil { if err != nil {

View File

@@ -627,6 +627,9 @@ components:
organization_id: organization_id:
$ref: "#/components/schemas/GID" $ref: "#/components/schemas/GID"
description: Organization ID description: Organization ID
first_level:
type: boolean
description: Filter by first-level status
order_by: order_by:
$ref: "#/components/schemas/ThirdPartyOrderBy" $ref: "#/components/schemas/ThirdPartyOrderBy"
description: ThirdParty order by description: ThirdParty order by
@@ -650,6 +653,69 @@ components:
items: items:
$ref: "#/components/schemas/ThirdParty" $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: ThirdParty:
type: object type: object
required: required:
@@ -657,6 +723,7 @@ components:
- name - name
- organization_id - organization_id
- category - category
- first_level
- created_at - created_at
- updated_at - updated_at
properties: properties:
@@ -780,6 +847,9 @@ components:
- string - string
- "null" - "null"
description: Trust page URL description: Trust page URL
first_level:
type: boolean
description: Whether this is a first-level third party
created_at: created_at:
type: string type: string
format: date-time format: date-time
@@ -11788,6 +11858,31 @@ tools:
$ref: "#/components/schemas/ListThirdPartiesInput" $ref: "#/components/schemas/ListThirdPartiesInput"
outputSchema: outputSchema:
$ref: "#/components/schemas/ListThirdPartiesOutput" $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 - name: listUsers
description: List all users for the organization description: List all users for the organization
hints: hints:

View File

@@ -87,6 +87,7 @@ func NewThirdParty(v *coredata.ThirdParty) *ThirdParty {
TermsOfServiceURL: v.TermsOfServiceURL, TermsOfServiceURL: v.TermsOfServiceURL,
SecurityPageURL: v.SecurityPageURL, SecurityPageURL: v.SecurityPageURL,
TrustPageURL: v.TrustPageURL, TrustPageURL: v.TrustPageURL,
FirstLevel: v.FirstLevel,
CreatedAt: v.CreatedAt, CreatedAt: v.CreatedAt,
UpdatedAt: v.UpdatedAt, 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 { func NewAddThirdPartyOutput(v *coredata.ThirdParty) AddThirdPartyOutput {
return AddThirdPartyOutput{ return AddThirdPartyOutput{
ThirdParty: NewThirdParty(v), ThirdParty: NewThirdParty(v),

View File

@@ -65,7 +65,7 @@ func (s ThirdPartyService) ListForOrganizationId(
ctx, ctx,
func(ctx context.Context, conn pg.Querier) error { func(ctx context.Context, conn pg.Querier) error {
showOnTrustCenter := true showOnTrustCenter := true
filter := coredata.NewThirdPartyFilter(&showOnTrustCenter) filter := coredata.NewThirdPartyFilter(&showOnTrustCenter, nil)
err := thirdParties.LoadByOrganizationID(ctx, conn, scope, organizationID, cursor, filter) err := thirdParties.LoadByOrganizationID(ctx, conn, scope, organizationID, cursor, filter)
if err != nil { if err != nil {
@@ -99,7 +99,7 @@ func (s ThirdPartyService) CountForTrustCenterId(
thirdParties := &coredata.ThirdParties{} thirdParties := &coredata.ThirdParties{}
showOnTrustCenter := true showOnTrustCenter := true
filter := coredata.NewThirdPartyFilter(&showOnTrustCenter) filter := coredata.NewThirdPartyFilter(&showOnTrustCenter, nil)
count, err = thirdParties.CountByOrganizationID(ctx, conn, scope, trustCenter.OrganizationID, filter) count, err = thirdParties.CountByOrganizationID(ctx, conn, scope, trustCenter.OrganizationID, filter)
if err != nil { if err != nil {