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:
@@ -24,6 +24,7 @@ type ThirdParty = {
|
||||
id: string;
|
||||
name: string;
|
||||
websiteUrl: string | null | undefined;
|
||||
firstLevel?: boolean;
|
||||
};
|
||||
|
||||
type Props<T extends FieldValues = FieldValues> = {
|
||||
@@ -66,7 +67,7 @@ function ThirdPartiesMultiSelectWithQuery<T extends FieldValues = FieldValues>(
|
||||
const thirdParties = useThirdParties(organizationId);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const allThirdParties = [...thirdParties];
|
||||
const allThirdParties: ThirdParty[] = [...thirdParties];
|
||||
if (props.disabled) {
|
||||
selectedThirdParties.forEach((selectedThirdParty) => {
|
||||
if (!allThirdParties.find(v => v.id === selectedThirdParty.id)) {
|
||||
|
||||
@@ -138,6 +138,7 @@ export const paginatedThirdPartiesFragment = graphql`
|
||||
after: { type: "CursorKey", defaultValue: null }
|
||||
before: { type: "CursorKey", defaultValue: null }
|
||||
last: { type: "Int", defaultValue: null }
|
||||
filter: { type: "ThirdPartyFilter", defaultValue: { firstLevel: true } }
|
||||
) {
|
||||
thirdParties(
|
||||
first: $first
|
||||
@@ -145,7 +146,8 @@ export const paginatedThirdPartiesFragment = graphql`
|
||||
last: $last
|
||||
before: $before
|
||||
orderBy: $order
|
||||
) @connection(key: "ThirdPartiesListQuery_thirdParties") {
|
||||
filter: $filter
|
||||
) @connection(key: "ThirdPartiesListQuery_thirdParties", filters: ["filter"]) {
|
||||
__id
|
||||
edges {
|
||||
node {
|
||||
@@ -182,6 +184,7 @@ export const thirdPartyNodeQuery = graphql`
|
||||
... on ThirdParty {
|
||||
name
|
||||
websiteUrl
|
||||
firstLevel
|
||||
canAssess: permission(action: "core:thirdParty:assess")
|
||||
canUpdate: permission(action: "core:thirdParty:update")
|
||||
canDelete: permission(action: "core:thirdParty:delete")
|
||||
@@ -224,6 +227,7 @@ export const thirdPartiesSelectQuery = graphql`
|
||||
id
|
||||
name
|
||||
websiteUrl
|
||||
firstLevel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,12 +26,15 @@ import {
|
||||
IconUpload,
|
||||
PageHeader,
|
||||
RiskBadge,
|
||||
TabItem,
|
||||
Tabs,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
} from "@probo/ui";
|
||||
import { useState, useTransition } from "react";
|
||||
import {
|
||||
type PreloadedQuery,
|
||||
usePaginationFragment,
|
||||
@@ -76,9 +79,21 @@ export default function ThirdPartiesPage(props: Props) {
|
||||
|
||||
const thirdParties = pagination.data.thirdParties?.edges.map(edge => edge.node);
|
||||
const connectionId = pagination.data.thirdParties.__id;
|
||||
const [, startTransition] = useTransition();
|
||||
const [firstLevelFilter, setFirstLevelFilter] = useState<boolean | null>(true);
|
||||
|
||||
usePageTitle(__("Third parties"));
|
||||
|
||||
const handleFilterChange = (firstLevel: boolean | null) => {
|
||||
setFirstLevelFilter(firstLevel);
|
||||
startTransition(() => {
|
||||
pagination.refetch(
|
||||
{ filter: firstLevel !== null ? { firstLevel } : {} },
|
||||
{ fetchPolicy: "store-and-network" },
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const hasAnyAction
|
||||
= thirdParties.some(({ canUpdate, canDelete }) => canUpdate || canDelete);
|
||||
|
||||
@@ -129,6 +144,20 @@ export default function ThirdPartiesPage(props: Props) {
|
||||
)}
|
||||
</div>
|
||||
</PageHeader>
|
||||
<Tabs>
|
||||
<TabItem
|
||||
active={firstLevelFilter === true}
|
||||
onClick={() => handleFilterChange(true)}
|
||||
>
|
||||
{__("First Level")}
|
||||
</TabItem>
|
||||
<TabItem
|
||||
active={firstLevelFilter === null}
|
||||
onClick={() => handleFilterChange(null)}
|
||||
>
|
||||
{__("All")}
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
<SortableTable {...pagination}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { faviconUrl } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
ActionDropdown,
|
||||
Badge,
|
||||
Breadcrumb,
|
||||
Button,
|
||||
DropdownItem,
|
||||
@@ -91,7 +92,12 @@ export default function ThirdPartyDetailPage(props: Props) {
|
||||
className="shadow-mid rounded-2xl"
|
||||
/>
|
||||
)}
|
||||
<div className="text-2xl">{thirdParty.name}</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-2xl">{thirdParty.name}</div>
|
||||
<Badge variant={thirdParty.firstLevel ? "info" : "neutral"}>
|
||||
{thirdParty.firstLevel ? __("First Level") : __("Indirect")}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
{thirdParty.canAssess && (
|
||||
@@ -127,6 +133,9 @@ export default function ThirdPartyDetailPage(props: Props) {
|
||||
<TabLink to={`${baseThirdPartyUrl}/risks`}>{__("Risk Assessment")}</TabLink>
|
||||
<TabLink to={`${baseThirdPartyUrl}/contacts`}>{__("Contacts")}</TabLink>
|
||||
<TabLink to={`${baseThirdPartyUrl}/services`}>{__("Services")}</TabLink>
|
||||
<TabLink to={`${baseThirdPartyUrl}/third-parties`}>
|
||||
{__("Third Parties")}
|
||||
</TabLink>
|
||||
</Tabs>
|
||||
|
||||
<Outlet context={{ thirdParty }} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,7 @@ import type { CreateThirdPartyInput } from "#/__generated__/core/ThirdPartyGraph
|
||||
export type CommonThirdPartyRef
|
||||
= CommonThirdPartyCombobox_commonThirdParty$data;
|
||||
|
||||
const commonThirdPartyFragment = graphql`
|
||||
export const commonThirdPartyFragment = graphql`
|
||||
fragment CommonThirdPartyCombobox_commonThirdParty on CommonThirdParty @inline {
|
||||
name
|
||||
logoUrl
|
||||
@@ -57,20 +57,44 @@ export const commonThirdPartiesQuery = graphql`
|
||||
}
|
||||
`;
|
||||
|
||||
function toCreateInput(tp: CommonThirdPartyRef): Omit<CreateThirdPartyInput, "organizationId"> {
|
||||
return {
|
||||
name: tp.name,
|
||||
headquarterAddress: tp.headquarterAddress,
|
||||
legalName: tp.legalName,
|
||||
websiteUrl: tp.websiteUrl,
|
||||
category: tp.category,
|
||||
privacyPolicyUrl: tp.privacyPolicyUrl,
|
||||
serviceLevelAgreementUrl: tp.serviceLevelAgreementUrl,
|
||||
dataProcessingAgreementUrl: tp.dataProcessingAgreementUrl,
|
||||
certifications: tp.certifications,
|
||||
securityPageUrl: tp.securityPageUrl,
|
||||
trustPageUrl: tp.trustPageUrl,
|
||||
statusPageUrl: tp.statusPageUrl,
|
||||
termsOfServiceUrl: tp.termsOfServiceUrl,
|
||||
};
|
||||
}
|
||||
|
||||
interface CommonThirdPartyComboboxProps {
|
||||
queryRef: PreloadedQuery<CommonThirdPartyComboboxQuery>;
|
||||
onSelect: (thridParty: Omit<CreateThirdPartyInput, "organizationId">) => void;
|
||||
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 (
|
||||
<>
|
||||
{data.commonThirdParties.map(thirdParty => (
|
||||
{items.map(thirdParty => (
|
||||
<ComboboxItem
|
||||
key={thirdParty.id}
|
||||
onClick={() => {
|
||||
@@ -78,21 +102,7 @@ export function CommonThirdPartyCombobox({
|
||||
commonThirdPartyFragment,
|
||||
thirdParty,
|
||||
);
|
||||
onSelect({
|
||||
name: tp.name,
|
||||
headquarterAddress: tp.headquarterAddress,
|
||||
legalName: tp.legalName,
|
||||
websiteUrl: tp.websiteUrl,
|
||||
category: tp.category,
|
||||
privacyPolicyUrl: tp.privacyPolicyUrl,
|
||||
serviceLevelAgreementUrl: tp.serviceLevelAgreementUrl,
|
||||
dataProcessingAgreementUrl: tp.dataProcessingAgreementUrl,
|
||||
certifications: tp.certifications,
|
||||
securityPageUrl: tp.securityPageUrl,
|
||||
trustPageUrl: tp.trustPageUrl,
|
||||
statusPageUrl: tp.statusPageUrl,
|
||||
termsOfServiceUrl: tp.termsOfServiceUrl,
|
||||
});
|
||||
onSelect(toCreateInput(tp));
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
|
||||
@@ -12,8 +12,10 @@
|
||||
// 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,
|
||||
@@ -23,17 +25,30 @@ import {
|
||||
useDialogRef,
|
||||
} from "@probo/ui";
|
||||
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 type { CommonThirdPartyComboboxQuery } from "#/__generated__/core/CommonThirdPartyComboboxQuery.graphql";
|
||||
import type { CreateThirdPartyDialogPromoteMutation } from "#/__generated__/core/CreateThirdPartyDialogPromoteMutation.graphql";
|
||||
import type { CreateThirdPartyInput } from "#/__generated__/core/ThirdPartyGraphCreateMutation.graphql";
|
||||
import { useCreateThirdPartyMutation } from "#/hooks/graph/ThirdPartyGraph";
|
||||
import { useCreateThirdPartyMutation, useThirdParties } from "#/hooks/graph/ThirdPartyGraph";
|
||||
|
||||
import {
|
||||
commonThirdPartiesQuery,
|
||||
CommonThirdPartyCombobox,
|
||||
} from "./CommonThirdPartyCombobox";
|
||||
import { commonThirdPartiesQuery, CommonThirdPartyCombobox } from "./CommonThirdPartyCombobox";
|
||||
|
||||
const promoteMutation = graphql`
|
||||
mutation CreateThirdPartyDialogPromoteMutation(
|
||||
$input: UpdateThirdPartyInput!
|
||||
) {
|
||||
updateThirdParty(input: $input) {
|
||||
thirdParty {
|
||||
id
|
||||
name
|
||||
firstLevel
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
@@ -48,12 +63,48 @@ export function CreateThirdPartyDialog({
|
||||
}: Props) {
|
||||
const { __ } = useTranslate();
|
||||
const [createThirdParty] = useCreateThirdPartyMutation();
|
||||
const [promoteThirdParty] = useMutation<CreateThirdPartyDialogPromoteMutation>(promoteMutation);
|
||||
const thirdParties = useThirdParties(organizationId);
|
||||
const dialogRef = useDialogRef();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [queryRef, loadQuery]
|
||||
= 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 name = typeof thirdParty === "string" ? thirdParty : thirdParty.name;
|
||||
const existing = nonFirstLevelByName.get(name.toLowerCase());
|
||||
|
||||
if (existing) {
|
||||
promoteThirdParty({
|
||||
variables: {
|
||||
input: { id: existing.id, firstLevel: true },
|
||||
},
|
||||
updater: (store) => {
|
||||
const payload = store.getRootField("updateThirdParty");
|
||||
const node = payload?.getLinkedRecord("thirdParty");
|
||||
if (!node) return;
|
||||
|
||||
const connectionRecord = store.get(connection);
|
||||
if (!connectionRecord) return;
|
||||
|
||||
const edge = ConnectionHandler.createEdge(store, connectionRecord, node, "ThirdPartyEdge");
|
||||
ConnectionHandler.insertEdgeBefore(connectionRecord, edge);
|
||||
},
|
||||
onCompleted: () => {
|
||||
dialogRef.current?.close();
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const input
|
||||
= typeof thirdParty === "string"
|
||||
? {
|
||||
@@ -98,10 +149,26 @@ export function CreateThirdPartyDialog({
|
||||
<Dialog ref={dialogRef} trigger={children} title={__("Add a third party")}>
|
||||
<DialogContent className="p-6">
|
||||
<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 && (
|
||||
<Suspense>
|
||||
<CommonThirdPartyCombobox
|
||||
queryRef={queryRef}
|
||||
excludeNames={existingNames}
|
||||
onSelect={thirdPartyRef => void onSelect(thirdPartyRef)}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -97,6 +97,14 @@ export const thirdPartyRoutes = [
|
||||
() => import("../pages/organizations/third-parties/tabs/ThirdPartyServicesTab"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "third-parties",
|
||||
Fallback: LinkCardSkeleton,
|
||||
Component: lazy(
|
||||
() =>
|
||||
import("../pages/organizations/third-parties/third-parties/ThirdPartyThirdPartiesPageLoader"),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
] satisfies AppRoute[];
|
||||
|
||||
508
e2e/console/third_party_relation_test.go
Normal file
508
e2e/console/third_party_relation_test.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
@@ -183,6 +183,13 @@ export const description: INodeProperties[] = [
|
||||
type: 'string',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Root',
|
||||
name: 'firstLevel',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether this is a first-level third party',
|
||||
},
|
||||
{
|
||||
displayName: 'Security Page URL',
|
||||
name: 'securityPageUrl',
|
||||
@@ -249,6 +256,7 @@ export async function execute(
|
||||
trustPageUrl?: string;
|
||||
certifications?: string;
|
||||
countries?: string;
|
||||
firstLevel?: boolean;
|
||||
};
|
||||
|
||||
const query = `
|
||||
@@ -275,6 +283,7 @@ export async function execute(
|
||||
certifications
|
||||
countries
|
||||
showOnTrustCenter
|
||||
firstLevel
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
@@ -303,6 +312,7 @@ export async function execute(
|
||||
if (additionalFields.subprocessorsListUrl) input.subprocessorsListUrl = additionalFields.subprocessorsListUrl;
|
||||
if (additionalFields.securityPageUrl) input.securityPageUrl = additionalFields.securityPageUrl;
|
||||
if (additionalFields.trustPageUrl) input.trustPageUrl = additionalFields.trustPageUrl;
|
||||
if (additionalFields.firstLevel !== undefined) input.firstLevel = additionalFields.firstLevel;
|
||||
if (additionalFields.certifications) {
|
||||
input.certifications = additionalFields.certifications.split(',').map((c) => c.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
@@ -73,6 +73,13 @@ export const description: INodeProperties[] = [
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Filter by Root',
|
||||
name: 'filterFirstLevel',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to filter by first-level third parties only',
|
||||
},
|
||||
{
|
||||
displayName: 'Include Organization',
|
||||
name: 'includeOrganization',
|
||||
@@ -106,6 +113,7 @@ export async function execute(
|
||||
const returnAll = this.getNodeParameter('returnAll', itemIndex) as boolean;
|
||||
const limit = this.getNodeParameter('limit', itemIndex, 50) as number;
|
||||
const options = this.getNodeParameter('options', itemIndex, {}) as {
|
||||
filterFirstLevel?: boolean;
|
||||
includeOrganization?: boolean;
|
||||
includeBusinessOwner?: boolean;
|
||||
includeSecurityOwner?: boolean;
|
||||
@@ -134,11 +142,14 @@ export async function execute(
|
||||
}`
|
||||
: '';
|
||||
|
||||
const filterVariable = options.filterFirstLevel !== undefined ? ', $filter: ThirdPartyFilter' : '';
|
||||
const filterArgument = options.filterFirstLevel !== undefined ? ', filter: $filter' : '';
|
||||
|
||||
const query = `
|
||||
query GetThirdParties($organizationId: ID!, $first: Int, $after: CursorKey) {
|
||||
query GetThirdParties($organizationId: ID!, $first: Int, $after: CursorKey${filterVariable}) {
|
||||
node(id: $organizationId) {
|
||||
... on Organization {
|
||||
thirdParties(first: $first, after: $after) {
|
||||
thirdParties(first: $first, after: $after${filterArgument}) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
@@ -160,6 +171,7 @@ export async function execute(
|
||||
certifications
|
||||
countries
|
||||
showOnTrustCenter
|
||||
firstLevel
|
||||
${organizationFragment}
|
||||
${businessOwnerFragment}
|
||||
${securityOwnerFragment}
|
||||
@@ -177,10 +189,15 @@ export async function execute(
|
||||
}
|
||||
`;
|
||||
|
||||
const variables: IDataObject = { organizationId };
|
||||
if (options.filterFirstLevel) {
|
||||
variables.filter = { firstLevel: true };
|
||||
}
|
||||
|
||||
const thirdParties = await proboApiRequestAllItems.call(
|
||||
this,
|
||||
query,
|
||||
{ organizationId },
|
||||
variables,
|
||||
(response) => {
|
||||
const data = response?.data as IDataObject | undefined;
|
||||
const node = data?.node as IDataObject | undefined;
|
||||
|
||||
@@ -39,6 +39,9 @@ import * as updateBusinessAssociateAgreementOp from './updateBusinessAssociateAg
|
||||
import * as getDataPrivacyAgreementOp from './getDataPrivacyAgreement.operation';
|
||||
import * as deleteDataPrivacyAgreementOp from './deleteDataPrivacyAgreement.operation';
|
||||
import * as updateDataPrivacyAgreementOp from './updateDataPrivacyAgreement.operation';
|
||||
import * as linkThirdPartyOp from './linkThirdParty.operation';
|
||||
import * as unlinkThirdPartyOp from './unlinkThirdParty.operation';
|
||||
import * as listChildThirdPartiesOp from './listChildThirdParties.operation';
|
||||
import * as publishOp from './publish.operation';
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
@@ -143,6 +146,12 @@ export const description: INodeProperties[] = [
|
||||
description: 'Get many third parties',
|
||||
action: 'Get many third parties',
|
||||
},
|
||||
{
|
||||
name: 'Get Many Child Third Parties',
|
||||
value: 'listChildThirdParties',
|
||||
description: 'Get child third parties linked to a parent',
|
||||
action: 'Get many child third parties',
|
||||
},
|
||||
{
|
||||
name: 'Get Many Compliance Reports',
|
||||
value: 'getAllComplianceReports',
|
||||
@@ -179,12 +188,24 @@ export const description: INodeProperties[] = [
|
||||
description: 'Get a third party service',
|
||||
action: 'Get a third party service',
|
||||
},
|
||||
{
|
||||
name: 'Link Third Party',
|
||||
value: 'linkThirdParty',
|
||||
description: 'Link a child third party to a parent third party',
|
||||
action: 'Link a child third party',
|
||||
},
|
||||
{
|
||||
name: 'Publish List',
|
||||
value: 'publish',
|
||||
description: 'Publish the third party register as a document version',
|
||||
action: 'Publish the third party register',
|
||||
},
|
||||
{
|
||||
name: 'Unlink Third Party',
|
||||
value: 'unlinkThirdParty',
|
||||
description: 'Unlink a child third party from a parent third party',
|
||||
action: 'Unlink a child third party',
|
||||
},
|
||||
{
|
||||
name: 'Update',
|
||||
value: 'update',
|
||||
@@ -244,6 +265,9 @@ export const description: INodeProperties[] = [
|
||||
...getDataPrivacyAgreementOp.description,
|
||||
...deleteDataPrivacyAgreementOp.description,
|
||||
...updateDataPrivacyAgreementOp.description,
|
||||
...linkThirdPartyOp.description,
|
||||
...unlinkThirdPartyOp.description,
|
||||
...listChildThirdPartiesOp.description,
|
||||
...publishOp.description,
|
||||
];
|
||||
|
||||
@@ -274,5 +298,8 @@ export {
|
||||
getDataPrivacyAgreementOp as getDataPrivacyAgreement,
|
||||
deleteDataPrivacyAgreementOp as deleteDataPrivacyAgreement,
|
||||
updateDataPrivacyAgreementOp as updateDataPrivacyAgreement,
|
||||
linkThirdPartyOp as linkThirdParty,
|
||||
unlinkThirdPartyOp as unlinkThirdParty,
|
||||
listChildThirdPartiesOp as listChildThirdParties,
|
||||
publishOp as publish,
|
||||
};
|
||||
|
||||
75
packages/n8n-node/nodes/Probo/actions/thirdParty/linkThirdParty.operation.ts
vendored
Normal file
75
packages/n8n-node/nodes/Probo/actions/thirdParty/linkThirdParty.operation.ts
vendored
Normal 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 },
|
||||
};
|
||||
}
|
||||
118
packages/n8n-node/nodes/Probo/actions/thirdParty/listChildThirdParties.operation.ts
vendored
Normal file
118
packages/n8n-node/nodes/Probo/actions/thirdParty/listChildThirdParties.operation.ts
vendored
Normal 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 },
|
||||
};
|
||||
}
|
||||
70
packages/n8n-node/nodes/Probo/actions/thirdParty/unlinkThirdParty.operation.ts
vendored
Normal file
70
packages/n8n-node/nodes/Probo/actions/thirdParty/unlinkThirdParty.operation.ts
vendored
Normal 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 },
|
||||
};
|
||||
}
|
||||
108
pkg/cmd/thirdpartymgmt/link/link.go
Normal file
108
pkg/cmd/thirdpartymgmt/link/link.go
Normal 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
|
||||
}
|
||||
@@ -24,11 +24,11 @@ import (
|
||||
)
|
||||
|
||||
const listQuery = `
|
||||
query($id: ID!, $first: Int, $after: CursorKey, $orderBy: ThirdPartyOrder) {
|
||||
query($id: ID!, $first: Int, $after: CursorKey, $orderBy: ThirdPartyOrder, $filter: ThirdPartyFilter) {
|
||||
node(id: $id) {
|
||||
__typename
|
||||
... on Organization {
|
||||
third_parties(first: $first, after: $after, orderBy: $orderBy) {
|
||||
third_parties(first: $first, after: $after, orderBy: $orderBy, filter: $filter) {
|
||||
totalCount
|
||||
edges {
|
||||
node {
|
||||
@@ -55,11 +55,12 @@ type thirdParty struct {
|
||||
|
||||
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
var (
|
||||
flagOrg string
|
||||
flagLimit int
|
||||
flagOrderBy string
|
||||
flagOrderDir string
|
||||
flagOutput *string
|
||||
flagOrg string
|
||||
flagLimit int
|
||||
flagOrderBy string
|
||||
flagOrderDir string
|
||||
flagFirstLevel bool
|
||||
flagOutput *string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
@@ -107,6 +108,12 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
"id": flagOrg,
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed("first-level") {
|
||||
variables["filter"] = map[string]any{
|
||||
"first-level": flagFirstLevel,
|
||||
}
|
||||
}
|
||||
|
||||
if flagOrderBy != "" {
|
||||
if err := cmdutil.ValidateEnum("order-by", flagOrderBy, []string{"NAME", "CREATED_AT", "UPDATED_AT"}); err != nil {
|
||||
return err
|
||||
@@ -188,6 +195,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd.Flags().IntVarP(&flagLimit, "limit", "L", 30, "Maximum number of thirdParties to list")
|
||||
cmd.Flags().StringVar(&flagOrderBy, "order-by", "", "Order by field (NAME, CREATED_AT, UPDATED_AT)")
|
||||
cmd.Flags().StringVar(&flagOrderDir, "order-direction", "DESC", "Sort direction (ASC, DESC)")
|
||||
cmd.Flags().BoolVar(&flagFirstLevel, "first-level", false, "Filter by first-level thirdParties only")
|
||||
flagOutput = cmdutil.AddOutputFlag(cmd)
|
||||
|
||||
return cmd
|
||||
|
||||
@@ -20,8 +20,10 @@ import (
|
||||
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/assess"
|
||||
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/create"
|
||||
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/delete"
|
||||
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/link"
|
||||
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/list"
|
||||
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/publish"
|
||||
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/unlink"
|
||||
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/update"
|
||||
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/view"
|
||||
)
|
||||
@@ -39,6 +41,8 @@ func NewCmdThirdParty(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd.AddCommand(delete.NewCmdDelete(f))
|
||||
cmd.AddCommand(assess.NewCmdAssess(f))
|
||||
cmd.AddCommand(publish.NewCmdPublish(f))
|
||||
cmd.AddCommand(link.NewCmdLink(f))
|
||||
cmd.AddCommand(unlink.NewCmdUnlink(f))
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
84
pkg/cmd/thirdpartymgmt/unlink/unlink.go
Normal file
84
pkg/cmd/thirdpartymgmt/unlink/unlink.go
Normal 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
|
||||
}
|
||||
25
pkg/coredata/migrations/20260519T100000Z.sql
Normal file
25
pkg/coredata/migrations/20260519T100000Z.sql
Normal 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)
|
||||
);
|
||||
@@ -162,6 +162,7 @@ type (
|
||||
SecurityPageURL *string `db:"security_page_url"`
|
||||
TrustPageURL *string `db:"trust_page_url"`
|
||||
ShowOnTrustCenter bool `db:"show_on_trust_center"`
|
||||
FirstLevel bool `db:"first_level"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
@@ -253,6 +254,7 @@ SELECT
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
show_on_trust_center,
|
||||
first_level,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
@@ -320,6 +322,7 @@ SELECT
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
show_on_trust_center,
|
||||
first_level,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
@@ -381,6 +384,7 @@ INSERT INTO
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
show_on_trust_center,
|
||||
first_level,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
@@ -409,6 +413,7 @@ VALUES (
|
||||
@security_page_url,
|
||||
@trust_page_url,
|
||||
@show_on_trust_center,
|
||||
@first_level,
|
||||
@created_at,
|
||||
@updated_at
|
||||
)
|
||||
@@ -439,6 +444,7 @@ VALUES (
|
||||
"security_page_url": v.SecurityPageURL,
|
||||
"trust_page_url": v.TrustPageURL,
|
||||
"show_on_trust_center": v.ShowOnTrustCenter,
|
||||
"first_level": v.FirstLevel,
|
||||
"created_at": v.CreatedAt,
|
||||
"updated_at": v.UpdatedAt,
|
||||
}
|
||||
@@ -534,6 +540,7 @@ SELECT
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
show_on_trust_center,
|
||||
first_level,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
@@ -597,6 +604,7 @@ SELECT
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
show_on_trust_center,
|
||||
first_level,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
@@ -657,6 +665,7 @@ SET
|
||||
business_owner_profile_id = @business_owner_profile_id,
|
||||
security_owner_profile_id = @security_owner_profile_id,
|
||||
show_on_trust_center = @show_on_trust_center,
|
||||
first_level = @first_level,
|
||||
updated_at = @updated_at
|
||||
WHERE %s
|
||||
AND id = @third_party_id
|
||||
@@ -686,6 +695,7 @@ WHERE %s
|
||||
"business_owner_profile_id": v.BusinessOwnerID,
|
||||
"security_owner_profile_id": v.SecurityOwnerID,
|
||||
"show_on_trust_center": v.ShowOnTrustCenter,
|
||||
"first_level": v.FirstLevel,
|
||||
}
|
||||
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
@@ -803,6 +813,7 @@ WITH vend AS (
|
||||
v.security_page_url,
|
||||
v.trust_page_url,
|
||||
v.show_on_trust_center,
|
||||
v.first_level,
|
||||
v.created_at,
|
||||
v.updated_at
|
||||
FROM
|
||||
@@ -837,6 +848,7 @@ SELECT
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
show_on_trust_center,
|
||||
first_level,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
@@ -938,6 +950,7 @@ WITH vend AS (
|
||||
v.security_page_url,
|
||||
v.trust_page_url,
|
||||
v.show_on_trust_center,
|
||||
v.first_level,
|
||||
v.created_at,
|
||||
v.updated_at
|
||||
FROM
|
||||
@@ -972,6 +985,7 @@ SELECT
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
show_on_trust_center,
|
||||
first_level,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
@@ -1033,6 +1047,7 @@ WITH vend AS (
|
||||
v.security_page_url,
|
||||
v.trust_page_url,
|
||||
v.show_on_trust_center,
|
||||
v.first_level,
|
||||
v.created_at,
|
||||
v.updated_at
|
||||
FROM
|
||||
@@ -1067,6 +1082,7 @@ SELECT
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
show_on_trust_center,
|
||||
first_level,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
@@ -1129,6 +1145,7 @@ WITH vend AS (
|
||||
v.security_page_url,
|
||||
v.trust_page_url,
|
||||
v.show_on_trust_center,
|
||||
v.first_level,
|
||||
v.created_at,
|
||||
v.updated_at
|
||||
FROM
|
||||
@@ -1163,6 +1180,7 @@ SELECT
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
show_on_trust_center,
|
||||
first_level,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
@@ -1293,6 +1311,7 @@ WITH vend AS (
|
||||
v.security_page_url,
|
||||
v.trust_page_url,
|
||||
v.show_on_trust_center,
|
||||
v.first_level,
|
||||
v.created_at,
|
||||
v.updated_at
|
||||
FROM
|
||||
@@ -1327,6 +1346,7 @@ SELECT
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
show_on_trust_center,
|
||||
first_level,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
@@ -1387,6 +1407,7 @@ SELECT
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
show_on_trust_center,
|
||||
first_level,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
|
||||
@@ -21,12 +21,14 @@ import (
|
||||
type (
|
||||
ThirdPartyFilter struct {
|
||||
showOnTrustCenter *bool
|
||||
firstLevel *bool
|
||||
}
|
||||
)
|
||||
|
||||
func NewThirdPartyFilter(showOnTrustCenter *bool) *ThirdPartyFilter {
|
||||
func NewThirdPartyFilter(showOnTrustCenter *bool, firstLevel *bool) *ThirdPartyFilter {
|
||||
return &ThirdPartyFilter{
|
||||
showOnTrustCenter: showOnTrustCenter,
|
||||
firstLevel: firstLevel,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +41,12 @@ func (f *ThirdPartyFilter) SQLArguments() pgx.StrictNamedArgs {
|
||||
args["show_on_trust_center"] = nil
|
||||
}
|
||||
|
||||
if f.firstLevel != nil {
|
||||
args["first_level"] = *f.firstLevel
|
||||
} else {
|
||||
args["first_level"] = nil
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
@@ -50,5 +58,13 @@ func (f *ThirdPartyFilter) SQLFragment() string {
|
||||
show_on_trust_center = @show_on_trust_center::boolean
|
||||
ELSE TRUE
|
||||
END
|
||||
)
|
||||
AND
|
||||
(
|
||||
CASE
|
||||
WHEN @first_level::boolean IS NOT NULL THEN
|
||||
first_level = @first_level::boolean
|
||||
ELSE TRUE
|
||||
END
|
||||
)`
|
||||
}
|
||||
|
||||
226
pkg/coredata/third_party_third_party.go
Normal file
226
pkg/coredata/third_party_third_party.go
Normal 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
|
||||
}
|
||||
@@ -92,6 +92,11 @@ const (
|
||||
ActionThirdPartyAssess = "core:thirdParty:assess"
|
||||
ActionThirdPartyPublish = "core:thirdParty:publish"
|
||||
|
||||
// ThirdPartyRelation actions
|
||||
ActionThirdPartyRelationCreate = "core:thirdParty-relation:create"
|
||||
ActionThirdPartyRelationDelete = "core:thirdParty-relation:delete"
|
||||
ActionThirdPartyRelationList = "core:thirdParty-relation:list"
|
||||
|
||||
// ThirdPartyContact actions
|
||||
ActionThirdPartyContactGet = "core:thirdParty-contact:get"
|
||||
ActionThirdPartyContactList = "core:thirdParty-contact:list"
|
||||
|
||||
@@ -55,6 +55,7 @@ var ViewerPolicy = policy.NewPolicy(
|
||||
ActionThirdPartyBusinessAssociateAgreementGet,
|
||||
ActionThirdPartyDataPrivacyAgreementGet,
|
||||
ActionThirdPartyRiskAssessmentList,
|
||||
ActionThirdPartyRelationList,
|
||||
ActionFrameworkGet, ActionFrameworkList,
|
||||
ActionControlGet, ActionControlList,
|
||||
ActionMeasureGet, ActionMeasureList,
|
||||
@@ -140,6 +141,7 @@ var AuditorPolicy = policy.NewPolicy(
|
||||
ActionThirdPartyBusinessAssociateAgreementGet,
|
||||
ActionThirdPartyDataPrivacyAgreementGet,
|
||||
ActionThirdPartyRiskAssessmentList,
|
||||
ActionThirdPartyRelationList,
|
||||
ActionFrameworkGet, ActionFrameworkList,
|
||||
ActionControlGet, ActionControlList,
|
||||
ActionMeasureGet, ActionMeasureList,
|
||||
|
||||
@@ -92,6 +92,7 @@ type (
|
||||
StatusPageURL *string
|
||||
BusinessOwnerID *gid.GID
|
||||
SecurityOwnerID *gid.GID
|
||||
FirstLevel *bool
|
||||
}
|
||||
|
||||
UpdateThirdPartyRequest struct {
|
||||
@@ -116,6 +117,7 @@ type (
|
||||
BusinessOwnerID **gid.GID
|
||||
SecurityOwnerID **gid.GID
|
||||
ShowOnTrustCenter *bool
|
||||
FirstLevel *bool
|
||||
}
|
||||
|
||||
AssessThirdPartyRequest struct {
|
||||
@@ -386,6 +388,10 @@ func (s ThirdPartyService) Update(
|
||||
thirdParty.ShowOnTrustCenter = *req.ShowOnTrustCenter
|
||||
}
|
||||
|
||||
if req.FirstLevel != nil {
|
||||
thirdParty.FirstLevel = *req.FirstLevel
|
||||
}
|
||||
|
||||
if req.TrustPageURL != nil {
|
||||
thirdParty.TrustPageURL = *req.TrustPageURL
|
||||
}
|
||||
@@ -573,6 +579,11 @@ func (s ThirdPartyService) Create(
|
||||
StatusPageURL: req.StatusPageURL,
|
||||
TermsOfServiceURL: req.TermsOfServiceURL,
|
||||
ShowOnTrustCenter: false,
|
||||
FirstLevel: true,
|
||||
}
|
||||
|
||||
if req.FirstLevel != nil {
|
||||
thirdParty.FirstLevel = *req.FirstLevel
|
||||
}
|
||||
|
||||
err := s.svc.pg.WithTx(
|
||||
@@ -947,3 +958,116 @@ func (s ThirdPartyService) Assess(
|
||||
Subprocessors: subprocessors,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s ThirdPartyService) CreateThirdPartyMapping(
|
||||
ctx context.Context,
|
||||
scope coredata.Scoper,
|
||||
parentThirdPartyID gid.GID,
|
||||
childThirdPartyID gid.GID,
|
||||
) (*coredata.ThirdParty, error) {
|
||||
childThirdParty := &coredata.ThirdParty{}
|
||||
|
||||
err := s.svc.pg.WithTx(
|
||||
ctx,
|
||||
func(ctx context.Context, conn pg.Tx) error {
|
||||
parentThirdParty := &coredata.ThirdParty{}
|
||||
if err := parentThirdParty.LoadByID(ctx, conn, scope, parentThirdPartyID); err != nil {
|
||||
return fmt.Errorf("cannot load parent third party: %w", err)
|
||||
}
|
||||
|
||||
if err := childThirdParty.LoadByID(ctx, conn, scope, childThirdPartyID); err != nil {
|
||||
return fmt.Errorf("cannot load child third party: %w", err)
|
||||
}
|
||||
|
||||
if parentThirdParty.OrganizationID != childThirdParty.OrganizationID {
|
||||
return fmt.Errorf("cannot create mapping for third parties from different organizations: %w", coredata.ErrResourceNotFound)
|
||||
}
|
||||
|
||||
relation := &coredata.ThirdPartyThirdParty{
|
||||
ParentThirdPartyID: parentThirdPartyID,
|
||||
ChildThirdPartyID: childThirdPartyID,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := relation.Insert(ctx, conn, scope); err != nil {
|
||||
return fmt.Errorf("cannot create third party mapping: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return childThirdParty, nil
|
||||
}
|
||||
|
||||
func (s ThirdPartyService) DeleteThirdPartyMapping(
|
||||
ctx context.Context,
|
||||
scope coredata.Scoper,
|
||||
parentThirdPartyID gid.GID,
|
||||
childThirdPartyID gid.GID,
|
||||
) error {
|
||||
return s.svc.pg.WithTx(
|
||||
ctx,
|
||||
func(ctx context.Context, conn pg.Tx) error {
|
||||
relation := &coredata.ThirdPartyThirdParty{
|
||||
ParentThirdPartyID: parentThirdPartyID,
|
||||
ChildThirdPartyID: childThirdPartyID,
|
||||
}
|
||||
if err := relation.Delete(ctx, conn, scope); err != nil {
|
||||
return fmt.Errorf("cannot delete third party mapping: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (s ThirdPartyService) CountForParentThirdPartyID(
|
||||
ctx context.Context,
|
||||
scope coredata.Scoper,
|
||||
parentThirdPartyID gid.GID,
|
||||
) (int, error) {
|
||||
var count int
|
||||
|
||||
err := s.svc.pg.WithConn(
|
||||
ctx,
|
||||
func(ctx context.Context, conn pg.Querier) (err error) {
|
||||
thirdParties := coredata.ThirdParties{}
|
||||
|
||||
count, err = thirdParties.CountByParentThirdPartyID(ctx, conn, scope, parentThirdPartyID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot count child third parties: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (s ThirdPartyService) ListForParentThirdPartyID(
|
||||
ctx context.Context,
|
||||
scope coredata.Scoper,
|
||||
parentThirdPartyID gid.GID,
|
||||
cursor *page.Cursor[coredata.ThirdPartyOrderField],
|
||||
) (*page.Page[*coredata.ThirdParty, coredata.ThirdPartyOrderField], error) {
|
||||
var thirdParties coredata.ThirdParties
|
||||
|
||||
err := s.svc.pg.WithConn(
|
||||
ctx,
|
||||
func(ctx context.Context, conn pg.Querier) error {
|
||||
return thirdParties.LoadByParentThirdPartyID(ctx, conn, scope, parentThirdPartyID, cursor)
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return page.NewPage(thirdParties, cursor), nil
|
||||
}
|
||||
|
||||
@@ -331,6 +331,7 @@ type Organization implements Node {
|
||||
last: Int
|
||||
before: CursorKey
|
||||
orderBy: ThirdPartyOrder
|
||||
filter: ThirdPartyFilter
|
||||
): ThirdPartyConnection! @goField(forceResolver: true)
|
||||
|
||||
thirdPartiesDocument: Document @goField(forceResolver: true)
|
||||
|
||||
@@ -177,6 +177,10 @@ input ThirdPartyOrder
|
||||
field: ThirdPartyOrderField!
|
||||
}
|
||||
|
||||
input ThirdPartyFilter {
|
||||
firstLevel: Boolean
|
||||
}
|
||||
|
||||
input ThirdPartyComplianceReportOrder
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.ThirdPartyComplianceReportOrderBy"
|
||||
@@ -269,6 +273,16 @@ type ThirdParty implements Node {
|
||||
legalName: String
|
||||
websiteUrl: String
|
||||
showOnTrustCenter: Boolean!
|
||||
firstLevel: Boolean!
|
||||
|
||||
childThirdParties(
|
||||
first: Int
|
||||
after: CursorKey
|
||||
last: Int
|
||||
before: CursorKey
|
||||
orderBy: ThirdPartyOrder
|
||||
): ThirdPartyConnection! @goField(forceResolver: true)
|
||||
|
||||
createdAt: Datetime!
|
||||
updatedAt: Datetime!
|
||||
|
||||
@@ -460,6 +474,12 @@ extend type Mutation {
|
||||
publishThirdPartyList(
|
||||
input: PublishThirdPartyListInput!
|
||||
): PublishThirdPartyListPayload!
|
||||
createThirdPartyThirdPartyMapping(
|
||||
input: CreateThirdPartyThirdPartyMappingInput!
|
||||
): CreateThirdPartyThirdPartyMappingPayload!
|
||||
deleteThirdPartyThirdPartyMapping(
|
||||
input: DeleteThirdPartyThirdPartyMappingInput!
|
||||
): DeleteThirdPartyThirdPartyMappingPayload!
|
||||
}
|
||||
|
||||
input PublishThirdPartyListInput {
|
||||
@@ -494,6 +514,7 @@ input CreateThirdPartyInput {
|
||||
termsOfServiceUrl: String
|
||||
businessOwnerId: ID
|
||||
securityOwnerId: ID
|
||||
firstLevel: Boolean
|
||||
}
|
||||
|
||||
input UpdateThirdPartyInput {
|
||||
@@ -518,6 +539,7 @@ input UpdateThirdPartyInput {
|
||||
businessOwnerId: ID @goField(omittable: true)
|
||||
securityOwnerId: ID @goField(omittable: true)
|
||||
showOnTrustCenter: Boolean
|
||||
firstLevel: Boolean
|
||||
}
|
||||
|
||||
input DeleteThirdPartyInput {
|
||||
@@ -709,3 +731,21 @@ type AssessThirdPartyPayload {
|
||||
report: String!
|
||||
subprocessors: [ThirdPartySubprocessor!]!
|
||||
}
|
||||
|
||||
input CreateThirdPartyThirdPartyMappingInput {
|
||||
parentThirdPartyId: ID!
|
||||
childThirdPartyId: ID!
|
||||
}
|
||||
|
||||
type CreateThirdPartyThirdPartyMappingPayload {
|
||||
thirdPartyEdge: ThirdPartyEdge!
|
||||
}
|
||||
|
||||
input DeleteThirdPartyThirdPartyMappingInput {
|
||||
parentThirdPartyId: ID!
|
||||
childThirdPartyId: ID!
|
||||
}
|
||||
|
||||
type DeleteThirdPartyThirdPartyMappingPayload {
|
||||
removedThirdPartyId: ID!
|
||||
}
|
||||
|
||||
@@ -1271,7 +1271,7 @@ func (r *organizationResolver) CookieBanners(ctx context.Context, obj *types.Org
|
||||
}
|
||||
|
||||
// ThirdParties is the resolver for the thirdParties field.
|
||||
func (r *organizationResolver) ThirdParties(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ThirdPartyOrderBy) (*types.ThirdPartyConnection, error) {
|
||||
func (r *organizationResolver) ThirdParties(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ThirdPartyOrderBy, filter *types.ThirdPartyFilter) (*types.ThirdPartyConnection, error) {
|
||||
scope, err := r.authorize(ctx, obj.ID, probo.ActionThirdPartyList)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1291,7 +1291,12 @@ func (r *organizationResolver) ThirdParties(ctx context.Context, obj *types.Orga
|
||||
|
||||
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
||||
|
||||
thirdPartyFilter := coredata.NewThirdPartyFilter(nil)
|
||||
var firstLevel *bool
|
||||
if filter != nil {
|
||||
firstLevel = filter.FirstLevel
|
||||
}
|
||||
|
||||
thirdPartyFilter := coredata.NewThirdPartyFilter(nil, firstLevel)
|
||||
|
||||
page, err := r.probo.ThirdParties.ListForOrganizationID(ctx, scope, obj.ID, cursor, thirdPartyFilter)
|
||||
if err != nil {
|
||||
|
||||
@@ -55,6 +55,7 @@ func (r *mutationResolver) CreateThirdParty(ctx context.Context, input types.Cre
|
||||
BusinessOwnerID: input.BusinessOwnerID,
|
||||
SecurityOwnerID: input.SecurityOwnerID,
|
||||
Countries: input.Countries,
|
||||
FirstLevel: input.FirstLevel,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
@@ -106,6 +107,7 @@ func (r *mutationResolver) UpdateThirdParty(ctx context.Context, input types.Upd
|
||||
BusinessOwnerID: gqlutils.UnwrapOmittable(input.BusinessOwnerID),
|
||||
SecurityOwnerID: gqlutils.UnwrapOmittable(input.SecurityOwnerID),
|
||||
ShowOnTrustCenter: input.ShowOnTrustCenter,
|
||||
FirstLevel: input.FirstLevel,
|
||||
Countries: input.Countries,
|
||||
},
|
||||
)
|
||||
@@ -594,6 +596,46 @@ func (r *mutationResolver) PublishThirdPartyList(ctx context.Context, input type
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateThirdPartyThirdPartyMapping is the resolver for the linkThirdPartyThirdParty field.
|
||||
func (r *mutationResolver) CreateThirdPartyThirdPartyMapping(ctx context.Context, input types.CreateThirdPartyThirdPartyMappingInput) (*types.CreateThirdPartyThirdPartyMappingPayload, error) {
|
||||
scope, err := r.authorize(ctx, input.ParentThirdPartyID, probo.ActionThirdPartyRelationCreate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
childThirdParty, err := r.probo.ThirdParties.CreateThirdPartyMapping(ctx, scope, input.ParentThirdPartyID, input.ChildThirdPartyID)
|
||||
if err != nil {
|
||||
if errors.Is(err, coredata.ErrResourceNotFound) {
|
||||
return nil, gqlutils.NotFound(ctx, err)
|
||||
}
|
||||
|
||||
r.logger.ErrorCtx(ctx, "cannot create third party mapping", log.Error(err))
|
||||
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
return &types.CreateThirdPartyThirdPartyMappingPayload{
|
||||
ThirdPartyEdge: types.NewThirdPartyEdge(childThirdParty, coredata.ThirdPartyOrderFieldName),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeleteThirdPartyThirdPartyMapping is the resolver for the deleteThirdPartyThirdPartyMapping field.
|
||||
func (r *mutationResolver) DeleteThirdPartyThirdPartyMapping(ctx context.Context, input types.DeleteThirdPartyThirdPartyMappingInput) (*types.DeleteThirdPartyThirdPartyMappingPayload, error) {
|
||||
scope, err := r.authorize(ctx, input.ParentThirdPartyID, probo.ActionThirdPartyRelationDelete)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := r.probo.ThirdParties.DeleteThirdPartyMapping(ctx, scope, input.ParentThirdPartyID, input.ChildThirdPartyID); err != nil {
|
||||
r.logger.ErrorCtx(ctx, "cannot delete third party mapping", log.Error(err))
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
return &types.DeleteThirdPartyThirdPartyMappingPayload{
|
||||
RemovedThirdPartyID: input.ChildThirdPartyID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Organization is the resolver for the organization field.
|
||||
func (r *thirdPartyResolver) Organization(ctx context.Context, obj *types.ThirdParty) (*types.Organization, error) {
|
||||
if _, err := r.authorize(ctx, obj.ID, probo.ActionOrganizationGet); err != nil {
|
||||
@@ -830,6 +872,35 @@ func (r *thirdPartyResolver) SecurityOwner(ctx context.Context, obj *types.Third
|
||||
return types.NewProfile(securityOwner), nil
|
||||
}
|
||||
|
||||
// ChildThirdParties is the resolver for the childThirdParties field.
|
||||
func (r *thirdPartyResolver) ChildThirdParties(ctx context.Context, obj *types.ThirdParty, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ThirdPartyOrderBy) (*types.ThirdPartyConnection, error) {
|
||||
scope, err := r.authorize(ctx, obj.ID, probo.ActionThirdPartyRelationList)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pageOrderBy := page.OrderBy[coredata.ThirdPartyOrderField]{
|
||||
Field: coredata.ThirdPartyOrderFieldName,
|
||||
Direction: page.OrderDirectionAsc,
|
||||
}
|
||||
if orderBy != nil {
|
||||
pageOrderBy = page.OrderBy[coredata.ThirdPartyOrderField]{
|
||||
Field: orderBy.Field,
|
||||
Direction: orderBy.Direction,
|
||||
}
|
||||
}
|
||||
|
||||
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
||||
|
||||
page, err := r.probo.ThirdParties.ListForParentThirdPartyID(ctx, scope, obj.ID, cursor)
|
||||
if err != nil {
|
||||
r.logger.ErrorCtx(ctx, "cannot list child third parties", log.Error(err))
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
return types.NewThirdPartyConnection(page, r, obj.ID), nil
|
||||
}
|
||||
|
||||
// Permission is the resolver for the permission field.
|
||||
func (r *thirdPartyResolver) Permission(ctx context.Context, obj *types.ThirdParty, action string) (bool, error) {
|
||||
return r.Resolver.Permission(ctx, obj, action)
|
||||
@@ -963,6 +1034,19 @@ func (r *thirdPartyConnectionResolver) TotalCount(ctx context.Context, obj *type
|
||||
return 0, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
case *thirdPartyResolver:
|
||||
if _, err := r.authorize(ctx, obj.ParentID, probo.ActionThirdPartyRelationList); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
count, err := r.probo.ThirdParties.CountForParentThirdPartyID(ctx, scope, obj.ParentID)
|
||||
if err != nil {
|
||||
r.logger.ErrorCtx(ctx, "cannot count child third parties", log.Error(err))
|
||||
|
||||
return 0, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
@@ -1145,3 +1229,15 @@ type thirdPartyContactResolver struct{ *Resolver }
|
||||
type thirdPartyDataPrivacyAgreementResolver struct{ *Resolver }
|
||||
type thirdPartyRiskAssessmentResolver struct{ *Resolver }
|
||||
type thirdPartyServiceResolver struct{ *Resolver }
|
||||
|
||||
// !!! WARNING !!!
|
||||
// The code below was going to be deleted when updating resolvers. It has been copied here so you have
|
||||
// one last chance to move it out of harms way if you want. There are two reasons this happens:
|
||||
// - When renaming or deleting a resolver the old code will be put in here. You can safely delete
|
||||
// it when you're done.
|
||||
// - You have helper methods in this file. Move them out to keep these resolver files clean.
|
||||
/*
|
||||
func (r *mutationResolver) UncreateThirdPartyThirdPartyMapping(ctx context.Context, input types.UncreateThirdPartyThirdPartyMappingInput) (*types.UncreateThirdPartyThirdPartyMappingPayload, error) {
|
||||
panic(fmt.Errorf("not implemented: UncreateThirdPartyThirdPartyMapping - uncreateThirdPartyThirdPartyMapping"))
|
||||
}
|
||||
*/
|
||||
|
||||
@@ -84,6 +84,7 @@ func NewThirdParty(v *coredata.ThirdParty) *ThirdParty {
|
||||
WebsiteURL: v.WebsiteURL,
|
||||
Category: v.Category,
|
||||
ShowOnTrustCenter: v.ShowOnTrustCenter,
|
||||
FirstLevel: v.FirstLevel,
|
||||
Countries: v.Countries,
|
||||
UpdatedAt: v.UpdatedAt,
|
||||
CreatedAt: v.CreatedAt,
|
||||
|
||||
@@ -70,7 +70,7 @@ func (r *Resolver) ListThirdPartiesTool(ctx context.Context, req *mcp.CallToolRe
|
||||
|
||||
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
|
||||
|
||||
thirdPartyFilter := coredata.NewThirdPartyFilter(nil)
|
||||
thirdPartyFilter := coredata.NewThirdPartyFilter(nil, input.FirstLevel)
|
||||
|
||||
page, err := prb.ThirdParties.ListForOrganizationID(ctx, scope, input.OrganizationID, cursor, thirdPartyFilter)
|
||||
if err != nil {
|
||||
@@ -6148,6 +6148,59 @@ func (r *Resolver) MoveTrackerResourceToCategoryTool(ctx context.Context, req *m
|
||||
return nil, types.MoveTrackerResourceToCategoryOutput{TrackerResource: types.NewTrackerResource(result.TrackerResource)}, nil
|
||||
}
|
||||
|
||||
func (r *Resolver) CreateThirdPartyThirdPartyMappingTool(ctx context.Context, req *mcp.CallToolRequest, input *types.CreateThirdPartyThirdPartyMappingInput) (*mcp.CallToolResult, types.CreateThirdPartyThirdPartyMappingOutput, error) {
|
||||
scope, err := r.Authorize(ctx, input.ParentThirdPartyID, probo.ActionThirdPartyRelationCreate)
|
||||
if err != nil {
|
||||
return nil, types.CreateThirdPartyThirdPartyMappingOutput{}, err
|
||||
}
|
||||
|
||||
if _, err := r.proboSvc.ThirdParties.CreateThirdPartyMapping(ctx, scope, input.ParentThirdPartyID, input.ChildThirdPartyID); err != nil {
|
||||
return nil, types.CreateThirdPartyThirdPartyMappingOutput{}, fmt.Errorf("cannot create third party mapping: %w", err)
|
||||
}
|
||||
|
||||
return nil, types.CreateThirdPartyThirdPartyMappingOutput{}, nil
|
||||
}
|
||||
|
||||
func (r *Resolver) DeleteThirdPartyThirdPartyMappingTool(ctx context.Context, req *mcp.CallToolRequest, input *types.DeleteThirdPartyThirdPartyMappingInput) (*mcp.CallToolResult, types.DeleteThirdPartyThirdPartyMappingOutput, error) {
|
||||
scope, err := r.Authorize(ctx, input.ParentThirdPartyID, probo.ActionThirdPartyRelationDelete)
|
||||
if err != nil {
|
||||
return nil, types.DeleteThirdPartyThirdPartyMappingOutput{}, err
|
||||
}
|
||||
|
||||
if err := r.proboSvc.ThirdParties.DeleteThirdPartyMapping(ctx, scope, input.ParentThirdPartyID, input.ChildThirdPartyID); err != nil {
|
||||
return nil, types.DeleteThirdPartyThirdPartyMappingOutput{}, fmt.Errorf("cannot delete third party mapping: %w", err)
|
||||
}
|
||||
|
||||
return nil, types.DeleteThirdPartyThirdPartyMappingOutput{}, nil
|
||||
}
|
||||
|
||||
func (r *Resolver) ListChildThirdPartiesTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListChildThirdPartiesInput) (*mcp.CallToolResult, types.ListChildThirdPartiesOutput, error) {
|
||||
scope, err := r.Authorize(ctx, input.ParentThirdPartyID, probo.ActionThirdPartyRelationList)
|
||||
if err != nil {
|
||||
return nil, types.ListChildThirdPartiesOutput{}, err
|
||||
}
|
||||
|
||||
pageOrderBy := page.OrderBy[coredata.ThirdPartyOrderField]{
|
||||
Field: coredata.ThirdPartyOrderFieldCreatedAt,
|
||||
Direction: page.OrderDirectionDesc,
|
||||
}
|
||||
if input.OrderBy != nil {
|
||||
pageOrderBy = page.OrderBy[coredata.ThirdPartyOrderField]{
|
||||
Field: input.OrderBy.Field,
|
||||
Direction: input.OrderBy.Direction,
|
||||
}
|
||||
}
|
||||
|
||||
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
|
||||
|
||||
page, err := r.proboSvc.ThirdParties.ListForParentThirdPartyID(ctx, scope, input.ParentThirdPartyID, cursor)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot list child third parties: %w", err))
|
||||
}
|
||||
|
||||
return nil, types.NewListChildThirdPartiesOutput(page), nil
|
||||
}
|
||||
|
||||
func (r *Resolver) ListRiskAssessmentsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListRiskAssessmentsInput) (*mcp.CallToolResult, types.ListRiskAssessmentsOutput, error) {
|
||||
scope, err := r.Authorize(ctx, input.OrganizationID, probo.ActionRiskAssessmentList)
|
||||
if err != nil {
|
||||
|
||||
@@ -627,6 +627,9 @@ components:
|
||||
organization_id:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Organization ID
|
||||
first_level:
|
||||
type: boolean
|
||||
description: Filter by first-level status
|
||||
order_by:
|
||||
$ref: "#/components/schemas/ThirdPartyOrderBy"
|
||||
description: ThirdParty order by
|
||||
@@ -650,6 +653,69 @@ components:
|
||||
items:
|
||||
$ref: "#/components/schemas/ThirdParty"
|
||||
|
||||
CreateThirdPartyThirdPartyMappingInput:
|
||||
type: object
|
||||
required:
|
||||
- parent_third_party_id
|
||||
- child_third_party_id
|
||||
properties:
|
||||
parent_third_party_id:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Parent third party ID
|
||||
child_third_party_id:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Child third party ID
|
||||
|
||||
CreateThirdPartyThirdPartyMappingOutput:
|
||||
type: object
|
||||
|
||||
DeleteThirdPartyThirdPartyMappingInput:
|
||||
type: object
|
||||
required:
|
||||
- parent_third_party_id
|
||||
- child_third_party_id
|
||||
properties:
|
||||
parent_third_party_id:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Parent third party ID
|
||||
child_third_party_id:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Child third party ID
|
||||
|
||||
DeleteThirdPartyThirdPartyMappingOutput:
|
||||
type: object
|
||||
|
||||
ListChildThirdPartiesInput:
|
||||
type: object
|
||||
required:
|
||||
- parent_third_party_id
|
||||
properties:
|
||||
parent_third_party_id:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Parent third party ID
|
||||
order_by:
|
||||
$ref: "#/components/schemas/ThirdPartyOrderBy"
|
||||
description: ThirdParty order by
|
||||
size:
|
||||
type: integer
|
||||
description: Page size
|
||||
cursor:
|
||||
$ref: "#/components/schemas/CursorKey"
|
||||
description: Page cursor
|
||||
|
||||
ListChildThirdPartiesOutput:
|
||||
type: object
|
||||
required:
|
||||
- thirdParties
|
||||
properties:
|
||||
next_cursor:
|
||||
$ref: "#/components/schemas/CursorKey"
|
||||
description: Next cursor
|
||||
thirdParties:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/ThirdParty"
|
||||
|
||||
ThirdParty:
|
||||
type: object
|
||||
required:
|
||||
@@ -657,6 +723,7 @@ components:
|
||||
- name
|
||||
- organization_id
|
||||
- category
|
||||
- first_level
|
||||
- created_at
|
||||
- updated_at
|
||||
properties:
|
||||
@@ -780,6 +847,9 @@ components:
|
||||
- string
|
||||
- "null"
|
||||
description: Trust page URL
|
||||
first_level:
|
||||
type: boolean
|
||||
description: Whether this is a first-level third party
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -11788,6 +11858,31 @@ tools:
|
||||
$ref: "#/components/schemas/ListThirdPartiesInput"
|
||||
outputSchema:
|
||||
$ref: "#/components/schemas/ListThirdPartiesOutput"
|
||||
- name: createThirdPartyThirdPartyMapping
|
||||
description: Link a child third party to a parent third party
|
||||
hints:
|
||||
readonly: false
|
||||
inputSchema:
|
||||
$ref: "#/components/schemas/CreateThirdPartyThirdPartyMappingInput"
|
||||
outputSchema:
|
||||
$ref: "#/components/schemas/CreateThirdPartyThirdPartyMappingOutput"
|
||||
- name: deleteThirdPartyThirdPartyMapping
|
||||
description: Unlink a child third party from a parent third party
|
||||
hints:
|
||||
readonly: false
|
||||
inputSchema:
|
||||
$ref: "#/components/schemas/DeleteThirdPartyThirdPartyMappingInput"
|
||||
outputSchema:
|
||||
$ref: "#/components/schemas/DeleteThirdPartyThirdPartyMappingOutput"
|
||||
- name: listChildThirdParties
|
||||
description: List child third parties linked to a parent third party
|
||||
hints:
|
||||
readonly: true
|
||||
idempotent: true
|
||||
inputSchema:
|
||||
$ref: "#/components/schemas/ListChildThirdPartiesInput"
|
||||
outputSchema:
|
||||
$ref: "#/components/schemas/ListChildThirdPartiesOutput"
|
||||
- name: listUsers
|
||||
description: List all users for the organization
|
||||
hints:
|
||||
|
||||
@@ -87,6 +87,7 @@ func NewThirdParty(v *coredata.ThirdParty) *ThirdParty {
|
||||
TermsOfServiceURL: v.TermsOfServiceURL,
|
||||
SecurityPageURL: v.SecurityPageURL,
|
||||
TrustPageURL: v.TrustPageURL,
|
||||
FirstLevel: v.FirstLevel,
|
||||
CreatedAt: v.CreatedAt,
|
||||
UpdatedAt: v.UpdatedAt,
|
||||
}
|
||||
@@ -111,6 +112,25 @@ func NewListThirdPartiesOutput(thirdPartyPage *page.Page[*coredata.ThirdParty, c
|
||||
}
|
||||
}
|
||||
|
||||
func NewListChildThirdPartiesOutput(thirdPartyPage *page.Page[*coredata.ThirdParty, coredata.ThirdPartyOrderField]) ListChildThirdPartiesOutput {
|
||||
thirdParties := make([]*ThirdParty, 0, len(thirdPartyPage.Data))
|
||||
for _, v := range thirdPartyPage.Data {
|
||||
thirdParties = append(thirdParties, NewThirdParty(v))
|
||||
}
|
||||
|
||||
var nextCursor *page.CursorKey
|
||||
|
||||
if len(thirdPartyPage.Data) > 0 {
|
||||
cursorKey := thirdPartyPage.Data[len(thirdPartyPage.Data)-1].CursorKey(thirdPartyPage.Cursor.OrderBy.Field)
|
||||
nextCursor = &cursorKey
|
||||
}
|
||||
|
||||
return ListChildThirdPartiesOutput{
|
||||
NextCursor: nextCursor,
|
||||
ThirdParties: thirdParties,
|
||||
}
|
||||
}
|
||||
|
||||
func NewAddThirdPartyOutput(v *coredata.ThirdParty) AddThirdPartyOutput {
|
||||
return AddThirdPartyOutput{
|
||||
ThirdParty: NewThirdParty(v),
|
||||
|
||||
@@ -65,7 +65,7 @@ func (s ThirdPartyService) ListForOrganizationId(
|
||||
ctx,
|
||||
func(ctx context.Context, conn pg.Querier) error {
|
||||
showOnTrustCenter := true
|
||||
filter := coredata.NewThirdPartyFilter(&showOnTrustCenter)
|
||||
filter := coredata.NewThirdPartyFilter(&showOnTrustCenter, nil)
|
||||
|
||||
err := thirdParties.LoadByOrganizationID(ctx, conn, scope, organizationID, cursor, filter)
|
||||
if err != nil {
|
||||
@@ -99,7 +99,7 @@ func (s ThirdPartyService) CountForTrustCenterId(
|
||||
|
||||
thirdParties := &coredata.ThirdParties{}
|
||||
showOnTrustCenter := true
|
||||
filter := coredata.NewThirdPartyFilter(&showOnTrustCenter)
|
||||
filter := coredata.NewThirdPartyFilter(&showOnTrustCenter, nil)
|
||||
|
||||
count, err = thirdParties.CountByOrganizationID(ctx, conn, scope, trustCenter.OrganizationID, filter)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user