Scope sub-third-parties per parent
Replace the many-to-many junction table with a direct parent_third_party_id foreign key on third_parties. Each sub-third-party now belongs to exactly one parent, making duplicates across parents independent entities. Replace the firstLevel boolean with an integer level field (1 = direct, 2+ = parent level + 1) to support arbitrary nesting depth. Remove the createThirdPartyThirdPartyMapping and deleteThirdPartyThirdPartyMapping mutations, the CLI link/unlink commands, and the corresponding MCP tools. Creating a child third party now just requires passing parentThirdPartyId on the existing createThirdParty mutation. The frontend walks the parentThirdParty chain to build display names like "Name (Ancestor1/Ancestor2)" and shows clickable ancestor links on the detail page. Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
@@ -24,7 +24,7 @@ type ThirdParty = {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
websiteUrl: string | null | undefined;
|
websiteUrl: string | null | undefined;
|
||||||
firstLevel?: boolean;
|
level?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props<T extends FieldValues = FieldValues> = {
|
type Props<T extends FieldValues = FieldValues> = {
|
||||||
|
|||||||
@@ -22,8 +22,7 @@ import { graphql } from "relay-runtime";
|
|||||||
import type { ThirdPartyGraphCreateMutation } from "#/__generated__/core/ThirdPartyGraphCreateMutation.graphql";
|
import type { ThirdPartyGraphCreateMutation } from "#/__generated__/core/ThirdPartyGraphCreateMutation.graphql";
|
||||||
import type { ThirdPartyGraphDeleteMutation } from "#/__generated__/core/ThirdPartyGraphDeleteMutation.graphql";
|
import type { ThirdPartyGraphDeleteMutation } from "#/__generated__/core/ThirdPartyGraphDeleteMutation.graphql";
|
||||||
import type { ThirdPartyGraphSelectQuery } from "#/__generated__/core/ThirdPartyGraphSelectQuery.graphql";
|
import type { ThirdPartyGraphSelectQuery } from "#/__generated__/core/ThirdPartyGraphSelectQuery.graphql";
|
||||||
|
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||||
import { useMutationWithToasts } from "../useMutationWithToasts";
|
|
||||||
|
|
||||||
/* eslint-disable relay/unused-fields, relay/must-colocate-fragment-spreads */
|
/* eslint-disable relay/unused-fields, relay/must-colocate-fragment-spreads */
|
||||||
|
|
||||||
@@ -51,14 +50,10 @@ const createThirdPartyMutation = graphql`
|
|||||||
|
|
||||||
export function useCreateThirdPartyMutation() {
|
export function useCreateThirdPartyMutation() {
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
|
return useMutationWithToasts<ThirdPartyGraphCreateMutation>(createThirdPartyMutation, {
|
||||||
return useMutationWithToasts<ThirdPartyGraphCreateMutation>(
|
successMessage: __("Third party created successfully"),
|
||||||
createThirdPartyMutation,
|
|
||||||
{
|
|
||||||
successMessage: __("Third party created successfully."),
|
|
||||||
errorMessage: __("Failed to create third party"),
|
errorMessage: __("Failed to create third party"),
|
||||||
},
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteThirdPartyMutation = graphql`
|
const deleteThirdPartyMutation = graphql`
|
||||||
@@ -138,7 +133,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 } }
|
filter: { type: "ThirdPartyFilter", defaultValue: { level: 1 } }
|
||||||
) {
|
) {
|
||||||
thirdParties(
|
thirdParties(
|
||||||
first: $first
|
first: $first
|
||||||
@@ -154,6 +149,7 @@ export const paginatedThirdPartiesFragment = graphql`
|
|||||||
id
|
id
|
||||||
name
|
name
|
||||||
websiteUrl
|
websiteUrl
|
||||||
|
level
|
||||||
updatedAt
|
updatedAt
|
||||||
riskAssessments(
|
riskAssessments(
|
||||||
first: 1
|
first: 1
|
||||||
@@ -184,7 +180,11 @@ export const thirdPartyNodeQuery = graphql`
|
|||||||
... on ThirdParty {
|
... on ThirdParty {
|
||||||
name
|
name
|
||||||
websiteUrl
|
websiteUrl
|
||||||
firstLevel
|
level
|
||||||
|
ancestors {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
vettingStatus
|
vettingStatus
|
||||||
canVet: permission(action: "core:thirdParty:vet")
|
canVet: permission(action: "core:thirdParty:vet")
|
||||||
canUpdate: permission(action: "core:thirdParty:update")
|
canUpdate: permission(action: "core:thirdParty:update")
|
||||||
@@ -229,14 +229,13 @@ export const thirdPartiesSelectQuery = graphql`
|
|||||||
thirdParties(
|
thirdParties(
|
||||||
first: 100
|
first: 100
|
||||||
orderBy: { direction: ASC, field: NAME }
|
orderBy: { direction: ASC, field: NAME }
|
||||||
filter: { firstLevel: true }
|
|
||||||
) {
|
) {
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
websiteUrl
|
websiteUrl
|
||||||
firstLevel
|
level
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ const thirdPartiesFragment = graphql`
|
|||||||
last: { type: "Int", defaultValue: null }
|
last: { type: "Int", defaultValue: null }
|
||||||
before: { type: "CursorKey", defaultValue: null }
|
before: { type: "CursorKey", defaultValue: null }
|
||||||
order: { type: "ThirdPartyOrder", defaultValue: null }
|
order: { type: "ThirdPartyOrder", defaultValue: null }
|
||||||
filter: { type: "ThirdPartyFilter", defaultValue: { firstLevel: true } }
|
filter: { type: "ThirdPartyFilter", defaultValue: { level: 1 } }
|
||||||
)
|
)
|
||||||
@refetchable(queryName: "LinkedThirdPartiesDialogRefetchQuery") {
|
@refetchable(queryName: "LinkedThirdPartiesDialogRefetchQuery") {
|
||||||
thirdParties(
|
thirdParties(
|
||||||
@@ -159,7 +159,7 @@ function LinkedThirdPartiesDialogContent({
|
|||||||
refetch({
|
refetch({
|
||||||
first: 20,
|
first: 20,
|
||||||
filter: {
|
filter: {
|
||||||
firstLevel: true,
|
level: 1,
|
||||||
query: v,
|
query: v,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,15 +26,12 @@ 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,
|
||||||
@@ -61,6 +58,12 @@ import { PublishThirdPartyListDialog } from "./dialogs/PublishThirdPartyListDial
|
|||||||
|
|
||||||
type ThirdParty = NodeOf<ThirdPartyGraphPaginatedFragment$data["thirdParties"]>;
|
type ThirdParty = NodeOf<ThirdPartyGraphPaginatedFragment$data["thirdParties"]>;
|
||||||
|
|
||||||
|
function thirdPartyDisplayName(tp: ThirdParty): string {
|
||||||
|
// Sub-third-parties are persisted with their fully-qualified name
|
||||||
|
// (e.g. "aws (Probo/Level2/Level3)"), so the stored name is shown as-is.
|
||||||
|
return tp.name;
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
queryRef: PreloadedQuery<ThirdPartyGraphListQuery>;
|
queryRef: PreloadedQuery<ThirdPartyGraphListQuery>;
|
||||||
};
|
};
|
||||||
@@ -79,21 +82,9 @@ 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);
|
||||||
|
|
||||||
@@ -144,20 +135,6 @@ 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>
|
||||||
@@ -198,6 +175,7 @@ function ThirdPartyRow({
|
|||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const latestAssessment = thirdParty.riskAssessments?.edges[0]?.node;
|
const latestAssessment = thirdParty.riskAssessments?.edges[0]?.node;
|
||||||
const deleteThirdParty = useDeleteThirdParty(thirdParty, connectionId);
|
const deleteThirdParty = useDeleteThirdParty(thirdParty, connectionId);
|
||||||
|
const displayName = thirdPartyDisplayName(thirdParty);
|
||||||
|
|
||||||
const thirdPartyUrl = `/organizations/${organizationId}/third-parties/${thirdParty.id}/overview`;
|
const thirdPartyUrl = `/organizations/${organizationId}/third-parties/${thirdParty.id}/overview`;
|
||||||
|
|
||||||
@@ -207,7 +185,7 @@ function ThirdPartyRow({
|
|||||||
<Td>
|
<Td>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<Avatar name={thirdParty.name} src={faviconUrl(thirdParty.websiteUrl)} />
|
<Avatar name={thirdParty.name} src={faviconUrl(thirdParty.websiteUrl)} />
|
||||||
<div>{thirdParty.name}</div>
|
<div>{displayName}</div>
|
||||||
</div>
|
</div>
|
||||||
</Td>
|
</Td>
|
||||||
<Td>
|
<Td>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import {
|
|||||||
usePreloadedQuery,
|
usePreloadedQuery,
|
||||||
useRelayEnvironment,
|
useRelayEnvironment,
|
||||||
} from "react-relay";
|
} from "react-relay";
|
||||||
import { Outlet } from "react-router";
|
import { Link, Outlet } from "react-router";
|
||||||
import { fetchQuery } from "relay-runtime";
|
import { fetchQuery } from "relay-runtime";
|
||||||
|
|
||||||
import type { ThirdPartyComplianceTabFragment$key } from "#/__generated__/core/ThirdPartyComplianceTabFragment.graphql";
|
import type { ThirdPartyComplianceTabFragment$key } from "#/__generated__/core/ThirdPartyComplianceTabFragment.graphql";
|
||||||
@@ -104,6 +104,8 @@ export default function ThirdPartyDetailPage(props: Props) {
|
|||||||
|
|
||||||
const isVettingFailed = thirdParty.vettingStatus === "FAILED";
|
const isVettingFailed = thirdParty.vettingStatus === "FAILED";
|
||||||
|
|
||||||
|
const ancestors = thirdParty.ancestors ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{isVetting && (
|
{isVetting && (
|
||||||
@@ -142,10 +144,26 @@ export default function ThirdPartyDetailPage(props: Props) {
|
|||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-3">
|
<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"}>
|
<Badge variant={thirdParty.level === 1 ? "info" : "neutral"}>
|
||||||
{thirdParty.firstLevel ? __("First Level") : __("Indirect")}
|
{`${__("Level")} ${thirdParty.level}`}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
{ancestors.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1 text-sm text-txt-secondary">
|
||||||
|
<span className="text-txt-tertiary">{__("From:")}</span>
|
||||||
|
{ancestors.map((ancestor, i) => (
|
||||||
|
<span key={ancestor.id}>
|
||||||
|
{i > 0 && " / "}
|
||||||
|
<Link
|
||||||
|
to={`/organizations/${organizationId}/third-parties/${ancestor.id}/overview`}
|
||||||
|
className="text-txt-primary underline hover:no-underline"
|
||||||
|
>
|
||||||
|
{ancestor.name}
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
{thirdParty.canVet && !isVetting && (
|
{thirdParty.canVet && !isVetting && (
|
||||||
|
|||||||
@@ -12,15 +12,14 @@
|
|||||||
// 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,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
|
IconPlusLarge,
|
||||||
useDialogRef,
|
useDialogRef,
|
||||||
} from "@probo/ui";
|
} from "@probo/ui";
|
||||||
import { type ReactNode, Suspense, useCallback, useState } from "react";
|
import { type ReactNode, Suspense, useCallback, useState } from "react";
|
||||||
@@ -28,20 +27,18 @@ import { useMutation, useQueryLoader } from "react-relay";
|
|||||||
import { graphql } from "relay-runtime";
|
import { graphql } from "relay-runtime";
|
||||||
import { useDebounceCallback } from "usehooks-ts";
|
import { useDebounceCallback } from "usehooks-ts";
|
||||||
|
|
||||||
import type { AddChildThirdPartyDialogCreateMappingMutation } from "#/__generated__/core/AddChildThirdPartyDialogCreateMappingMutation.graphql";
|
|
||||||
import type { AddChildThirdPartyDialogCreateMutation } from "#/__generated__/core/AddChildThirdPartyDialogCreateMutation.graphql";
|
import type { AddChildThirdPartyDialogCreateMutation } from "#/__generated__/core/AddChildThirdPartyDialogCreateMutation.graphql";
|
||||||
import type { CommonThirdPartyComboboxQuery } from "#/__generated__/core/CommonThirdPartyComboboxQuery.graphql";
|
import type { CommonThirdPartyComboboxQuery } from "#/__generated__/core/CommonThirdPartyComboboxQuery.graphql";
|
||||||
import type { CreateThirdPartyInput } from "#/__generated__/core/ThirdPartyGraphCreateMutation.graphql";
|
import type { CreateThirdPartyInput } from "#/__generated__/core/ThirdPartyGraphCreateMutation.graphql";
|
||||||
import { useThirdParties } from "#/hooks/graph/ThirdPartyGraph";
|
|
||||||
|
|
||||||
import { commonThirdPartiesQuery, CommonThirdPartyCombobox } from "./CommonThirdPartyCombobox";
|
import { commonThirdPartiesQuery, CommonThirdPartyCombobox } from "./CommonThirdPartyCombobox";
|
||||||
|
|
||||||
const createMappingMutation = graphql`
|
const createChildMutation = graphql`
|
||||||
mutation AddChildThirdPartyDialogCreateMappingMutation(
|
mutation AddChildThirdPartyDialogCreateMutation(
|
||||||
$input: CreateThirdPartyThirdPartyMappingInput!
|
$input: CreateThirdPartyInput!
|
||||||
$connections: [ID!]!
|
$connections: [ID!]!
|
||||||
) {
|
) {
|
||||||
createThirdPartyThirdPartyMapping(input: $input) {
|
createThirdParty(input: $input) {
|
||||||
thirdPartyEdge @prependEdge(connections: $connections) {
|
thirdPartyEdge @prependEdge(connections: $connections) {
|
||||||
node {
|
node {
|
||||||
id
|
id
|
||||||
@@ -54,40 +51,24 @@ const createMappingMutation = graphql`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const createThirdPartyMutation = graphql`
|
|
||||||
mutation AddChildThirdPartyDialogCreateMutation(
|
|
||||||
$input: CreateThirdPartyInput!
|
|
||||||
) {
|
|
||||||
createThirdParty(input: $input) {
|
|
||||||
thirdPartyEdge {
|
|
||||||
node {
|
|
||||||
id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
parentThirdPartyId: string;
|
parentThirdPartyId: string;
|
||||||
|
parentNamePath: string[];
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
connectionId: string;
|
connectionId: string;
|
||||||
existingChildIds: string[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AddChildThirdPartyDialog({
|
export function AddChildThirdPartyDialog({
|
||||||
children,
|
children,
|
||||||
parentThirdPartyId,
|
parentThirdPartyId,
|
||||||
|
parentNamePath,
|
||||||
organizationId,
|
organizationId,
|
||||||
connectionId,
|
connectionId,
|
||||||
existingChildIds,
|
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const dialogRef = useDialogRef();
|
const dialogRef = useDialogRef();
|
||||||
const thirdParties = useThirdParties(organizationId);
|
const [createChild] = useMutation<AddChildThirdPartyDialogCreateMutation>(createChildMutation);
|
||||||
const [createMapping] = useMutation<AddChildThirdPartyDialogCreateMappingMutation>(createMappingMutation);
|
|
||||||
const [createThirdParty] = useMutation<AddChildThirdPartyDialogCreateMutation>(createThirdPartyMutation);
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [queryRef, loadQuery] = useQueryLoader<CommonThirdPartyComboboxQuery>(commonThirdPartiesQuery);
|
const [queryRef, loadQuery] = useQueryLoader<CommonThirdPartyComboboxQuery>(commonThirdPartiesQuery);
|
||||||
|
|
||||||
@@ -109,44 +90,18 @@ export function AddChildThirdPartyDialog({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const existingThirdParties = thirdParties.filter(
|
const handleCreate = (input: Omit<CreateThirdPartyInput, "organizationId">) => {
|
||||||
tp =>
|
const qualifiedName = parentNamePath.length > 0
|
||||||
tp.id !== parentThirdPartyId
|
? `${input.name} (${parentNamePath.join("/")})`
|
||||||
&& !existingChildIds.includes(tp.id)
|
: input.name;
|
||||||
&& tp.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSelectExisting = (childId: string) => {
|
void createChild({
|
||||||
createMapping({
|
|
||||||
variables: {
|
variables: {
|
||||||
input: {
|
input: {
|
||||||
parentThirdPartyId,
|
...input,
|
||||||
childThirdPartyId: childId,
|
name: qualifiedName,
|
||||||
},
|
|
||||||
connections: [connectionId],
|
|
||||||
},
|
|
||||||
onCompleted: () => {
|
|
||||||
dialogRef.current?.close();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectCommon = (common: Omit<CreateThirdPartyInput, "organizationId">) => {
|
|
||||||
createThirdParty({
|
|
||||||
variables: {
|
|
||||||
input: {
|
|
||||||
...common,
|
|
||||||
organizationId,
|
organizationId,
|
||||||
firstLevel: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onCompleted: (response) => {
|
|
||||||
const newId = response.createThirdParty.thirdPartyEdge.node.id;
|
|
||||||
createMapping({
|
|
||||||
variables: {
|
|
||||||
input: {
|
|
||||||
parentThirdPartyId,
|
parentThirdPartyId,
|
||||||
childThirdPartyId: newId,
|
|
||||||
},
|
},
|
||||||
connections: [connectionId],
|
connections: [connectionId],
|
||||||
},
|
},
|
||||||
@@ -154,31 +109,34 @@ export function AddChildThirdPartyDialog({
|
|||||||
dialogRef.current?.close();
|
dialogRef.current?.close();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const existingNames = new Set(thirdParties.map(tp => tp.name.toLowerCase()));
|
const handleCreateNew = (name: string) => {
|
||||||
|
handleCreate({ name, category: null });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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")}>
|
||||||
{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 && (
|
{searchQuery.trim().length >= 2 && queryRef && (
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<CommonThirdPartyCombobox
|
<CommonThirdPartyCombobox
|
||||||
queryRef={queryRef}
|
queryRef={queryRef}
|
||||||
excludeNames={existingNames}
|
excludeNames={new Set()}
|
||||||
onSelect={handleSelectCommon}
|
onSelect={handleCreate}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
|
{searchQuery.trim().length >= 2 && (
|
||||||
|
<ComboboxItem onClick={() => handleCreateNew(searchQuery.trim())}>
|
||||||
|
<IconPlusLarge size={20} />
|
||||||
|
{__("Create a new third party")}
|
||||||
|
{" "}
|
||||||
|
:
|
||||||
|
{searchQuery}
|
||||||
|
</ComboboxItem>
|
||||||
|
)}
|
||||||
</Combobox>
|
</Combobox>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogFooter />
|
<DialogFooter />
|
||||||
|
|||||||
@@ -12,10 +12,8 @@
|
|||||||
// 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,
|
||||||
@@ -25,31 +23,15 @@ 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 { useMutation, useQueryLoader } from "react-relay";
|
import { 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, useThirdParties } from "#/hooks/graph/ThirdPartyGraph";
|
import { useCreateThirdPartyMutation } from "#/hooks/graph/ThirdPartyGraph";
|
||||||
|
|
||||||
import { commonThirdPartiesQuery, CommonThirdPartyCombobox } from "./CommonThirdPartyCombobox";
|
import { commonThirdPartiesQuery, CommonThirdPartyCombobox } from "./CommonThirdPartyCombobox";
|
||||||
|
|
||||||
const promoteMutation = graphql`
|
|
||||||
mutation CreateThirdPartyDialogPromoteMutation(
|
|
||||||
$input: UpdateThirdPartyInput!
|
|
||||||
) {
|
|
||||||
updateThirdParty(input: $input) {
|
|
||||||
thirdParty {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
firstLevel
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
@@ -63,48 +45,12 @@ 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(
|
const onSelect = (thirdParty: Omit<CreateThirdPartyInput, "organizationId"> | string) => {
|
||||||
thirdParties
|
|
||||||
.filter(tp => !tp.firstLevel)
|
|
||||||
.map(tp => [tp.name.toLowerCase(), tp]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const existingNames = new Set(thirdParties.map(tp => tp.name.toLowerCase()));
|
|
||||||
|
|
||||||
const onSelect = async (thirdParty: Omit<CreateThirdPartyInput, "organizationId"> | string) => {
|
|
||||||
const name = typeof thirdParty === "string" ? thirdParty : thirdParty.name;
|
|
||||||
const existing = nonFirstLevelByName.get(name.toLowerCase());
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
promoteThirdParty({
|
|
||||||
variables: {
|
|
||||||
input: { id: existing.id, firstLevel: true },
|
|
||||||
},
|
|
||||||
updater: (store) => {
|
|
||||||
const payload = store.getRootField("updateThirdParty");
|
|
||||||
const node = payload?.getLinkedRecord("thirdParty");
|
|
||||||
if (!node) return;
|
|
||||||
|
|
||||||
const connectionRecord = store.get(connection);
|
|
||||||
if (!connectionRecord) return;
|
|
||||||
|
|
||||||
const edge = ConnectionHandler.createEdge(store, connectionRecord, node, "ThirdPartyEdge");
|
|
||||||
ConnectionHandler.insertEdgeBefore(connectionRecord, edge);
|
|
||||||
},
|
|
||||||
onCompleted: () => {
|
|
||||||
dialogRef.current?.close();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const input
|
const input
|
||||||
= typeof thirdParty === "string"
|
= typeof thirdParty === "string"
|
||||||
? {
|
? {
|
||||||
@@ -116,7 +62,7 @@ export function CreateThirdPartyDialog({
|
|||||||
...thirdParty,
|
...thirdParty,
|
||||||
organizationId,
|
organizationId,
|
||||||
};
|
};
|
||||||
await createThirdParty({
|
void createThirdParty({
|
||||||
variables: {
|
variables: {
|
||||||
input,
|
input,
|
||||||
connections: [connection],
|
connections: [connection],
|
||||||
@@ -149,26 +95,11 @@ 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}
|
excludeNames={new Set()}
|
||||||
onSelect={thirdPartyRef => void onSelect(thirdPartyRef)}
|
onSelect={thirdPartyRef => void onSelect(thirdPartyRef)}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import {
|
|||||||
} from "react-relay";
|
} from "react-relay";
|
||||||
import { graphql } from "relay-runtime";
|
import { graphql } from "relay-runtime";
|
||||||
|
|
||||||
import type { ThirdPartyThirdPartiesPageDeleteMappingMutation } from "#/__generated__/core/ThirdPartyThirdPartiesPageDeleteMappingMutation.graphql";
|
import type { ThirdPartyThirdPartiesPageDeleteMutation } from "#/__generated__/core/ThirdPartyThirdPartiesPageDeleteMutation.graphql";
|
||||||
import type { ThirdPartyThirdPartiesPageFragment$key } from "#/__generated__/core/ThirdPartyThirdPartiesPageFragment.graphql";
|
import type { ThirdPartyThirdPartiesPageFragment$key } from "#/__generated__/core/ThirdPartyThirdPartiesPageFragment.graphql";
|
||||||
import type { ThirdPartyThirdPartiesPagePaginationQuery } from "#/__generated__/core/ThirdPartyThirdPartiesPagePaginationQuery.graphql";
|
import type { ThirdPartyThirdPartiesPagePaginationQuery } from "#/__generated__/core/ThirdPartyThirdPartiesPagePaginationQuery.graphql";
|
||||||
import type { ThirdPartyThirdPartiesPageQuery } from "#/__generated__/core/ThirdPartyThirdPartiesPageQuery.graphql";
|
import type { ThirdPartyThirdPartiesPageQuery } from "#/__generated__/core/ThirdPartyThirdPartiesPageQuery.graphql";
|
||||||
@@ -47,6 +47,9 @@ import { useOrganizationId } from "#/hooks/useOrganizationId";
|
|||||||
|
|
||||||
import { AddChildThirdPartyDialog } from "../dialogs/AddChildThirdPartyDialog";
|
import { AddChildThirdPartyDialog } from "../dialogs/AddChildThirdPartyDialog";
|
||||||
|
|
||||||
|
// Keep in sync with coredata.MaxThirdPartyLevel on the backend.
|
||||||
|
const MAX_THIRD_PARTY_LEVEL = 4;
|
||||||
|
|
||||||
export const thirdPartyThirdPartiesPageQuery = graphql`
|
export const thirdPartyThirdPartiesPageQuery = graphql`
|
||||||
query ThirdPartyThirdPartiesPageQuery($thirdPartyId: ID!) {
|
query ThirdPartyThirdPartiesPageQuery($thirdPartyId: ID!) {
|
||||||
node(id: $thirdPartyId) {
|
node(id: $thirdPartyId) {
|
||||||
@@ -54,6 +57,10 @@ export const thirdPartyThirdPartiesPageQuery = graphql`
|
|||||||
... on ThirdParty {
|
... on ThirdParty {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
|
level
|
||||||
|
ancestors {
|
||||||
|
name
|
||||||
|
}
|
||||||
canUpdate: permission(action: "core:thirdParty:update")
|
canUpdate: permission(action: "core:thirdParty:update")
|
||||||
...ThirdPartyThirdPartiesPageFragment
|
...ThirdPartyThirdPartiesPageFragment
|
||||||
}
|
}
|
||||||
@@ -103,13 +110,13 @@ const paginatedFragment = graphql`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const deleteMappingMutation = graphql`
|
const deleteChildMutation = graphql`
|
||||||
mutation ThirdPartyThirdPartiesPageDeleteMappingMutation(
|
mutation ThirdPartyThirdPartiesPageDeleteMutation(
|
||||||
$input: DeleteThirdPartyThirdPartyMappingInput!
|
$input: DeleteThirdPartyInput!
|
||||||
$connections: [ID!]!
|
$connections: [ID!]!
|
||||||
) {
|
) {
|
||||||
deleteThirdPartyThirdPartyMapping(input: $input) {
|
deleteThirdParty(input: $input) {
|
||||||
removedThirdPartyId @deleteEdge(connections: $connections)
|
deletedThirdPartyId @deleteEdge(connections: $connections)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -129,7 +136,7 @@ export default function ThirdPartyThirdPartiesPage({ queryRef }: Props) {
|
|||||||
ThirdPartyThirdPartiesPagePaginationQuery,
|
ThirdPartyThirdPartiesPagePaginationQuery,
|
||||||
ThirdPartyThirdPartiesPageFragment$key
|
ThirdPartyThirdPartiesPageFragment$key
|
||||||
>(paginatedFragment, thirdParty as ThirdPartyThirdPartiesPageFragment$key);
|
>(paginatedFragment, thirdParty as ThirdPartyThirdPartiesPageFragment$key);
|
||||||
const [deleteMapping] = useMutation<ThirdPartyThirdPartiesPageDeleteMappingMutation>(deleteMappingMutation);
|
const [deleteChild] = useMutation<ThirdPartyThirdPartiesPageDeleteMutation>(deleteChildMutation);
|
||||||
|
|
||||||
usePageTitle((thirdParty?.name ?? "") + " - " + __("Third Parties"));
|
usePageTitle((thirdParty?.name ?? "") + " - " + __("Third Parties"));
|
||||||
|
|
||||||
@@ -140,16 +147,22 @@ export default function ThirdPartyThirdPartiesPage({ queryRef }: Props) {
|
|||||||
const connectionId = pagination.data.childThirdParties.__id;
|
const connectionId = pagination.data.childThirdParties.__id;
|
||||||
const childThirdParties = pagination.data.childThirdParties.edges.map(edge => edge.node);
|
const childThirdParties = pagination.data.childThirdParties.edges.map(edge => edge.node);
|
||||||
|
|
||||||
const handleRemove = (childId: string, childName: string) => {
|
// Strip any existing " (...)" suffix so the chain stays clean even when a
|
||||||
|
// parent's stored name is itself already qualified.
|
||||||
|
const baseName = (name: string) => name.replace(/\s*\([^)]*\)\s*$/, "");
|
||||||
|
const parentAncestors = thirdParty.ancestors ?? [];
|
||||||
|
const parentNamePath = [
|
||||||
|
...parentAncestors.map(ancestor => baseName(ancestor.name)),
|
||||||
|
baseName(thirdParty.name),
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleDelete = (childId: string, childName: string) => {
|
||||||
confirm(
|
confirm(
|
||||||
() =>
|
() =>
|
||||||
new Promise<void>((resolve, reject) => {
|
new Promise<void>((resolve, reject) => {
|
||||||
deleteMapping({
|
deleteChild({
|
||||||
variables: {
|
variables: {
|
||||||
input: {
|
input: { thirdPartyId: childId },
|
||||||
parentThirdPartyId: thirdParty.id,
|
|
||||||
childThirdPartyId: childId,
|
|
||||||
},
|
|
||||||
connections: [connectionId],
|
connections: [connectionId],
|
||||||
},
|
},
|
||||||
onCompleted: () => resolve(),
|
onCompleted: () => resolve(),
|
||||||
@@ -157,7 +170,7 @@ export default function ThirdPartyThirdPartiesPage({ queryRef }: Props) {
|
|||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
message: `${__("Remove")} "${childName}" ${__("from this third party?")}`,
|
message: `${__("Delete")} "${childName}"?`,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -168,12 +181,12 @@ export default function ThirdPartyThirdPartiesPage({ queryRef }: Props) {
|
|||||||
title={__("Third Parties")}
|
title={__("Third Parties")}
|
||||||
description={__("Manage third parties linked to this third party.")}
|
description={__("Manage third parties linked to this third party.")}
|
||||||
>
|
>
|
||||||
{thirdParty.canUpdate && (
|
{thirdParty.canUpdate && thirdParty.level < MAX_THIRD_PARTY_LEVEL && (
|
||||||
<AddChildThirdPartyDialog
|
<AddChildThirdPartyDialog
|
||||||
parentThirdPartyId={thirdParty.id}
|
parentThirdPartyId={thirdParty.id}
|
||||||
|
parentNamePath={parentNamePath}
|
||||||
organizationId={organizationId}
|
organizationId={organizationId}
|
||||||
connectionId={connectionId}
|
connectionId={connectionId}
|
||||||
existingChildIds={childThirdParties.map(c => c.id)}
|
|
||||||
>
|
>
|
||||||
<Button icon={IconPlusLarge}>{__("Add third party")}</Button>
|
<Button icon={IconPlusLarge}>{__("Add third party")}</Button>
|
||||||
</AddChildThirdPartyDialog>
|
</AddChildThirdPartyDialog>
|
||||||
@@ -223,7 +236,7 @@ export default function ThirdPartyThirdPartiesPage({ queryRef }: Props) {
|
|||||||
<Button
|
<Button
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
icon={IconTrashCan}
|
icon={IconTrashCan}
|
||||||
onClick={() => handleRemove(child.id, child.name)}
|
onClick={() => handleDelete(child.id, child.name)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Td>
|
</Td>
|
||||||
|
|||||||
@@ -23,14 +23,12 @@ import (
|
|||||||
"go.probo.inc/probo/e2e/internal/testutil"
|
"go.probo.inc/probo/e2e/internal/testutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestThirdPartyRelation_AddAndList(t *testing.T) {
|
func TestThirdPartyRelation_CreateChildAndList(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
|
|
||||||
parentID := factory.NewThirdParty(owner).WithName("Parent Corp").Create()
|
parentID := factory.NewThirdParty(owner).WithName("Parent Corp").Create()
|
||||||
childID := factory.NewThirdParty(owner).WithName("Child Corp").Create()
|
childID := createChildThirdParty(t, owner, parentID, "Child Corp")
|
||||||
|
|
||||||
addRelation(t, owner, parentID, childID)
|
|
||||||
|
|
||||||
t.Run("list child third parties", func(t *testing.T) {
|
t.Run("list child third parties", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -74,90 +72,146 @@ func TestThirdPartyRelation_AddAndList(t *testing.T) {
|
|||||||
require.Len(t, result.Node.ChildThirdParties.Edges, 1)
|
require.Len(t, result.Node.ChildThirdParties.Edges, 1)
|
||||||
assert.Equal(t, childID, result.Node.ChildThirdParties.Edges[0].Node.ID)
|
assert.Equal(t, childID, result.Node.ChildThirdParties.Edges[0].Node.ID)
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
func TestThirdPartyRelation_Remove(t *testing.T) {
|
t.Run("child has parent reference", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
|
||||||
|
|
||||||
parentID := factory.NewThirdParty(owner).WithName("Parent Remove").Create()
|
const query = `
|
||||||
childID := factory.NewThirdParty(owner).WithName("Child Remove").Create()
|
query($id: ID!) {
|
||||||
|
node(id: $id) {
|
||||||
addRelation(t, owner, parentID, childID)
|
... on ThirdParty {
|
||||||
|
parentThirdParty {
|
||||||
const removeQuery = `
|
id
|
||||||
mutation($input: DeleteThirdPartyThirdPartyMappingInput!) {
|
}
|
||||||
deleteThirdPartyThirdPartyMapping(input: $input) {
|
}
|
||||||
removedThirdPartyId
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
DeleteThirdPartyThirdPartyMapping struct {
|
Node struct {
|
||||||
RemovedThirdPartyID string `json:"removedThirdPartyId"`
|
ParentThirdParty *struct {
|
||||||
} `json:"deleteThirdPartyThirdPartyMapping"`
|
ID string `json:"id"`
|
||||||
|
} `json:"parentThirdParty"`
|
||||||
|
} `json:"node"`
|
||||||
}
|
}
|
||||||
|
|
||||||
err := owner.Execute(removeQuery, map[string]any{
|
err := owner.Execute(query, map[string]any{"id": childID}, &result)
|
||||||
"input": map[string]any{
|
|
||||||
"parentThirdPartyId": parentID,
|
|
||||||
"childThirdPartyId": childID,
|
|
||||||
},
|
|
||||||
}, &result)
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, childID, result.DeleteThirdPartyThirdPartyMapping.RemovedThirdPartyID)
|
require.NotNil(t, result.Node.ParentThirdParty)
|
||||||
|
assert.Equal(t, parentID, result.Node.ParentThirdParty.ID)
|
||||||
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) {
|
func TestThirdPartyRelation_Ancestors(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
|
|
||||||
parentID := factory.NewThirdParty(owner).WithName("Idempotent Parent").Create()
|
rootID := factory.NewThirdParty(owner).WithName("Ancestor Root").Create()
|
||||||
childID := factory.NewThirdParty(owner).WithName("Idempotent Child").Create()
|
midID := createChildThirdParty(t, owner, rootID, "Ancestor Mid")
|
||||||
|
leafID := createChildThirdParty(t, owner, midID, "Ancestor Leaf")
|
||||||
|
|
||||||
addRelation(t, owner, parentID, childID)
|
t.Run("root has no ancestors", func(t *testing.T) {
|
||||||
addRelation(t, owner, parentID, childID)
|
t.Parallel()
|
||||||
|
|
||||||
count := countChildThirdParties(t, owner, parentID)
|
const query = `
|
||||||
assert.Equal(t, 1, count)
|
query($id: ID!) {
|
||||||
|
node(id: $id) {
|
||||||
|
... on ThirdParty {
|
||||||
|
ancestors {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Node struct {
|
||||||
|
Ancestors []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"ancestors"`
|
||||||
|
} `json:"node"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestThirdPartyRelation_CascadeOnDelete(t *testing.T) {
|
err := owner.Execute(query, map[string]any{"id": rootID}, &result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, result.Node.Ancestors)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("mid has one ancestor", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
query($id: ID!) {
|
||||||
|
node(id: $id) {
|
||||||
|
... on ThirdParty {
|
||||||
|
ancestors {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Node struct {
|
||||||
|
Ancestors []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"ancestors"`
|
||||||
|
} `json:"node"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := owner.Execute(query, map[string]any{"id": midID}, &result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, result.Node.Ancestors, 1)
|
||||||
|
assert.Equal(t, rootID, result.Node.Ancestors[0].ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("leaf returns ancestors root-first", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
query($id: ID!) {
|
||||||
|
node(id: $id) {
|
||||||
|
... on ThirdParty {
|
||||||
|
ancestors {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Node struct {
|
||||||
|
Ancestors []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"ancestors"`
|
||||||
|
} `json:"node"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := owner.Execute(query, map[string]any{"id": leafID}, &result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, result.Node.Ancestors, 2)
|
||||||
|
assert.Equal(t, rootID, result.Node.Ancestors[0].ID, "root should be first")
|
||||||
|
assert.Equal(t, midID, result.Node.Ancestors[1].ID, "mid should be second")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestThirdPartyRelation_DeleteChild(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
|
|
||||||
parentID := factory.NewThirdParty(owner).WithName("Cascade Parent").Create()
|
parentID := factory.NewThirdParty(owner).WithName("Parent Remove").Create()
|
||||||
childID := factory.NewThirdParty(owner).WithName("Cascade Child").Create()
|
childID := createChildThirdParty(t, owner, parentID, "Child Remove")
|
||||||
|
|
||||||
addRelation(t, owner, parentID, childID)
|
|
||||||
|
|
||||||
const deleteQuery = `
|
const deleteQuery = `
|
||||||
mutation($input: DeleteThirdPartyInput!) {
|
mutation($input: DeleteThirdPartyInput!) {
|
||||||
@@ -178,7 +232,9 @@ func TestThirdPartyRelation_CascadeOnDelete(t *testing.T) {
|
|||||||
"thirdPartyId": childID,
|
"thirdPartyId": childID,
|
||||||
},
|
},
|
||||||
}, &result)
|
}, &result)
|
||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, childID, result.DeleteThirdParty.DeletedThirdPartyID)
|
||||||
|
|
||||||
count := countChildThirdParties(t, owner, parentID)
|
count := countChildThirdParties(t, owner, parentID)
|
||||||
assert.Equal(t, 0, count)
|
assert.Equal(t, 0, count)
|
||||||
@@ -190,51 +246,6 @@ func TestThirdPartyRelation_Authorization(t *testing.T) {
|
|||||||
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
|
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
|
||||||
|
|
||||||
parentID := factory.NewThirdParty(owner).WithName("Auth Parent").Create()
|
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.Run("viewer can list child third parties", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -251,32 +262,7 @@ func TestThirdPartyRelation_TenantIsolation(t *testing.T) {
|
|||||||
org2Owner := testutil.NewClient(t, testutil.RoleOwner)
|
org2Owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
|
|
||||||
parentID := factory.NewThirdParty(org1Owner).WithName("Org1 Parent").Create()
|
parentID := factory.NewThirdParty(org1Owner).WithName("Org1 Parent").Create()
|
||||||
childID := factory.NewThirdParty(org1Owner).WithName("Org1 Child").Create()
|
createChildThirdParty(t, org1Owner, parentID, "Org1 Child")
|
||||||
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.Run("cannot list children of other org third party", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -309,58 +295,73 @@ func TestThirdPartyRelation_TenantIsolation(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestThirdParty_DirectFilter(t *testing.T) {
|
func TestThirdParty_LevelFilter(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
|
|
||||||
factory.NewThirdParty(owner).WithName("Direct TP").Create()
|
factory.NewThirdParty(owner).WithName("Direct TP").Create()
|
||||||
|
|
||||||
const createNonDirect = `
|
const createThirdParty = `
|
||||||
mutation($input: CreateThirdPartyInput!) {
|
mutation($input: CreateThirdPartyInput!) {
|
||||||
createThirdParty(input: $input) {
|
createThirdParty(input: $input) {
|
||||||
thirdPartyEdge {
|
thirdPartyEdge {
|
||||||
node {
|
node {
|
||||||
id
|
id
|
||||||
firstLevel
|
level
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
var createResult struct {
|
type createThirdPartyResult struct {
|
||||||
CreateThirdParty struct {
|
CreateThirdParty struct {
|
||||||
ThirdPartyEdge struct {
|
ThirdPartyEdge struct {
|
||||||
Node struct {
|
Node struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
FirstLevel bool `json:"firstLevel"`
|
Level int `json:"level"`
|
||||||
} `json:"node"`
|
} `json:"node"`
|
||||||
} `json:"thirdPartyEdge"`
|
} `json:"thirdPartyEdge"`
|
||||||
} `json:"createThirdParty"`
|
} `json:"createThirdParty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
err := owner.Execute(createNonDirect, map[string]any{
|
// A level-2 third party can only exist as the child of a level-1 parent;
|
||||||
|
// the level is derived from the parent rather than supplied by the client.
|
||||||
|
var parentResult createThirdPartyResult
|
||||||
|
|
||||||
|
err := owner.Execute(createThirdParty, map[string]any{
|
||||||
|
"input": map[string]any{
|
||||||
|
"organizationId": owner.GetOrganizationID().String(),
|
||||||
|
"name": factory.SafeName("Parent TP"),
|
||||||
|
},
|
||||||
|
}, &parentResult)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 1, parentResult.CreateThirdParty.ThirdPartyEdge.Node.Level)
|
||||||
|
|
||||||
|
var createResult createThirdPartyResult
|
||||||
|
|
||||||
|
err = owner.Execute(createThirdParty, map[string]any{
|
||||||
"input": map[string]any{
|
"input": map[string]any{
|
||||||
"organizationId": owner.GetOrganizationID().String(),
|
"organizationId": owner.GetOrganizationID().String(),
|
||||||
"name": factory.SafeName("NonDirect TP"),
|
"name": factory.SafeName("NonDirect TP"),
|
||||||
"firstLevel": false,
|
"parentThirdPartyId": parentResult.CreateThirdParty.ThirdPartyEdge.Node.ID,
|
||||||
},
|
},
|
||||||
}, &createResult)
|
}, &createResult)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.False(t, createResult.CreateThirdParty.ThirdPartyEdge.Node.FirstLevel)
|
assert.Equal(t, 2, createResult.CreateThirdParty.ThirdPartyEdge.Node.Level)
|
||||||
|
|
||||||
t.Run("filter firstLevel only", func(t *testing.T) {
|
t.Run("filter level 1 only", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
query($orgId: ID!) {
|
query($orgId: ID!) {
|
||||||
node(id: $orgId) {
|
node(id: $orgId) {
|
||||||
... on Organization {
|
... on Organization {
|
||||||
thirdParties(first: 100, filter: { firstLevel: true }) {
|
thirdParties(first: 100, filter: { level: 1 }) {
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
id
|
id
|
||||||
firstLevel
|
level
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -375,7 +376,7 @@ func TestThirdParty_DirectFilter(t *testing.T) {
|
|||||||
Edges []struct {
|
Edges []struct {
|
||||||
Node struct {
|
Node struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
FirstLevel bool `json:"firstLevel"`
|
Level int `json:"level"`
|
||||||
} `json:"node"`
|
} `json:"node"`
|
||||||
} `json:"edges"`
|
} `json:"edges"`
|
||||||
} `json:"thirdParties"`
|
} `json:"thirdParties"`
|
||||||
@@ -388,7 +389,7 @@ func TestThirdParty_DirectFilter(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
for _, edge := range result.Node.ThirdParties.Edges {
|
for _, edge := range result.Node.ThirdParties.Edges {
|
||||||
assert.True(t, edge.Node.FirstLevel, "expected all third parties to be firstLevel when filtering direct=true")
|
assert.Equal(t, 1, edge.Node.Level, "expected all third parties to be level 1 when filtering level=1")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -403,7 +404,7 @@ func TestThirdParty_DirectFilter(t *testing.T) {
|
|||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
id
|
id
|
||||||
firstLevel
|
level
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -418,7 +419,7 @@ func TestThirdParty_DirectFilter(t *testing.T) {
|
|||||||
Edges []struct {
|
Edges []struct {
|
||||||
Node struct {
|
Node struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
FirstLevel bool `json:"firstLevel"`
|
Level int `json:"level"`
|
||||||
} `json:"node"`
|
} `json:"node"`
|
||||||
} `json:"edges"`
|
} `json:"edges"`
|
||||||
} `json:"thirdParties"`
|
} `json:"thirdParties"`
|
||||||
@@ -430,28 +431,28 @@ func TestThirdParty_DirectFilter(t *testing.T) {
|
|||||||
}, &result)
|
}, &result)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
hasFirstLevel := false
|
hasLevel1 := false
|
||||||
hasNonFirstLevel := false
|
hasLevel2 := false
|
||||||
|
|
||||||
for _, edge := range result.Node.ThirdParties.Edges {
|
for _, edge := range result.Node.ThirdParties.Edges {
|
||||||
if edge.Node.FirstLevel {
|
if edge.Node.Level == 1 {
|
||||||
hasFirstLevel = true
|
hasLevel1 = true
|
||||||
} else {
|
} else if edge.Node.Level >= 2 {
|
||||||
hasNonFirstLevel = true
|
hasLevel2 = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.True(t, hasFirstLevel, "expected at least one first-level third party")
|
assert.True(t, hasLevel1, "expected at least one level-1 third party")
|
||||||
assert.True(t, hasNonFirstLevel, "expected at least one non-first-level third party")
|
assert.True(t, hasLevel2, "expected at least one level-2+ third party")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func addRelation(t *testing.T, c *testutil.Client, parentID, childID string) {
|
func createChildThirdParty(t *testing.T, c *testutil.Client, parentID, name string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
mutation($input: CreateThirdPartyThirdPartyMappingInput!) {
|
mutation($input: CreateThirdPartyInput!) {
|
||||||
createThirdPartyThirdPartyMapping(input: $input) {
|
createThirdParty(input: $input) {
|
||||||
thirdPartyEdge {
|
thirdPartyEdge {
|
||||||
node { id }
|
node { id }
|
||||||
}
|
}
|
||||||
@@ -460,22 +461,25 @@ func addRelation(t *testing.T, c *testutil.Client, parentID, childID string) {
|
|||||||
`
|
`
|
||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
CreateThirdPartyThirdPartyMapping struct {
|
CreateThirdParty struct {
|
||||||
ThirdPartyEdge struct {
|
ThirdPartyEdge struct {
|
||||||
Node struct {
|
Node struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
} `json:"node"`
|
} `json:"node"`
|
||||||
} `json:"thirdPartyEdge"`
|
} `json:"thirdPartyEdge"`
|
||||||
} `json:"createThirdPartyThirdPartyMapping"`
|
} `json:"createThirdParty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
err := c.Execute(query, map[string]any{
|
err := c.Execute(query, map[string]any{
|
||||||
"input": map[string]any{
|
"input": map[string]any{
|
||||||
|
"organizationId": c.GetOrganizationID().String(),
|
||||||
|
"name": factory.SafeName(name),
|
||||||
"parentThirdPartyId": parentID,
|
"parentThirdPartyId": parentID,
|
||||||
"childThirdPartyId": childID,
|
|
||||||
},
|
},
|
||||||
}, &result)
|
}, &result)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return result.CreateThirdParty.ThirdPartyEdge.Node.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
func countChildThirdParties(t *testing.T, c *testutil.Client, parentID string) int {
|
func countChildThirdParties(t *testing.T, c *testutil.Client, parentID string) int {
|
||||||
|
|||||||
@@ -177,19 +177,19 @@ export const description: INodeProperties[] = [
|
|||||||
type: 'string',
|
type: 'string',
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Parent Third Party ID',
|
||||||
|
name: 'parentThirdPartyId',
|
||||||
|
type: 'string',
|
||||||
|
default: '',
|
||||||
|
description: 'The ID of the parent third party (creates a child relationship; level is derived from the parent)',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
displayName: 'Privacy Policy URL',
|
displayName: 'Privacy Policy URL',
|
||||||
name: 'privacyPolicyUrl',
|
name: 'privacyPolicyUrl',
|
||||||
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',
|
||||||
@@ -256,7 +256,7 @@ export async function execute(
|
|||||||
trustPageUrl?: string;
|
trustPageUrl?: string;
|
||||||
certifications?: string;
|
certifications?: string;
|
||||||
countries?: string;
|
countries?: string;
|
||||||
firstLevel?: boolean;
|
parentThirdPartyId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
@@ -283,7 +283,7 @@ export async function execute(
|
|||||||
certifications
|
certifications
|
||||||
countries
|
countries
|
||||||
showOnTrustCenter
|
showOnTrustCenter
|
||||||
firstLevel
|
level
|
||||||
createdAt
|
createdAt
|
||||||
updatedAt
|
updatedAt
|
||||||
}
|
}
|
||||||
@@ -312,7 +312,10 @@ 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.parentThirdPartyId) {
|
||||||
|
// The server derives the level from the parent (parent.level + 1).
|
||||||
|
input.parentThirdPartyId = additionalFields.parentThirdPartyId;
|
||||||
|
}
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,11 +74,11 @@ export const description: INodeProperties[] = [
|
|||||||
},
|
},
|
||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
displayName: 'Filter by Root',
|
displayName: 'Filter by Level',
|
||||||
name: 'filterFirstLevel',
|
name: 'filterLevel',
|
||||||
type: 'boolean',
|
type: 'number',
|
||||||
default: false,
|
default: 0,
|
||||||
description: 'Whether to filter by first-level third parties only',
|
description: 'Filter by third party level (1 = direct, 2+ = indirect, 0 = no filter)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
displayName: 'Include Organization',
|
displayName: 'Include Organization',
|
||||||
@@ -113,7 +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;
|
filterLevel?: number;
|
||||||
includeOrganization?: boolean;
|
includeOrganization?: boolean;
|
||||||
includeBusinessOwner?: boolean;
|
includeBusinessOwner?: boolean;
|
||||||
includeSecurityOwner?: boolean;
|
includeSecurityOwner?: boolean;
|
||||||
@@ -142,8 +142,8 @@ export async function execute(
|
|||||||
}`
|
}`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
const filterVariable = options.filterFirstLevel !== undefined ? ', $filter: ThirdPartyFilter' : '';
|
const filterVariable = options.filterLevel ? ', $filter: ThirdPartyFilter' : '';
|
||||||
const filterArgument = options.filterFirstLevel !== undefined ? ', filter: $filter' : '';
|
const filterArgument = options.filterLevel ? ', filter: $filter' : '';
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
query GetThirdParties($organizationId: ID!, $first: Int, $after: CursorKey${filterVariable}) {
|
query GetThirdParties($organizationId: ID!, $first: Int, $after: CursorKey${filterVariable}) {
|
||||||
@@ -171,7 +171,11 @@ export async function execute(
|
|||||||
certifications
|
certifications
|
||||||
countries
|
countries
|
||||||
showOnTrustCenter
|
showOnTrustCenter
|
||||||
firstLevel
|
level
|
||||||
|
ancestors {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
${organizationFragment}
|
${organizationFragment}
|
||||||
${businessOwnerFragment}
|
${businessOwnerFragment}
|
||||||
${securityOwnerFragment}
|
${securityOwnerFragment}
|
||||||
@@ -190,8 +194,8 @@ export async function execute(
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const variables: IDataObject = { organizationId };
|
const variables: IDataObject = { organizationId };
|
||||||
if (options.filterFirstLevel) {
|
if (options.filterLevel) {
|
||||||
variables.filter = { firstLevel: true };
|
variables.filter = { level: options.filterLevel };
|
||||||
}
|
}
|
||||||
|
|
||||||
const thirdParties = await proboApiRequestAllItems.call(
|
const thirdParties = await proboApiRequestAllItems.call(
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
// Copyright (c) 2025-2026 Probo Inc <hello@probo.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
|
|
||||||
}
|
|
||||||
@@ -59,7 +59,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
|||||||
flagLimit int
|
flagLimit int
|
||||||
flagOrderBy string
|
flagOrderBy string
|
||||||
flagOrderDir string
|
flagOrderDir string
|
||||||
flagFirstLevel bool
|
flagLevel int
|
||||||
flagOutput *string
|
flagOutput *string
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -108,9 +108,13 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
|||||||
"id": flagOrg,
|
"id": flagOrg,
|
||||||
}
|
}
|
||||||
|
|
||||||
if cmd.Flags().Changed("first-level") {
|
if cmd.Flags().Changed("level") {
|
||||||
|
if flagLevel < 1 {
|
||||||
|
return fmt.Errorf("invalid --level value %d: must be greater than or equal to 1", flagLevel)
|
||||||
|
}
|
||||||
|
|
||||||
variables["filter"] = map[string]any{
|
variables["filter"] = map[string]any{
|
||||||
"first-level": flagFirstLevel,
|
"level": flagLevel,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +199,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")
|
cmd.Flags().IntVar(&flagLevel, "level", 0, "Filter by third party level (1 = direct, 2+ = indirect)")
|
||||||
flagOutput = cmdutil.AddOutputFlag(cmd)
|
flagOutput = cmdutil.AddOutputFlag(cmd)
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
|
|||||||
@@ -19,10 +19,8 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/cmd/cmdutil"
|
"go.probo.inc/probo/pkg/cmd/cmdutil"
|
||||||
"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/vet"
|
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/vet"
|
||||||
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/view"
|
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/view"
|
||||||
@@ -41,8 +39,6 @@ func NewCmdThirdParty(f *cmdutil.Factory) *cobra.Command {
|
|||||||
cmd.AddCommand(delete.NewCmdDelete(f))
|
cmd.AddCommand(delete.NewCmdDelete(f))
|
||||||
cmd.AddCommand(vet.NewCmdVet(f))
|
cmd.AddCommand(vet.NewCmdVet(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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
// Copyright (c) 2025-2026 Probo Inc <hello@probo.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
|
|
||||||
}
|
|
||||||
@@ -1141,8 +1141,16 @@ func (h *trackerMappingHandler) prepareOrgThirdParty(
|
|||||||
return prep, fmt.Errorf("cannot load common third party domains: %w", err)
|
return prep, fmt.Errorf("cannot load common third party domains: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
firstLevel := 1
|
||||||
|
|
||||||
var orgThirdParties coredata.ThirdParties
|
var orgThirdParties coredata.ThirdParties
|
||||||
if err := orgThirdParties.LoadAllByOrganizationID(ctx, conn, scope, tp.OrganizationID); err != nil {
|
if err := orgThirdParties.LoadAllByOrganizationID(
|
||||||
|
ctx,
|
||||||
|
conn,
|
||||||
|
scope,
|
||||||
|
tp.OrganizationID,
|
||||||
|
coredata.NewThirdPartyFilter(nil, &firstLevel, nil),
|
||||||
|
); err != nil {
|
||||||
return prep, fmt.Errorf("cannot load org third parties: %w", err)
|
return prep, fmt.Errorf("cannot load org third parties: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ func TestPromoteThirdParty_FallbackCreate(t *testing.T) {
|
|||||||
require.NotNil(t, reloaded.CommonThirdPartyID)
|
require.NotNil(t, reloaded.CommonThirdPartyID)
|
||||||
assert.Equal(t, fx.commonThirdPartyID, *reloaded.CommonThirdPartyID)
|
assert.Equal(t, fx.commonThirdPartyID, *reloaded.CommonThirdPartyID)
|
||||||
assert.Equal(t, coredata.ThirdPartyCategoryAnalytics, reloaded.Category)
|
assert.Equal(t, coredata.ThirdPartyCategoryAnalytics, reloaded.Category)
|
||||||
assert.True(t, reloaded.FirstLevel)
|
assert.Equal(t, 1, reloaded.Level)
|
||||||
assert.False(t, reloaded.ShowOnTrustCenter)
|
assert.False(t, reloaded.ShowOnTrustCenter)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
251
pkg/coredata/migrations/20260608T120000Z.sql
Normal file
251
pkg/coredata/migrations/20260608T120000Z.sql
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
-- Copyright (c) 2025-2026 Probo Inc <hello@probo.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.
|
||||||
|
|
||||||
|
-- ADD COLUMN ... DEFAULT 1 already backfills every existing row to level 1, so
|
||||||
|
-- only the sub-third-parties (first_level = false) need correcting to level 2.
|
||||||
|
-- Scoping the UPDATE to them leaves first-level rows entirely untouched.
|
||||||
|
ALTER TABLE third_parties ADD COLUMN level integer NOT NULL DEFAULT 1;
|
||||||
|
UPDATE third_parties SET level = 2 WHERE first_level = false;
|
||||||
|
ALTER TABLE third_parties ALTER COLUMN level DROP DEFAULT;
|
||||||
|
|
||||||
|
-- Keep first_level around instead of dropping it so the change is reversible and
|
||||||
|
-- old code paths keep working during a rolling deploy. New inserts no longer set
|
||||||
|
-- it, so give it a default to satisfy the NOT NULL constraint.
|
||||||
|
ALTER TABLE third_parties ALTER COLUMN first_level SET DEFAULT true;
|
||||||
|
|
||||||
|
ALTER TABLE third_parties ADD COLUMN parent_third_party_id text REFERENCES third_parties(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- Pick each non-first-level child's primary parent: the parent it will be
|
||||||
|
-- re-parented to in place. Only first_level = false rows are eligible — a
|
||||||
|
-- first-level row stays a root (its links become copies below, never an
|
||||||
|
-- in-place move). Prefer a top-level (first_level = true) parent so the child
|
||||||
|
-- anchors to a real root, then fall back to the relationship that was created
|
||||||
|
-- first. IDs are text GIDs, so MIN() would order them lexicographically rather
|
||||||
|
-- than chronologically — sort by the junction row's created_at instead, with
|
||||||
|
-- the parent id as a deterministic tie-break.
|
||||||
|
CREATE TEMP TABLE _tp_primary_parent AS
|
||||||
|
SELECT DISTINCT ON (tpr.child_third_party_id)
|
||||||
|
tpr.child_third_party_id,
|
||||||
|
tpr.parent_third_party_id
|
||||||
|
FROM third_party_third_parties tpr
|
||||||
|
JOIN third_parties parent ON parent.id = tpr.parent_third_party_id
|
||||||
|
JOIN third_parties child ON child.id = tpr.child_third_party_id
|
||||||
|
WHERE child.first_level = false
|
||||||
|
ORDER BY
|
||||||
|
tpr.child_third_party_id,
|
||||||
|
parent.first_level DESC,
|
||||||
|
tpr.created_at ASC,
|
||||||
|
tpr.parent_third_party_id ASC;
|
||||||
|
|
||||||
|
-- Assign every existing child its primary parent in place. Updating the row in
|
||||||
|
-- place preserves its ID and therefore every dependent record (risk
|
||||||
|
-- assessments, services, contacts, measure links, …) — nothing is deleted or
|
||||||
|
-- re-keyed for the common one-parent case.
|
||||||
|
-- Restricted to first_level = false: only sub-third-parties may gain a parent;
|
||||||
|
-- a top-level (first_level = true) row must stay at level 1 with no parent even
|
||||||
|
-- if it appears in the junction table by accident.
|
||||||
|
UPDATE third_parties tp
|
||||||
|
SET parent_third_party_id = primary_parent.parent_third_party_id
|
||||||
|
FROM _tp_primary_parent AS primary_parent
|
||||||
|
WHERE tp.id = primary_parent.child_third_party_id
|
||||||
|
AND tp.first_level = false;
|
||||||
|
|
||||||
|
-- Re-qualify the names of the reparented sub-third-parties to the hierarchy
|
||||||
|
-- convention used by the console/vetting ("base (root/.../parent)"), matching
|
||||||
|
-- the copies created below. Names are rebuilt from base names (trailing " (…)"
|
||||||
|
-- stripped) so already-qualified rows are normalized rather than double-suffixed.
|
||||||
|
WITH RECURSIVE tp_path AS (
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
trim(regexp_replace(name, '\s*\([^)]*\)\s*$', '')) AS path
|
||||||
|
FROM third_parties
|
||||||
|
WHERE parent_third_party_id IS NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
p.path || '/' || trim(regexp_replace(c.name, '\s*\([^)]*\)\s*$', '')) AS path
|
||||||
|
FROM third_parties c
|
||||||
|
JOIN tp_path p ON p.id = c.parent_third_party_id
|
||||||
|
)
|
||||||
|
UPDATE third_parties tp
|
||||||
|
SET name = trim(regexp_replace(tp.name, '\s*\([^)]*\)\s*$', '')) || ' (' || pp.path || ')'
|
||||||
|
FROM tp_path pp
|
||||||
|
WHERE pp.id = tp.parent_third_party_id
|
||||||
|
AND tp.first_level = false;
|
||||||
|
|
||||||
|
-- Every junction row that is NOT an in-place reparent becomes its own copy: the
|
||||||
|
-- extra parents of a non-first-level child, plus every link whose child is
|
||||||
|
-- first_level = true (the root is preserved and a fresh sub-third-party copy is
|
||||||
|
-- created under the parent). Generate a fresh GID for each (child, parent) pair.
|
||||||
|
-- generate_gid() and parse_tenant_id() are defined in migration 20250420T120000Z.
|
||||||
|
CREATE TEMP TABLE _tp_copy_map AS
|
||||||
|
SELECT
|
||||||
|
tpr.child_third_party_id AS old_id,
|
||||||
|
tpr.parent_third_party_id AS parent_id,
|
||||||
|
generate_gid(parse_tenant_id(tp.tenant_id), 7) AS new_id
|
||||||
|
FROM third_party_third_parties tpr
|
||||||
|
JOIN third_parties tp ON tp.id = tpr.child_third_party_id
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM _tp_primary_parent pp
|
||||||
|
WHERE pp.child_third_party_id = tpr.child_third_party_id
|
||||||
|
AND pp.parent_third_party_id = tpr.parent_third_party_id
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Resolve each third party's hierarchy-qualified path (root → self, base names
|
||||||
|
-- joined by "/"), mirroring the console/vetting naming convention. A copy under
|
||||||
|
-- parent P is named "<child base> (<path of P>)", e.g. "Google Workspace (Probo)".
|
||||||
|
-- The base name strips any trailing " (…)" suffix exactly like the app regex.
|
||||||
|
WITH RECURSIVE tp_path AS (
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
level,
|
||||||
|
trim(regexp_replace(name, '\s*\([^)]*\)\s*$', '')) AS path
|
||||||
|
FROM third_parties
|
||||||
|
WHERE parent_third_party_id IS NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
c.level,
|
||||||
|
p.path || '/' || trim(regexp_replace(c.name, '\s*\([^)]*\)\s*$', '')) AS path
|
||||||
|
FROM third_parties c
|
||||||
|
JOIN tp_path p ON p.id = c.parent_third_party_id
|
||||||
|
)
|
||||||
|
INSERT INTO third_parties (
|
||||||
|
id,
|
||||||
|
tenant_id,
|
||||||
|
organization_id,
|
||||||
|
parent_third_party_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,
|
||||||
|
level,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
m.new_id,
|
||||||
|
tp.tenant_id,
|
||||||
|
tp.organization_id,
|
||||||
|
-- The parent is always an existing row that needs no remapping.
|
||||||
|
m.parent_id,
|
||||||
|
-- A copy is a relationship/subprocessor record, not the canonical
|
||||||
|
-- catalog-linked vendor. Leave common_third_party_id NULL so the original
|
||||||
|
-- root stays the single third party resolved for a common catalog entry
|
||||||
|
-- (e.g. cookie-banner tracker mapping by common_third_party_id).
|
||||||
|
NULL,
|
||||||
|
-- Hierarchy-qualified name: child base name suffixed with the parent's path.
|
||||||
|
trim(regexp_replace(tp.name, '\s*\([^)]*\)\s*$', '')) || ' (' || pp.path || ')',
|
||||||
|
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,
|
||||||
|
-- Copies are sub-third-parties (level >= 2), never first-level roots.
|
||||||
|
false,
|
||||||
|
-- Level follows the parent, not the copied child: a first-level child
|
||||||
|
-- (level 1) copied under Probo (level 1) becomes a level-2 sub-third-party.
|
||||||
|
pp.level + 1,
|
||||||
|
tp.created_at,
|
||||||
|
tp.updated_at
|
||||||
|
FROM _tp_copy_map m
|
||||||
|
JOIN third_parties tp ON tp.id = m.old_id
|
||||||
|
JOIN tp_path pp ON pp.id = m.parent_id;
|
||||||
|
|
||||||
|
INSERT INTO third_party_services (
|
||||||
|
tenant_id,
|
||||||
|
id,
|
||||||
|
organization_id,
|
||||||
|
third_party_id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
tp.tenant_id,
|
||||||
|
generate_gid(parse_tenant_id(tp.tenant_id), 30),
|
||||||
|
s.organization_id,
|
||||||
|
m.new_id,
|
||||||
|
s.name,
|
||||||
|
s.description,
|
||||||
|
s.created_at,
|
||||||
|
s.updated_at
|
||||||
|
FROM _tp_copy_map m
|
||||||
|
JOIN third_parties tp ON tp.id = m.old_id
|
||||||
|
JOIN third_party_services s ON s.third_party_id = m.old_id;
|
||||||
|
|
||||||
|
INSERT INTO third_party_contacts (
|
||||||
|
tenant_id,
|
||||||
|
id,
|
||||||
|
organization_id,
|
||||||
|
third_party_id,
|
||||||
|
full_name,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
role,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
tp.tenant_id,
|
||||||
|
generate_gid(parse_tenant_id(tp.tenant_id), 26),
|
||||||
|
c.organization_id,
|
||||||
|
m.new_id,
|
||||||
|
c.full_name,
|
||||||
|
c.email,
|
||||||
|
c.phone,
|
||||||
|
c.role,
|
||||||
|
c.created_at,
|
||||||
|
c.updated_at
|
||||||
|
FROM _tp_copy_map m
|
||||||
|
JOIN third_parties tp ON tp.id = m.old_id
|
||||||
|
JOIN third_party_contacts c ON c.third_party_id = m.old_id;
|
||||||
|
|
||||||
|
DROP TABLE _tp_copy_map;
|
||||||
|
DROP TABLE _tp_primary_parent;
|
||||||
|
DROP TABLE third_party_third_parties;
|
||||||
@@ -28,6 +28,11 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/page"
|
"go.probo.inc/probo/pkg/page"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MaxThirdPartyLevel is the deepest sub-third-party nesting allowed. Level 1 is
|
||||||
|
// a direct third party; each descendant adds one level, so the chain may not go
|
||||||
|
// beyond level 4.
|
||||||
|
const MaxThirdPartyLevel = 4
|
||||||
|
|
||||||
func (v ThirdParty) GetGeneratedDocumentID(
|
func (v ThirdParty) GetGeneratedDocumentID(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
conn pg.Querier,
|
conn pg.Querier,
|
||||||
@@ -140,6 +145,7 @@ type (
|
|||||||
ThirdParty struct {
|
ThirdParty struct {
|
||||||
ID gid.GID `db:"id"`
|
ID gid.GID `db:"id"`
|
||||||
OrganizationID gid.GID `db:"organization_id"`
|
OrganizationID gid.GID `db:"organization_id"`
|
||||||
|
ParentThirdPartyID *gid.GID `db:"parent_third_party_id"`
|
||||||
CommonThirdPartyID *gid.GID `db:"common_third_party_id"`
|
CommonThirdPartyID *gid.GID `db:"common_third_party_id"`
|
||||||
Name string `db:"name"`
|
Name string `db:"name"`
|
||||||
Description *string `db:"description"`
|
Description *string `db:"description"`
|
||||||
@@ -161,7 +167,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"`
|
Level int `db:"level"`
|
||||||
VettingStatus *ThirdPartyVettingStatus `db:"vetting_status"`
|
VettingStatus *ThirdPartyVettingStatus `db:"vetting_status"`
|
||||||
VettingWebsiteURL *string `db:"vetting_website_url"`
|
VettingWebsiteURL *string `db:"vetting_website_url"`
|
||||||
VettingProcedure *string `db:"vetting_procedure"`
|
VettingProcedure *string `db:"vetting_procedure"`
|
||||||
@@ -236,6 +242,7 @@ func (v *ThirdParty) LoadByID(
|
|||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
organization_id,
|
organization_id,
|
||||||
|
parent_third_party_id,
|
||||||
common_third_party_id,
|
common_third_party_id,
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
@@ -257,7 +264,7 @@ SELECT
|
|||||||
security_page_url,
|
security_page_url,
|
||||||
trust_page_url,
|
trust_page_url,
|
||||||
show_on_trust_center,
|
show_on_trust_center,
|
||||||
first_level,
|
level,
|
||||||
vetting_status,
|
vetting_status,
|
||||||
vetting_website_url,
|
vetting_website_url,
|
||||||
vetting_procedure,
|
vetting_procedure,
|
||||||
@@ -308,6 +315,7 @@ func (v *ThirdParty) LoadByIDForUpdate(
|
|||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
organization_id,
|
organization_id,
|
||||||
|
parent_third_party_id,
|
||||||
common_third_party_id,
|
common_third_party_id,
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
@@ -329,7 +337,7 @@ SELECT
|
|||||||
security_page_url,
|
security_page_url,
|
||||||
trust_page_url,
|
trust_page_url,
|
||||||
show_on_trust_center,
|
show_on_trust_center,
|
||||||
first_level,
|
level,
|
||||||
vetting_status,
|
vetting_status,
|
||||||
vetting_website_url,
|
vetting_website_url,
|
||||||
vetting_procedure,
|
vetting_procedure,
|
||||||
@@ -382,6 +390,7 @@ func (v *ThirdParty) LoadByNameAndOrganizationID(
|
|||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
organization_id,
|
organization_id,
|
||||||
|
parent_third_party_id,
|
||||||
common_third_party_id,
|
common_third_party_id,
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
@@ -403,7 +412,7 @@ SELECT
|
|||||||
security_page_url,
|
security_page_url,
|
||||||
trust_page_url,
|
trust_page_url,
|
||||||
show_on_trust_center,
|
show_on_trust_center,
|
||||||
first_level,
|
level,
|
||||||
vetting_status,
|
vetting_status,
|
||||||
vetting_website_url,
|
vetting_website_url,
|
||||||
vetting_procedure,
|
vetting_procedure,
|
||||||
@@ -448,16 +457,18 @@ LIMIT 1;
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *ThirdParties) LoadByIDs(
|
func (v *ThirdParty) LoadByNameAndParentThirdPartyID(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
conn pg.Querier,
|
conn pg.Querier,
|
||||||
scope Scoper,
|
scope Scoper,
|
||||||
thirdPartyIDs []gid.GID,
|
name string,
|
||||||
|
parentThirdPartyID gid.GID,
|
||||||
) error {
|
) error {
|
||||||
q := `
|
q := `
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
organization_id,
|
organization_id,
|
||||||
|
parent_third_party_id,
|
||||||
common_third_party_id,
|
common_third_party_id,
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
@@ -479,7 +490,84 @@ SELECT
|
|||||||
security_page_url,
|
security_page_url,
|
||||||
trust_page_url,
|
trust_page_url,
|
||||||
show_on_trust_center,
|
show_on_trust_center,
|
||||||
first_level,
|
level,
|
||||||
|
vetting_status,
|
||||||
|
vetting_website_url,
|
||||||
|
vetting_procedure,
|
||||||
|
vetting_processing_started_at,
|
||||||
|
vetting_error_message,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM
|
||||||
|
third_parties
|
||||||
|
WHERE
|
||||||
|
%s
|
||||||
|
AND parent_third_party_id = @parent_third_party_id
|
||||||
|
AND name = @name
|
||||||
|
LIMIT 1;
|
||||||
|
`
|
||||||
|
|
||||||
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||||
|
|
||||||
|
args := pgx.StrictNamedArgs{
|
||||||
|
"parent_third_party_id": parentThirdPartyID,
|
||||||
|
"name": name,
|
||||||
|
}
|
||||||
|
maps.Copy(args, scope.SQLArguments())
|
||||||
|
|
||||||
|
rows, err := conn.Query(ctx, q, args)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot query thirdParty by name and parent: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
thirdParty, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[ThirdParty])
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return ErrResourceNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("cannot collect thirdParty: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
*v = thirdParty
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *ThirdParties) LoadByIDs(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Querier,
|
||||||
|
scope Scoper,
|
||||||
|
thirdPartyIDs []gid.GID,
|
||||||
|
) error {
|
||||||
|
q := `
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
organization_id,
|
||||||
|
parent_third_party_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,
|
||||||
|
level,
|
||||||
vetting_status,
|
vetting_status,
|
||||||
vetting_website_url,
|
vetting_website_url,
|
||||||
vetting_procedure,
|
vetting_procedure,
|
||||||
@@ -525,6 +613,7 @@ INSERT INTO
|
|||||||
tenant_id,
|
tenant_id,
|
||||||
id,
|
id,
|
||||||
organization_id,
|
organization_id,
|
||||||
|
parent_third_party_id,
|
||||||
common_third_party_id,
|
common_third_party_id,
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
@@ -546,7 +635,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,
|
level,
|
||||||
vetting_status,
|
vetting_status,
|
||||||
vetting_website_url,
|
vetting_website_url,
|
||||||
vetting_procedure,
|
vetting_procedure,
|
||||||
@@ -559,6 +648,7 @@ VALUES (
|
|||||||
@tenant_id,
|
@tenant_id,
|
||||||
@third_party_id,
|
@third_party_id,
|
||||||
@organization_id,
|
@organization_id,
|
||||||
|
@parent_third_party_id,
|
||||||
@common_third_party_id,
|
@common_third_party_id,
|
||||||
@name,
|
@name,
|
||||||
@description,
|
@description,
|
||||||
@@ -580,7 +670,7 @@ VALUES (
|
|||||||
@security_page_url,
|
@security_page_url,
|
||||||
@trust_page_url,
|
@trust_page_url,
|
||||||
@show_on_trust_center,
|
@show_on_trust_center,
|
||||||
@first_level,
|
@level,
|
||||||
@vetting_status,
|
@vetting_status,
|
||||||
@vetting_website_url,
|
@vetting_website_url,
|
||||||
@vetting_procedure,
|
@vetting_procedure,
|
||||||
@@ -595,6 +685,7 @@ VALUES (
|
|||||||
"tenant_id": scope.GetTenantID(),
|
"tenant_id": scope.GetTenantID(),
|
||||||
"third_party_id": v.ID,
|
"third_party_id": v.ID,
|
||||||
"organization_id": v.OrganizationID,
|
"organization_id": v.OrganizationID,
|
||||||
|
"parent_third_party_id": v.ParentThirdPartyID,
|
||||||
"common_third_party_id": v.CommonThirdPartyID,
|
"common_third_party_id": v.CommonThirdPartyID,
|
||||||
"name": v.Name,
|
"name": v.Name,
|
||||||
"description": v.Description,
|
"description": v.Description,
|
||||||
@@ -616,7 +707,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,
|
"level": v.Level,
|
||||||
"vetting_status": v.VettingStatus,
|
"vetting_status": v.VettingStatus,
|
||||||
"vetting_website_url": v.VettingWebsiteURL,
|
"vetting_website_url": v.VettingWebsiteURL,
|
||||||
"vetting_procedure": v.VettingProcedure,
|
"vetting_procedure": v.VettingProcedure,
|
||||||
@@ -690,11 +781,13 @@ func (v *ThirdParties) LoadAllByOrganizationID(
|
|||||||
conn pg.Querier,
|
conn pg.Querier,
|
||||||
scope Scoper,
|
scope Scoper,
|
||||||
organizationID gid.GID,
|
organizationID gid.GID,
|
||||||
|
filter *ThirdPartyFilter,
|
||||||
) error {
|
) error {
|
||||||
q := `
|
q := `
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
organization_id,
|
organization_id,
|
||||||
|
parent_third_party_id,
|
||||||
common_third_party_id,
|
common_third_party_id,
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
@@ -716,7 +809,7 @@ SELECT
|
|||||||
security_page_url,
|
security_page_url,
|
||||||
trust_page_url,
|
trust_page_url,
|
||||||
show_on_trust_center,
|
show_on_trust_center,
|
||||||
first_level,
|
level,
|
||||||
vetting_status,
|
vetting_status,
|
||||||
vetting_website_url,
|
vetting_website_url,
|
||||||
vetting_procedure,
|
vetting_procedure,
|
||||||
@@ -729,12 +822,14 @@ FROM
|
|||||||
WHERE
|
WHERE
|
||||||
%s
|
%s
|
||||||
AND organization_id = @organization_id
|
AND organization_id = @organization_id
|
||||||
|
AND %s
|
||||||
ORDER BY name ASC
|
ORDER BY name ASC
|
||||||
`
|
`
|
||||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
|
||||||
|
|
||||||
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
||||||
maps.Copy(args, scope.SQLArguments())
|
maps.Copy(args, scope.SQLArguments())
|
||||||
|
maps.Copy(args, filter.SQLArguments())
|
||||||
|
|
||||||
rows, err := conn.Query(ctx, q, args)
|
rows, err := conn.Query(ctx, q, args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -763,6 +858,7 @@ func (v *ThirdParties) LoadByOrganizationID(
|
|||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
organization_id,
|
organization_id,
|
||||||
|
parent_third_party_id,
|
||||||
common_third_party_id,
|
common_third_party_id,
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
@@ -784,7 +880,7 @@ SELECT
|
|||||||
security_page_url,
|
security_page_url,
|
||||||
trust_page_url,
|
trust_page_url,
|
||||||
show_on_trust_center,
|
show_on_trust_center,
|
||||||
first_level,
|
level,
|
||||||
vetting_status,
|
vetting_status,
|
||||||
vetting_website_url,
|
vetting_website_url,
|
||||||
vetting_procedure,
|
vetting_procedure,
|
||||||
@@ -834,6 +930,7 @@ SET
|
|||||||
name = @name,
|
name = @name,
|
||||||
description = @description,
|
description = @description,
|
||||||
category = @category,
|
category = @category,
|
||||||
|
parent_third_party_id = @parent_third_party_id,
|
||||||
headquarter_address = @headquarter_address,
|
headquarter_address = @headquarter_address,
|
||||||
legal_name = @legal_name,
|
legal_name = @legal_name,
|
||||||
website_url = @website_url,
|
website_url = @website_url,
|
||||||
@@ -851,7 +948,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,
|
level = @level,
|
||||||
vetting_status = @vetting_status,
|
vetting_status = @vetting_status,
|
||||||
vetting_website_url = @vetting_website_url,
|
vetting_website_url = @vetting_website_url,
|
||||||
vetting_procedure = @vetting_procedure,
|
vetting_procedure = @vetting_procedure,
|
||||||
@@ -870,6 +967,7 @@ WHERE %s
|
|||||||
"name": v.Name,
|
"name": v.Name,
|
||||||
"description": v.Description,
|
"description": v.Description,
|
||||||
"category": v.Category,
|
"category": v.Category,
|
||||||
|
"parent_third_party_id": v.ParentThirdPartyID,
|
||||||
"headquarter_address": v.HeadquarterAddress,
|
"headquarter_address": v.HeadquarterAddress,
|
||||||
"legal_name": v.LegalName,
|
"legal_name": v.LegalName,
|
||||||
"website_url": v.WebsiteURL,
|
"website_url": v.WebsiteURL,
|
||||||
@@ -887,7 +985,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,
|
"level": v.Level,
|
||||||
"vetting_status": v.VettingStatus,
|
"vetting_status": v.VettingStatus,
|
||||||
"vetting_website_url": v.VettingWebsiteURL,
|
"vetting_website_url": v.VettingWebsiteURL,
|
||||||
"vetting_procedure": v.VettingProcedure,
|
"vetting_procedure": v.VettingProcedure,
|
||||||
@@ -996,6 +1094,7 @@ WITH vend AS (
|
|||||||
v.id,
|
v.id,
|
||||||
v.tenant_id,
|
v.tenant_id,
|
||||||
v.organization_id,
|
v.organization_id,
|
||||||
|
v.parent_third_party_id,
|
||||||
v.common_third_party_id,
|
v.common_third_party_id,
|
||||||
v.name,
|
v.name,
|
||||||
v.description,
|
v.description,
|
||||||
@@ -1017,7 +1116,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.level,
|
||||||
v.vetting_status,
|
v.vetting_status,
|
||||||
v.vetting_website_url,
|
v.vetting_website_url,
|
||||||
v.vetting_procedure,
|
v.vetting_procedure,
|
||||||
@@ -1035,6 +1134,7 @@ WITH vend AS (
|
|||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
organization_id,
|
organization_id,
|
||||||
|
parent_third_party_id,
|
||||||
common_third_party_id,
|
common_third_party_id,
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
@@ -1056,7 +1156,7 @@ SELECT
|
|||||||
security_page_url,
|
security_page_url,
|
||||||
trust_page_url,
|
trust_page_url,
|
||||||
show_on_trust_center,
|
show_on_trust_center,
|
||||||
first_level,
|
level,
|
||||||
vetting_status,
|
vetting_status,
|
||||||
vetting_website_url,
|
vetting_website_url,
|
||||||
vetting_procedure,
|
vetting_procedure,
|
||||||
@@ -1142,6 +1242,7 @@ WITH vend AS (
|
|||||||
v.id,
|
v.id,
|
||||||
v.tenant_id,
|
v.tenant_id,
|
||||||
v.organization_id,
|
v.organization_id,
|
||||||
|
v.parent_third_party_id,
|
||||||
v.common_third_party_id,
|
v.common_third_party_id,
|
||||||
v.name,
|
v.name,
|
||||||
v.description,
|
v.description,
|
||||||
@@ -1163,7 +1264,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.level,
|
||||||
v.vetting_status,
|
v.vetting_status,
|
||||||
v.vetting_website_url,
|
v.vetting_website_url,
|
||||||
v.vetting_procedure,
|
v.vetting_procedure,
|
||||||
@@ -1181,6 +1282,7 @@ WITH vend AS (
|
|||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
organization_id,
|
organization_id,
|
||||||
|
parent_third_party_id,
|
||||||
common_third_party_id,
|
common_third_party_id,
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
@@ -1202,7 +1304,7 @@ SELECT
|
|||||||
security_page_url,
|
security_page_url,
|
||||||
trust_page_url,
|
trust_page_url,
|
||||||
show_on_trust_center,
|
show_on_trust_center,
|
||||||
first_level,
|
level,
|
||||||
vetting_status,
|
vetting_status,
|
||||||
vetting_website_url,
|
vetting_website_url,
|
||||||
vetting_procedure,
|
vetting_procedure,
|
||||||
@@ -1248,6 +1350,7 @@ WITH vend AS (
|
|||||||
v.id,
|
v.id,
|
||||||
v.tenant_id,
|
v.tenant_id,
|
||||||
v.organization_id,
|
v.organization_id,
|
||||||
|
v.parent_third_party_id,
|
||||||
v.common_third_party_id,
|
v.common_third_party_id,
|
||||||
v.name,
|
v.name,
|
||||||
v.description,
|
v.description,
|
||||||
@@ -1269,7 +1372,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.level,
|
||||||
v.vetting_status,
|
v.vetting_status,
|
||||||
v.vetting_website_url,
|
v.vetting_website_url,
|
||||||
v.vetting_procedure,
|
v.vetting_procedure,
|
||||||
@@ -1287,6 +1390,7 @@ WITH vend AS (
|
|||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
organization_id,
|
organization_id,
|
||||||
|
parent_third_party_id,
|
||||||
common_third_party_id,
|
common_third_party_id,
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
@@ -1308,7 +1412,7 @@ SELECT
|
|||||||
security_page_url,
|
security_page_url,
|
||||||
trust_page_url,
|
trust_page_url,
|
||||||
show_on_trust_center,
|
show_on_trust_center,
|
||||||
first_level,
|
level,
|
||||||
vetting_status,
|
vetting_status,
|
||||||
vetting_website_url,
|
vetting_website_url,
|
||||||
vetting_procedure,
|
vetting_procedure,
|
||||||
@@ -1355,6 +1459,7 @@ WITH vend AS (
|
|||||||
v.id,
|
v.id,
|
||||||
v.tenant_id,
|
v.tenant_id,
|
||||||
v.organization_id,
|
v.organization_id,
|
||||||
|
v.parent_third_party_id,
|
||||||
v.common_third_party_id,
|
v.common_third_party_id,
|
||||||
v.name,
|
v.name,
|
||||||
v.description,
|
v.description,
|
||||||
@@ -1376,7 +1481,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.level,
|
||||||
v.vetting_status,
|
v.vetting_status,
|
||||||
v.vetting_website_url,
|
v.vetting_website_url,
|
||||||
v.vetting_procedure,
|
v.vetting_procedure,
|
||||||
@@ -1394,6 +1499,7 @@ WITH vend AS (
|
|||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
organization_id,
|
organization_id,
|
||||||
|
parent_third_party_id,
|
||||||
common_third_party_id,
|
common_third_party_id,
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
@@ -1415,7 +1521,7 @@ SELECT
|
|||||||
security_page_url,
|
security_page_url,
|
||||||
trust_page_url,
|
trust_page_url,
|
||||||
show_on_trust_center,
|
show_on_trust_center,
|
||||||
first_level,
|
level,
|
||||||
vetting_status,
|
vetting_status,
|
||||||
vetting_website_url,
|
vetting_website_url,
|
||||||
vetting_procedure,
|
vetting_procedure,
|
||||||
@@ -1530,6 +1636,7 @@ WITH vend AS (
|
|||||||
v.id,
|
v.id,
|
||||||
v.tenant_id,
|
v.tenant_id,
|
||||||
v.organization_id,
|
v.organization_id,
|
||||||
|
v.parent_third_party_id,
|
||||||
v.common_third_party_id,
|
v.common_third_party_id,
|
||||||
v.name,
|
v.name,
|
||||||
v.description,
|
v.description,
|
||||||
@@ -1551,7 +1658,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.level,
|
||||||
v.vetting_status,
|
v.vetting_status,
|
||||||
v.vetting_website_url,
|
v.vetting_website_url,
|
||||||
v.vetting_procedure,
|
v.vetting_procedure,
|
||||||
@@ -1569,6 +1676,7 @@ WITH vend AS (
|
|||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
organization_id,
|
organization_id,
|
||||||
|
parent_third_party_id,
|
||||||
common_third_party_id,
|
common_third_party_id,
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
@@ -1590,7 +1698,7 @@ SELECT
|
|||||||
security_page_url,
|
security_page_url,
|
||||||
trust_page_url,
|
trust_page_url,
|
||||||
show_on_trust_center,
|
show_on_trust_center,
|
||||||
first_level,
|
level,
|
||||||
vetting_status,
|
vetting_status,
|
||||||
vetting_website_url,
|
vetting_website_url,
|
||||||
vetting_procedure,
|
vetting_procedure,
|
||||||
@@ -1634,6 +1742,7 @@ func (v *ThirdParty) LoadByOrganizationIDAndCommonThirdPartyID(
|
|||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
organization_id,
|
organization_id,
|
||||||
|
parent_third_party_id,
|
||||||
common_third_party_id,
|
common_third_party_id,
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
@@ -1655,7 +1764,7 @@ SELECT
|
|||||||
security_page_url,
|
security_page_url,
|
||||||
trust_page_url,
|
trust_page_url,
|
||||||
show_on_trust_center,
|
show_on_trust_center,
|
||||||
first_level,
|
level,
|
||||||
vetting_status,
|
vetting_status,
|
||||||
vetting_website_url,
|
vetting_website_url,
|
||||||
vetting_procedure,
|
vetting_procedure,
|
||||||
@@ -1754,6 +1863,7 @@ WITH tps AS (
|
|||||||
v.id,
|
v.id,
|
||||||
v.tenant_id,
|
v.tenant_id,
|
||||||
v.organization_id,
|
v.organization_id,
|
||||||
|
v.parent_third_party_id,
|
||||||
v.common_third_party_id,
|
v.common_third_party_id,
|
||||||
v.name,
|
v.name,
|
||||||
v.description,
|
v.description,
|
||||||
@@ -1775,7 +1885,7 @@ WITH tps 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.level,
|
||||||
v.vetting_status,
|
v.vetting_status,
|
||||||
v.vetting_website_url,
|
v.vetting_website_url,
|
||||||
v.vetting_procedure,
|
v.vetting_procedure,
|
||||||
@@ -1793,6 +1903,7 @@ WITH tps AS (
|
|||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
organization_id,
|
organization_id,
|
||||||
|
parent_third_party_id,
|
||||||
common_third_party_id,
|
common_third_party_id,
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
@@ -1814,7 +1925,7 @@ SELECT
|
|||||||
security_page_url,
|
security_page_url,
|
||||||
trust_page_url,
|
trust_page_url,
|
||||||
show_on_trust_center,
|
show_on_trust_center,
|
||||||
first_level,
|
level,
|
||||||
vetting_status,
|
vetting_status,
|
||||||
vetting_website_url,
|
vetting_website_url,
|
||||||
vetting_procedure,
|
vetting_procedure,
|
||||||
@@ -1847,3 +1958,254 @@ WHERE %s
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (v *ThirdParties) CountByParentThirdPartyID(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Querier,
|
||||||
|
scope Scoper,
|
||||||
|
parentThirdPartyID gid.GID,
|
||||||
|
) (int, error) {
|
||||||
|
q := `
|
||||||
|
SELECT
|
||||||
|
COUNT(id)
|
||||||
|
FROM
|
||||||
|
third_parties
|
||||||
|
WHERE
|
||||||
|
%s
|
||||||
|
AND parent_third_party_id = @parent_third_party_id
|
||||||
|
`
|
||||||
|
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) LoadAllAncestorsByThirdPartyID(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Querier,
|
||||||
|
scope Scoper,
|
||||||
|
thirdPartyID gid.GID,
|
||||||
|
) error {
|
||||||
|
q := `
|
||||||
|
WITH RECURSIVE ancestor_chain AS (
|
||||||
|
SELECT
|
||||||
|
tp.id,
|
||||||
|
tp.tenant_id,
|
||||||
|
tp.organization_id,
|
||||||
|
tp.parent_third_party_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.level,
|
||||||
|
tp.vetting_status,
|
||||||
|
tp.vetting_website_url,
|
||||||
|
tp.vetting_procedure,
|
||||||
|
tp.vetting_processing_started_at,
|
||||||
|
tp.vetting_error_message,
|
||||||
|
tp.created_at,
|
||||||
|
tp.updated_at,
|
||||||
|
1 AS depth
|
||||||
|
FROM third_parties tp
|
||||||
|
WHERE %s
|
||||||
|
AND tp.id = (
|
||||||
|
SELECT parent_third_party_id
|
||||||
|
FROM third_parties
|
||||||
|
WHERE id = @third_party_id
|
||||||
|
)
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
tp.id,
|
||||||
|
tp.tenant_id,
|
||||||
|
tp.organization_id,
|
||||||
|
tp.parent_third_party_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.level,
|
||||||
|
tp.vetting_status,
|
||||||
|
tp.vetting_website_url,
|
||||||
|
tp.vetting_procedure,
|
||||||
|
tp.vetting_processing_started_at,
|
||||||
|
tp.vetting_error_message,
|
||||||
|
tp.created_at,
|
||||||
|
tp.updated_at,
|
||||||
|
ac.depth + 1
|
||||||
|
FROM third_parties tp
|
||||||
|
JOIN ancestor_chain ac ON tp.id = ac.parent_third_party_id
|
||||||
|
WHERE ac.depth < @max_depth
|
||||||
|
AND tp.tenant_id = ac.tenant_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
organization_id,
|
||||||
|
parent_third_party_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,
|
||||||
|
level,
|
||||||
|
vetting_status,
|
||||||
|
vetting_website_url,
|
||||||
|
vetting_procedure,
|
||||||
|
vetting_processing_started_at,
|
||||||
|
vetting_error_message,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM ancestor_chain
|
||||||
|
ORDER BY depth DESC
|
||||||
|
`
|
||||||
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||||
|
|
||||||
|
args := pgx.StrictNamedArgs{
|
||||||
|
"third_party_id": thirdPartyID,
|
||||||
|
"max_depth": MaxThirdPartyLevel,
|
||||||
|
}
|
||||||
|
maps.Copy(args, scope.SQLArguments())
|
||||||
|
|
||||||
|
rows, err := conn.Query(ctx, q, args)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot query ancestors: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ancestors, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[ThirdParty])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot collect ancestors: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
*v = ancestors
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *ThirdParties) LoadByParentThirdPartyID(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Querier,
|
||||||
|
scope Scoper,
|
||||||
|
parentThirdPartyID gid.GID,
|
||||||
|
cursor *page.Cursor[ThirdPartyOrderField],
|
||||||
|
) error {
|
||||||
|
q := `
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
organization_id,
|
||||||
|
parent_third_party_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,
|
||||||
|
level,
|
||||||
|
vetting_status,
|
||||||
|
vetting_website_url,
|
||||||
|
vetting_procedure,
|
||||||
|
vetting_processing_started_at,
|
||||||
|
vetting_error_message,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM
|
||||||
|
third_parties
|
||||||
|
WHERE
|
||||||
|
%s
|
||||||
|
AND parent_third_party_id = @parent_third_party_id
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,15 +21,15 @@ import (
|
|||||||
type (
|
type (
|
||||||
ThirdPartyFilter struct {
|
ThirdPartyFilter struct {
|
||||||
showOnTrustCenter *bool
|
showOnTrustCenter *bool
|
||||||
firstLevel *bool
|
level *int
|
||||||
query *string
|
query *string
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewThirdPartyFilter(showOnTrustCenter *bool, firstLevel *bool, query *string) *ThirdPartyFilter {
|
func NewThirdPartyFilter(showOnTrustCenter *bool, level *int, query *string) *ThirdPartyFilter {
|
||||||
return &ThirdPartyFilter{
|
return &ThirdPartyFilter{
|
||||||
showOnTrustCenter: showOnTrustCenter,
|
showOnTrustCenter: showOnTrustCenter,
|
||||||
firstLevel: firstLevel,
|
level: level,
|
||||||
query: query,
|
query: query,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,6 +38,7 @@ func (f *ThirdPartyFilter) SQLArguments() pgx.StrictNamedArgs {
|
|||||||
args := pgx.StrictNamedArgs{
|
args := pgx.StrictNamedArgs{
|
||||||
"show_on_trust_center": nil,
|
"show_on_trust_center": nil,
|
||||||
"filter_query": nil,
|
"filter_query": nil,
|
||||||
|
"level": nil,
|
||||||
}
|
}
|
||||||
|
|
||||||
if f.showOnTrustCenter != nil {
|
if f.showOnTrustCenter != nil {
|
||||||
@@ -48,10 +49,8 @@ func (f *ThirdPartyFilter) SQLArguments() pgx.StrictNamedArgs {
|
|||||||
args["filter_query"] = *f.query
|
args["filter_query"] = *f.query
|
||||||
}
|
}
|
||||||
|
|
||||||
if f.firstLevel != nil {
|
if f.level != nil {
|
||||||
args["first_level"] = *f.firstLevel
|
args["level"] = *f.level
|
||||||
} else {
|
|
||||||
args["first_level"] = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return args
|
return args
|
||||||
@@ -66,8 +65,8 @@ func (f *ThirdPartyFilter) SQLFragment() string {
|
|||||||
ELSE TRUE
|
ELSE TRUE
|
||||||
END
|
END
|
||||||
AND CASE
|
AND CASE
|
||||||
WHEN @first_level::boolean IS NOT NULL THEN
|
WHEN @level::integer IS NOT NULL THEN
|
||||||
first_level = @first_level::boolean
|
level = @level::integer
|
||||||
ELSE TRUE
|
ELSE TRUE
|
||||||
END
|
END
|
||||||
AND CASE
|
AND CASE
|
||||||
|
|||||||
@@ -1,240 +0,0 @@
|
|||||||
// Copyright (c) 2025-2026 Probo Inc <hello@probo.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"`
|
|
||||||
Purpose *string `db:"purpose"`
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
purpose
|
|
||||||
) VALUES (
|
|
||||||
@parent_third_party_id,
|
|
||||||
@child_third_party_id,
|
|
||||||
@tenant_id,
|
|
||||||
@created_at,
|
|
||||||
@purpose
|
|
||||||
)
|
|
||||||
ON CONFLICT (parent_third_party_id, child_third_party_id) DO UPDATE SET
|
|
||||||
purpose = COALESCE(EXCLUDED.purpose, third_party_third_parties.purpose)
|
|
||||||
`
|
|
||||||
|
|
||||||
args := pgx.StrictNamedArgs{
|
|
||||||
"parent_third_party_id": r.ParentThirdPartyID,
|
|
||||||
"child_third_party_id": r.ChildThirdPartyID,
|
|
||||||
"tenant_id": scope.GetTenantID(),
|
|
||||||
"created_at": r.CreatedAt,
|
|
||||||
"purpose": r.Purpose,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, 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.vetting_status,
|
|
||||||
tp.vetting_website_url,
|
|
||||||
tp.vetting_procedure,
|
|
||||||
tp.vetting_processing_started_at,
|
|
||||||
tp.vetting_error_message,
|
|
||||||
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,
|
|
||||||
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,
|
|
||||||
vetting_status,
|
|
||||||
vetting_website_url,
|
|
||||||
vetting_procedure,
|
|
||||||
vetting_processing_started_at,
|
|
||||||
vetting_error_message,
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -32,6 +32,7 @@ func (v *ThirdParty) LoadNextPendingVettingForUpdateSkipLocked(
|
|||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
organization_id,
|
organization_id,
|
||||||
|
parent_third_party_id,
|
||||||
common_third_party_id,
|
common_third_party_id,
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
@@ -53,7 +54,7 @@ SELECT
|
|||||||
security_page_url,
|
security_page_url,
|
||||||
trust_page_url,
|
trust_page_url,
|
||||||
show_on_trust_center,
|
show_on_trust_center,
|
||||||
first_level,
|
level,
|
||||||
vetting_status,
|
vetting_status,
|
||||||
vetting_website_url,
|
vetting_website_url,
|
||||||
vetting_procedure,
|
vetting_procedure,
|
||||||
|
|||||||
@@ -767,6 +767,7 @@ func (s *OrganizationService) CreateOrganization(
|
|||||||
TermsOfServiceURL: &proboThirdParty.TermsOfServiceURL,
|
TermsOfServiceURL: &proboThirdParty.TermsOfServiceURL,
|
||||||
SubprocessorsListURL: &proboThirdParty.SubprocessorsListURL,
|
SubprocessorsListURL: &proboThirdParty.SubprocessorsListURL,
|
||||||
ShowOnTrustCenter: false,
|
ShowOnTrustCenter: false,
|
||||||
|
Level: 1,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,6 @@ const (
|
|||||||
|
|
||||||
// ThirdPartyRelation actions
|
// ThirdPartyRelation actions
|
||||||
ActionThirdPartyRelationCreate = "core:thirdParty-relation:create"
|
ActionThirdPartyRelationCreate = "core:thirdParty-relation:create"
|
||||||
ActionThirdPartyRelationDelete = "core:thirdParty-relation:delete"
|
|
||||||
ActionThirdPartyRelationList = "core:thirdParty-relation:list"
|
ActionThirdPartyRelationList = "core:thirdParty-relation:list"
|
||||||
|
|
||||||
// ThirdPartyContact actions
|
// ThirdPartyContact actions
|
||||||
|
|||||||
@@ -2357,8 +2357,16 @@ func (s *GeneratedDocumentService) buildThirdPartyListDocumentData(
|
|||||||
conn pg.Querier,
|
conn pg.Querier,
|
||||||
organization *coredata.Organization,
|
organization *coredata.Organization,
|
||||||
) (docgen.ThirdPartyListData, error) {
|
) (docgen.ThirdPartyListData, error) {
|
||||||
|
firstLevel := 1
|
||||||
|
|
||||||
var thirdParties coredata.ThirdParties
|
var thirdParties coredata.ThirdParties
|
||||||
if err := thirdParties.LoadAllByOrganizationID(ctx, conn, scope, organization.ID); err != nil {
|
if err := thirdParties.LoadAllByOrganizationID(
|
||||||
|
ctx,
|
||||||
|
conn,
|
||||||
|
scope,
|
||||||
|
organization.ID,
|
||||||
|
coredata.NewThirdPartyFilter(nil, &firstLevel, nil),
|
||||||
|
); err != nil {
|
||||||
return docgen.ThirdPartyListData{}, fmt.Errorf("cannot load thirdParties: %w", err)
|
return docgen.ThirdPartyListData{}, fmt.Errorf("cannot load thirdParties: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ type (
|
|||||||
StatusPageURL *string
|
StatusPageURL *string
|
||||||
BusinessOwnerID *gid.GID
|
BusinessOwnerID *gid.GID
|
||||||
SecurityOwnerID *gid.GID
|
SecurityOwnerID *gid.GID
|
||||||
FirstLevel *bool
|
ParentThirdPartyID *gid.GID
|
||||||
}
|
}
|
||||||
|
|
||||||
UpdateThirdPartyRequest struct {
|
UpdateThirdPartyRequest struct {
|
||||||
@@ -79,7 +79,6 @@ type (
|
|||||||
BusinessOwnerID **gid.GID
|
BusinessOwnerID **gid.GID
|
||||||
SecurityOwnerID **gid.GID
|
SecurityOwnerID **gid.GID
|
||||||
ShowOnTrustCenter *bool
|
ShowOnTrustCenter *bool
|
||||||
FirstLevel *bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CreateThirdPartyRiskAssessmentRequest struct {
|
CreateThirdPartyRiskAssessmentRequest struct {
|
||||||
@@ -398,10 +397,6 @@ 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
|
||||||
}
|
}
|
||||||
@@ -589,11 +584,7 @@ func (s ThirdPartyService) Create(
|
|||||||
StatusPageURL: req.StatusPageURL,
|
StatusPageURL: req.StatusPageURL,
|
||||||
TermsOfServiceURL: req.TermsOfServiceURL,
|
TermsOfServiceURL: req.TermsOfServiceURL,
|
||||||
ShowOnTrustCenter: false,
|
ShowOnTrustCenter: false,
|
||||||
FirstLevel: true,
|
Level: 1,
|
||||||
}
|
|
||||||
|
|
||||||
if req.FirstLevel != nil {
|
|
||||||
thirdParty.FirstLevel = *req.FirstLevel
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err := s.svc.pg.WithTx(
|
err := s.svc.pg.WithTx(
|
||||||
@@ -606,6 +597,29 @@ func (s ThirdPartyService) Create(
|
|||||||
|
|
||||||
thirdParty.OrganizationID = organization.ID
|
thirdParty.OrganizationID = organization.ID
|
||||||
|
|
||||||
|
if req.ParentThirdPartyID != nil {
|
||||||
|
parent := &coredata.ThirdParty{}
|
||||||
|
if err := parent.LoadByID(ctx, conn, scope, *req.ParentThirdPartyID); err != nil {
|
||||||
|
return fmt.Errorf("cannot load parent third party: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if parent.OrganizationID != organization.ID {
|
||||||
|
return fmt.Errorf("parent third party belongs to a different organization: %w", coredata.ErrResourceNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
thirdParty.ParentThirdPartyID = &parent.ID
|
||||||
|
// The level always follows the parent chain; ignore any
|
||||||
|
// client-supplied level so it cannot desync from the hierarchy.
|
||||||
|
thirdParty.Level = parent.Level + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
levelValidator := validator.New()
|
||||||
|
levelValidator.Check(thirdParty.Level, "level", validator.Max(coredata.MaxThirdPartyLevel))
|
||||||
|
|
||||||
|
if err := levelValidator.Error(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if req.BusinessOwnerID != nil {
|
if req.BusinessOwnerID != nil {
|
||||||
businessOwner := &coredata.MembershipProfile{}
|
businessOwner := &coredata.MembershipProfile{}
|
||||||
if err := businessOwner.LoadByID(ctx, conn, scope, *req.BusinessOwnerID); err != nil {
|
if err := businessOwner.LoadByID(ctx, conn, scope, *req.BusinessOwnerID); err != nil {
|
||||||
@@ -848,69 +862,24 @@ func (s ThirdPartyService) GetByRiskAssessmentID(
|
|||||||
return thirdParty, nil
|
return thirdParty, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s ThirdPartyService) CreateThirdPartyMapping(
|
func (s ThirdPartyService) GetAncestors(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
scope coredata.Scoper,
|
scope coredata.Scoper,
|
||||||
parentThirdPartyID gid.GID,
|
thirdPartyID gid.GID,
|
||||||
childThirdPartyID gid.GID,
|
) (coredata.ThirdParties, error) {
|
||||||
) (*coredata.ThirdParty, error) {
|
var ancestors coredata.ThirdParties
|
||||||
childThirdParty := &coredata.ThirdParty{}
|
|
||||||
|
|
||||||
err := s.svc.pg.WithTx(
|
err := s.svc.pg.WithConn(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Tx) error {
|
func(ctx context.Context, conn pg.Querier) error {
|
||||||
parentThirdParty := &coredata.ThirdParty{}
|
return ancestors.LoadAllAncestorsByThirdPartyID(ctx, conn, scope, thirdPartyID)
|
||||||
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return childThirdParty, nil
|
return ancestors, 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(
|
func (s ThirdPartyService) CountForParentThirdPartyID(
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ input ThirdPartyOrder
|
|||||||
}
|
}
|
||||||
|
|
||||||
input ThirdPartyFilter {
|
input ThirdPartyFilter {
|
||||||
firstLevel: Boolean
|
level: Int
|
||||||
query: String
|
query: String
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,7 +303,10 @@ type ThirdParty implements Node {
|
|||||||
legalName: String
|
legalName: String
|
||||||
websiteUrl: String
|
websiteUrl: String
|
||||||
showOnTrustCenter: Boolean!
|
showOnTrustCenter: Boolean!
|
||||||
firstLevel: Boolean!
|
level: Int!
|
||||||
|
|
||||||
|
parentThirdParty: ThirdParty @goField(forceResolver: true)
|
||||||
|
ancestors: [ThirdParty!]! @goField(forceResolver: true)
|
||||||
|
|
||||||
childThirdParties(
|
childThirdParties(
|
||||||
first: Int
|
first: Int
|
||||||
@@ -506,12 +509,6 @@ extend type Mutation {
|
|||||||
publishThirdPartyList(
|
publishThirdPartyList(
|
||||||
input: PublishThirdPartyListInput!
|
input: PublishThirdPartyListInput!
|
||||||
): PublishThirdPartyListPayload!
|
): PublishThirdPartyListPayload!
|
||||||
createThirdPartyThirdPartyMapping(
|
|
||||||
input: CreateThirdPartyThirdPartyMappingInput!
|
|
||||||
): CreateThirdPartyThirdPartyMappingPayload!
|
|
||||||
deleteThirdPartyThirdPartyMapping(
|
|
||||||
input: DeleteThirdPartyThirdPartyMappingInput!
|
|
||||||
): DeleteThirdPartyThirdPartyMappingPayload!
|
|
||||||
}
|
}
|
||||||
|
|
||||||
input PublishThirdPartyListInput {
|
input PublishThirdPartyListInput {
|
||||||
@@ -546,7 +543,7 @@ input CreateThirdPartyInput {
|
|||||||
termsOfServiceUrl: String
|
termsOfServiceUrl: String
|
||||||
businessOwnerId: ID
|
businessOwnerId: ID
|
||||||
securityOwnerId: ID
|
securityOwnerId: ID
|
||||||
firstLevel: Boolean
|
parentThirdPartyId: ID
|
||||||
}
|
}
|
||||||
|
|
||||||
input UpdateThirdPartyInput {
|
input UpdateThirdPartyInput {
|
||||||
@@ -571,7 +568,6 @@ 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 {
|
||||||
@@ -755,21 +751,3 @@ type CreateThirdPartyRiskAssessmentPayload {
|
|||||||
type VetThirdPartyPayload {
|
type VetThirdPartyPayload {
|
||||||
thirdParty: ThirdParty!
|
thirdParty: ThirdParty!
|
||||||
}
|
}
|
||||||
|
|
||||||
input CreateThirdPartyThirdPartyMappingInput {
|
|
||||||
parentThirdPartyId: ID!
|
|
||||||
childThirdPartyId: ID!
|
|
||||||
}
|
|
||||||
|
|
||||||
type CreateThirdPartyThirdPartyMappingPayload {
|
|
||||||
thirdPartyEdge: ThirdPartyEdge!
|
|
||||||
}
|
|
||||||
|
|
||||||
input DeleteThirdPartyThirdPartyMappingInput {
|
|
||||||
parentThirdPartyId: ID!
|
|
||||||
childThirdPartyId: ID!
|
|
||||||
}
|
|
||||||
|
|
||||||
type DeleteThirdPartyThirdPartyMappingPayload {
|
|
||||||
removedThirdPartyId: ID!
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1291,15 +1291,15 @@ 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)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
firstLevel *bool
|
level *int
|
||||||
query *string
|
query *string
|
||||||
)
|
)
|
||||||
if filter != nil {
|
if filter != nil {
|
||||||
firstLevel = filter.FirstLevel
|
level = filter.Level
|
||||||
query = filter.Query
|
query = filter.Query
|
||||||
}
|
}
|
||||||
|
|
||||||
thirdPartyFilter := coredata.NewThirdPartyFilter(nil, firstLevel, query)
|
thirdPartyFilter := coredata.NewThirdPartyFilter(nil, level, query)
|
||||||
|
|
||||||
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 {
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ func (r *mutationResolver) CreateThirdParty(ctx context.Context, input types.Cre
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if input.ParentThirdPartyID != nil {
|
||||||
|
if _, err := r.authorize(ctx, *input.ParentThirdPartyID, probo.ActionThirdPartyRelationCreate); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
thirdParty, err := r.probo.ThirdParties.Create(
|
thirdParty, err := r.probo.ThirdParties.Create(
|
||||||
ctx, scope,
|
ctx, scope,
|
||||||
probo.CreateThirdPartyRequest{
|
probo.CreateThirdPartyRequest{
|
||||||
@@ -56,7 +62,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,
|
ParentThirdPartyID: input.ParentThirdPartyID,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -108,7 +114,6 @@ 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,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -603,46 +608,6 @@ 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 {
|
||||||
@@ -913,6 +878,53 @@ func (r *thirdPartyResolver) SecurityOwner(ctx context.Context, obj *types.Third
|
|||||||
return types.NewProfile(securityOwner), nil
|
return types.NewProfile(securityOwner), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ParentThirdParty is the resolver for the parentThirdParty field.
|
||||||
|
func (r *thirdPartyResolver) ParentThirdParty(ctx context.Context, obj *types.ThirdParty) (*types.ThirdParty, error) {
|
||||||
|
if obj.ParentThirdParty == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := r.authorize(ctx, obj.ID, probo.ActionThirdPartyGet); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
loaders := dataloader.FromContext(ctx)
|
||||||
|
|
||||||
|
parent, err := loaders.ThirdParty.Load(ctx, obj.ParentThirdParty.ID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, coredata.ErrResourceNotFound) || errors.Is(err, dataloadgen.ErrNotFound) {
|
||||||
|
return nil, gqlutils.NotFound(ctx, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot load parent third party", log.Error(err))
|
||||||
|
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return types.NewThirdParty(parent), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ancestors is the resolver for the ancestors field.
|
||||||
|
func (r *thirdPartyResolver) Ancestors(ctx context.Context, obj *types.ThirdParty) ([]*types.ThirdParty, error) {
|
||||||
|
scope, err := r.authorize(ctx, obj.ID, probo.ActionThirdPartyGet)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ancestors, err := r.probo.ThirdParties.GetAncestors(ctx, scope, obj.ID)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot load ancestors", log.Error(err))
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]*types.ThirdParty, len(ancestors))
|
||||||
|
for i, a := range ancestors {
|
||||||
|
result[i] = types.NewThirdParty(a)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ChildThirdParties is the resolver for the childThirdParties field.
|
// 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) {
|
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)
|
scope, err := r.authorize(ctx, obj.ID, probo.ActionThirdPartyRelationList)
|
||||||
|
|||||||
@@ -86,7 +86,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,
|
Level: v.Level,
|
||||||
Countries: v.Countries,
|
Countries: v.Countries,
|
||||||
UpdatedAt: v.UpdatedAt,
|
UpdatedAt: v.UpdatedAt,
|
||||||
CreatedAt: v.CreatedAt,
|
CreatedAt: v.CreatedAt,
|
||||||
@@ -104,5 +104,11 @@ func NewThirdParty(v *coredata.ThirdParty) *ThirdParty {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if v.ParentThirdPartyID != nil {
|
||||||
|
object.ParentThirdParty = &ThirdParty{
|
||||||
|
ID: *v.ParentThirdPartyID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return object
|
return object
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,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, input.FirstLevel, nil)
|
thirdPartyFilter := coredata.NewThirdPartyFilter(nil, input.Level, nil)
|
||||||
|
|
||||||
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 {
|
||||||
@@ -6215,32 +6215,6 @@ 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) {
|
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)
|
scope, err := r.Authorize(ctx, input.ParentThirdPartyID, probo.ActionThirdPartyRelationList)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -641,9 +641,9 @@ components:
|
|||||||
organization_id:
|
organization_id:
|
||||||
$ref: "#/components/schemas/GID"
|
$ref: "#/components/schemas/GID"
|
||||||
description: Organization ID
|
description: Organization ID
|
||||||
first_level:
|
level:
|
||||||
type: boolean
|
type: integer
|
||||||
description: Filter by first-level status
|
description: Filter by level
|
||||||
order_by:
|
order_by:
|
||||||
$ref: "#/components/schemas/ThirdPartyOrderBy"
|
$ref: "#/components/schemas/ThirdPartyOrderBy"
|
||||||
description: ThirdParty order by
|
description: ThirdParty order by
|
||||||
@@ -667,38 +667,6 @@ 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:
|
ListChildThirdPartiesInput:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
@@ -737,7 +705,7 @@ components:
|
|||||||
- name
|
- name
|
||||||
- organization_id
|
- organization_id
|
||||||
- category
|
- category
|
||||||
- first_level
|
- level
|
||||||
- created_at
|
- created_at
|
||||||
- updated_at
|
- updated_at
|
||||||
properties:
|
properties:
|
||||||
@@ -861,9 +829,9 @@ components:
|
|||||||
- string
|
- string
|
||||||
- "null"
|
- "null"
|
||||||
description: Trust page URL
|
description: Trust page URL
|
||||||
first_level:
|
level:
|
||||||
type: boolean
|
type: integer
|
||||||
description: Whether this is a first-level third party
|
description: Level of this third party (1 = direct, 2+ = indirect)
|
||||||
created_at:
|
created_at:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
@@ -12055,22 +12023,6 @@ 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
|
- name: listChildThirdParties
|
||||||
description: List child third parties linked to a parent third party
|
description: List child third parties linked to a parent third party
|
||||||
hints:
|
hints:
|
||||||
|
|||||||
@@ -86,7 +86,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,
|
Level: v.Level,
|
||||||
CreatedAt: v.CreatedAt,
|
CreatedAt: v.CreatedAt,
|
||||||
UpdatedAt: v.UpdatedAt,
|
UpdatedAt: v.UpdatedAt,
|
||||||
}
|
}
|
||||||
|
|||||||
4
pkg/thirdparty/match.go
vendored
4
pkg/thirdparty/match.go
vendored
@@ -220,7 +220,7 @@ func LinkToCommon(
|
|||||||
// CreateFromCommon inserts a new org ThirdParty seeded from the catalog
|
// CreateFromCommon inserts a new org ThirdParty seeded from the catalog
|
||||||
// row (name, category, addresses, URLs, certifications, …). The new row
|
// row (name, category, addresses, URLs, certifications, …). The new row
|
||||||
// has common_third_party_id pointed at commonParty, an empty Countries
|
// has common_third_party_id pointed at commonParty, an empty Countries
|
||||||
// list, ShowOnTrustCenter false, and FirstLevel true — the caller has
|
// list, ShowOnTrustCenter false, and Level 1 — the caller has
|
||||||
// already confirmed the vendor is actively present on the
|
// already confirmed the vendor is actively present on the
|
||||||
// organization's cookie banner, which makes it a first-level third
|
// organization's cookie banner, which makes it a first-level third
|
||||||
// party by definition.
|
// party by definition.
|
||||||
@@ -259,7 +259,7 @@ func CreateFromCommon(
|
|||||||
SecurityPageURL: commonParty.SecurityPageURL,
|
SecurityPageURL: commonParty.SecurityPageURL,
|
||||||
TrustPageURL: commonParty.TrustPageURL,
|
TrustPageURL: commonParty.TrustPageURL,
|
||||||
ShowOnTrustCenter: false,
|
ShowOnTrustCenter: false,
|
||||||
FirstLevel: true,
|
Level: 1,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -26,6 +27,11 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/gid"
|
"go.probo.inc/probo/pkg/gid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// nameSuffixPattern matches a trailing " (path)" suffix on a stored third party
|
||||||
|
// name. It mirrors the console UI regex so backend and frontend agree on how a
|
||||||
|
// hierarchy-qualified name is split back into its bare base.
|
||||||
|
var nameSuffixPattern = regexp.MustCompile(`\s*\([^)]*\)\s*$`)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
vettingRiskAssessmentValidity = 365 * 24 * time.Hour
|
vettingRiskAssessmentValidity = 365 * 24 * time.Hour
|
||||||
maxVettingNotesGaps = 5
|
maxVettingNotesGaps = 5
|
||||||
@@ -51,13 +57,28 @@ func PersistAssessmentResult(
|
|||||||
return fmt.Errorf("cannot load third party: %w", err)
|
return fmt.Errorf("cannot load third party: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
applySaveParams(thirdParty, pc.WebsiteURL, saveParamsFromInfo(result.Info))
|
// Sub third parties store hierarchy-qualified names ("aws (Probo)").
|
||||||
|
// Load the ancestor chain so the vetted third party and any
|
||||||
|
// discovered sub-processors are named consistently with the console.
|
||||||
|
ancestorBaseNames, err := loadAncestorBaseNames(ctx, conn, scope, thirdParty.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
applySaveParams(thirdParty, pc.WebsiteURL, saveParamsFromInfo(result.Info), ancestorBaseNames)
|
||||||
thirdParty.UpdatedAt = time.Now()
|
thirdParty.UpdatedAt = time.Now()
|
||||||
|
|
||||||
if err := thirdParty.Update(ctx, conn, scope); err != nil {
|
if err := thirdParty.Update(ctx, conn, scope); err != nil {
|
||||||
return fmt.Errorf("cannot update third party: %w", err)
|
return fmt.Errorf("cannot update third party: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The suffix for children of this third party is the ancestor path
|
||||||
|
// plus the third party itself (computed after applySaveParams so a
|
||||||
|
// freshly canonicalized name is reflected).
|
||||||
|
childNamePath := make([]string, 0, len(ancestorBaseNames)+1)
|
||||||
|
childNamePath = append(childNamePath, ancestorBaseNames...)
|
||||||
|
childNamePath = append(childNamePath, baseThirdPartyName(thirdParty.Name))
|
||||||
|
|
||||||
for _, sub := range result.Info.Subprocessors {
|
for _, sub := range result.Info.Subprocessors {
|
||||||
if sub.Name == "" {
|
if sub.Name == "" {
|
||||||
continue
|
continue
|
||||||
@@ -68,6 +89,8 @@ func PersistAssessmentResult(
|
|||||||
conn,
|
conn,
|
||||||
scope,
|
scope,
|
||||||
pc,
|
pc,
|
||||||
|
thirdParty.Level,
|
||||||
|
childNamePath,
|
||||||
linkSubThirdPartyParams{
|
linkSubThirdPartyParams{
|
||||||
Name: sub.Name,
|
Name: sub.Name,
|
||||||
Country: sub.Country,
|
Country: sub.Country,
|
||||||
@@ -296,9 +319,12 @@ func applySaveParams(
|
|||||||
thirdParty *coredata.ThirdParty,
|
thirdParty *coredata.ThirdParty,
|
||||||
websiteURL string,
|
websiteURL string,
|
||||||
p saveThirdPartyInfoParams,
|
p saveThirdPartyInfoParams,
|
||||||
|
nameSuffixPath []string,
|
||||||
) {
|
) {
|
||||||
if p.Name != "" {
|
if p.Name != "" {
|
||||||
thirdParty.Name = p.Name
|
// Keep the name hierarchy-qualified for sub third parties; a top-level
|
||||||
|
// third party (empty suffix path) keeps the bare name.
|
||||||
|
thirdParty.Name = qualifyThirdPartyName(p.Name, nameSuffixPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
thirdParty.WebsiteURL = &websiteURL
|
thirdParty.WebsiteURL = &websiteURL
|
||||||
@@ -371,27 +397,55 @@ func linkSubThirdParty(
|
|||||||
conn pg.Tx,
|
conn pg.Tx,
|
||||||
scope coredata.Scoper,
|
scope coredata.Scoper,
|
||||||
pc *PersistenceContext,
|
pc *PersistenceContext,
|
||||||
|
parentLevel int,
|
||||||
|
parentNamePath []string,
|
||||||
p linkSubThirdPartyParams,
|
p linkSubThirdPartyParams,
|
||||||
) error {
|
) error {
|
||||||
if p.Name == "" {
|
if p.Name == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-discovered subprocessors must not nest beyond the maximum level.
|
||||||
|
// Stop descending here rather than creating an invalid child.
|
||||||
|
if parentLevel+1 > coredata.MaxThirdPartyLevel {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store and match the child under its hierarchy-qualified name so vetting
|
||||||
|
// agrees with names created from the console (e.g. "aws (Probo)").
|
||||||
|
qualifiedName := qualifyThirdPartyName(p.Name, parentNamePath)
|
||||||
|
|
||||||
child := &coredata.ThirdParty{}
|
child := &coredata.ThirdParty{}
|
||||||
|
|
||||||
err := child.LoadByNameAndOrganizationID(ctx, conn, scope, p.Name, pc.OrganizationID)
|
// Sub-third-parties are scoped per parent, so a child is matched by name
|
||||||
if err != nil {
|
// within this parent only — a same-named third party under a different
|
||||||
if !errors.Is(err, coredata.ErrResourceNotFound) {
|
// parent is an independent entity and must be created here too.
|
||||||
|
err := child.LoadByNameAndParentThirdPartyID(ctx, conn, scope, qualifiedName, pc.ThirdPartyID)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
if countries := parseOptionalCountryCodes(p.Country); len(countries) > 0 && len(child.Countries) == 0 {
|
||||||
|
child.Countries = countries
|
||||||
|
child.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
if err := child.Update(ctx, conn, scope); err != nil {
|
||||||
|
return fmt.Errorf("cannot update child third party %q countries: %w", p.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
case !errors.Is(err, coredata.ErrResourceNotFound):
|
||||||
return fmt.Errorf("cannot find child third party %q: %w", p.Name, err)
|
return fmt.Errorf("cannot find child third party %q: %w", p.Name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
parentID := pc.ThirdPartyID
|
||||||
child = &coredata.ThirdParty{
|
child = &coredata.ThirdParty{
|
||||||
ID: gid.New(scope.GetTenantID(), coredata.ThirdPartyEntityType),
|
ID: gid.New(scope.GetTenantID(), coredata.ThirdPartyEntityType),
|
||||||
OrganizationID: pc.OrganizationID,
|
OrganizationID: pc.OrganizationID,
|
||||||
Name: p.Name,
|
ParentThirdPartyID: &parentID,
|
||||||
|
Name: qualifiedName,
|
||||||
Category: coredata.ThirdPartyCategoryOther,
|
Category: coredata.ThirdPartyCategoryOther,
|
||||||
FirstLevel: false,
|
Level: parentLevel + 1,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
@@ -417,32 +471,48 @@ func linkSubThirdParty(
|
|||||||
if err := child.Insert(ctx, conn, scope); err != nil {
|
if err := child.Insert(ctx, conn, scope); err != nil {
|
||||||
return fmt.Errorf("cannot create child third party %q: %w", p.Name, err)
|
return fmt.Errorf("cannot create child third party %q: %w", p.Name, err)
|
||||||
}
|
}
|
||||||
} else if countries := parseOptionalCountryCodes(p.Country); len(countries) > 0 && len(child.Countries) == 0 {
|
|
||||||
child.Countries = countries
|
|
||||||
child.UpdatedAt = time.Now()
|
|
||||||
|
|
||||||
if err := child.Update(ctx, conn, scope); err != nil {
|
|
||||||
return fmt.Errorf("cannot update child third party %q countries: %w", p.Name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if child.ID == pc.ThirdPartyID {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
relation := &coredata.ThirdPartyThirdParty{
|
|
||||||
ParentThirdPartyID: pc.ThirdPartyID,
|
|
||||||
ChildThirdPartyID: child.ID,
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
if p.Purpose != "" {
|
|
||||||
relation.Purpose = &p.Purpose
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := relation.Insert(ctx, conn, scope); err != nil {
|
|
||||||
return fmt.Errorf("cannot insert third party relation: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// baseThirdPartyName strips a trailing " (path)" suffix so a stored,
|
||||||
|
// hierarchy-qualified name is reduced to its bare base, mirroring the console
|
||||||
|
// UI convention.
|
||||||
|
func baseThirdPartyName(name string) string {
|
||||||
|
return strings.TrimSpace(nameSuffixPattern.ReplaceAllString(name, ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
// qualifyThirdPartyName appends the parent path as a parenthesized suffix, e.g.
|
||||||
|
// ("aws", ["Probo", "Acme"]) → "aws (Probo/Acme)". An empty path leaves the
|
||||||
|
// name unchanged, so top-level third parties are never suffixed.
|
||||||
|
func qualifyThirdPartyName(base string, path []string) string {
|
||||||
|
if len(path) == 0 {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s (%s)", base, strings.Join(path, "/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadAncestorBaseNames returns the base names of a third party's ancestors,
|
||||||
|
// ordered root → immediate parent. It is the suffix path used to qualify the
|
||||||
|
// third party's own name; append the third party's own base name to it to get
|
||||||
|
// the suffix path for its children.
|
||||||
|
func loadAncestorBaseNames(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Querier,
|
||||||
|
scope coredata.Scoper,
|
||||||
|
thirdPartyID gid.GID,
|
||||||
|
) ([]string, error) {
|
||||||
|
var ancestors coredata.ThirdParties
|
||||||
|
|
||||||
|
if err := ancestors.LoadAllAncestorsByThirdPartyID(ctx, conn, scope, thirdPartyID); err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot load ancestors: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
names := make([]string, len(ancestors))
|
||||||
|
for i, ancestor := range ancestors {
|
||||||
|
names[i] = baseThirdPartyName(ancestor.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -89,9 +89,14 @@ func SaveThirdPartyInfoTool(pc *PersistenceContext) agent.Tool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ancestorBaseNames, err := loadAncestorBaseNames(ctx, conn, scope, thirdParty.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
applySaveParams(thirdParty, pc.WebsiteURL, saveThirdPartyInfoParams{
|
applySaveParams(thirdParty, pc.WebsiteURL, saveThirdPartyInfoParams{
|
||||||
saveThirdPartyInfoToolParams: p,
|
saveThirdPartyInfoToolParams: p,
|
||||||
})
|
}, ancestorBaseNames)
|
||||||
thirdParty.UpdatedAt = time.Now()
|
thirdParty.UpdatedAt = time.Now()
|
||||||
|
|
||||||
if err := thirdParty.Update(ctx, conn, scope); err != nil {
|
if err := thirdParty.Update(ctx, conn, scope); err != nil {
|
||||||
@@ -124,7 +129,20 @@ func LinkSubThirdPartyTool(pc *PersistenceContext) agent.Tool {
|
|||||||
err := pc.PG.WithTx(
|
err := pc.PG.WithTx(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Tx) error {
|
func(ctx context.Context, conn pg.Tx) error {
|
||||||
return linkSubThirdParty(ctx, conn, scope, pc, p)
|
parent := &coredata.ThirdParty{}
|
||||||
|
if err := parent.LoadByID(ctx, conn, scope, pc.ThirdPartyID); err != nil {
|
||||||
|
return fmt.Errorf("cannot load parent third party: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ancestorBaseNames, err := loadAncestorBaseNames(ctx, conn, scope, pc.ThirdPartyID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Child suffix path is the parent's ancestors plus the parent itself.
|
||||||
|
childNamePath := append(ancestorBaseNames, baseThirdPartyName(parent.Name))
|
||||||
|
|
||||||
|
return linkSubThirdParty(ctx, conn, scope, pc, parent.Level, childNamePath, p)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user