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