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;
|
||||
name: string;
|
||||
websiteUrl: string | null | undefined;
|
||||
firstLevel?: boolean;
|
||||
level?: number;
|
||||
};
|
||||
|
||||
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 { ThirdPartyGraphDeleteMutation } from "#/__generated__/core/ThirdPartyGraphDeleteMutation.graphql";
|
||||
import type { ThirdPartyGraphSelectQuery } from "#/__generated__/core/ThirdPartyGraphSelectQuery.graphql";
|
||||
|
||||
import { useMutationWithToasts } from "../useMutationWithToasts";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
|
||||
/* eslint-disable relay/unused-fields, relay/must-colocate-fragment-spreads */
|
||||
|
||||
@@ -51,14 +50,10 @@ const createThirdPartyMutation = graphql`
|
||||
|
||||
export function useCreateThirdPartyMutation() {
|
||||
const { __ } = useTranslate();
|
||||
|
||||
return useMutationWithToasts<ThirdPartyGraphCreateMutation>(
|
||||
createThirdPartyMutation,
|
||||
{
|
||||
successMessage: __("Third party created successfully."),
|
||||
errorMessage: __("Failed to create third party"),
|
||||
},
|
||||
);
|
||||
return useMutationWithToasts<ThirdPartyGraphCreateMutation>(createThirdPartyMutation, {
|
||||
successMessage: __("Third party created successfully"),
|
||||
errorMessage: __("Failed to create third party"),
|
||||
});
|
||||
}
|
||||
|
||||
const deleteThirdPartyMutation = graphql`
|
||||
@@ -138,7 +133,7 @@ export const paginatedThirdPartiesFragment = graphql`
|
||||
after: { type: "CursorKey", defaultValue: null }
|
||||
before: { type: "CursorKey", defaultValue: null }
|
||||
last: { type: "Int", defaultValue: null }
|
||||
filter: { type: "ThirdPartyFilter", defaultValue: { firstLevel: true } }
|
||||
filter: { type: "ThirdPartyFilter", defaultValue: { level: 1 } }
|
||||
) {
|
||||
thirdParties(
|
||||
first: $first
|
||||
@@ -154,6 +149,7 @@ export const paginatedThirdPartiesFragment = graphql`
|
||||
id
|
||||
name
|
||||
websiteUrl
|
||||
level
|
||||
updatedAt
|
||||
riskAssessments(
|
||||
first: 1
|
||||
@@ -184,7 +180,11 @@ export const thirdPartyNodeQuery = graphql`
|
||||
... on ThirdParty {
|
||||
name
|
||||
websiteUrl
|
||||
firstLevel
|
||||
level
|
||||
ancestors {
|
||||
id
|
||||
name
|
||||
}
|
||||
vettingStatus
|
||||
canVet: permission(action: "core:thirdParty:vet")
|
||||
canUpdate: permission(action: "core:thirdParty:update")
|
||||
@@ -229,14 +229,13 @@ export const thirdPartiesSelectQuery = graphql`
|
||||
thirdParties(
|
||||
first: 100
|
||||
orderBy: { direction: ASC, field: NAME }
|
||||
filter: { firstLevel: true }
|
||||
) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
websiteUrl
|
||||
firstLevel
|
||||
level
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ const thirdPartiesFragment = graphql`
|
||||
last: { type: "Int", defaultValue: null }
|
||||
before: { type: "CursorKey", defaultValue: null }
|
||||
order: { type: "ThirdPartyOrder", defaultValue: null }
|
||||
filter: { type: "ThirdPartyFilter", defaultValue: { firstLevel: true } }
|
||||
filter: { type: "ThirdPartyFilter", defaultValue: { level: 1 } }
|
||||
)
|
||||
@refetchable(queryName: "LinkedThirdPartiesDialogRefetchQuery") {
|
||||
thirdParties(
|
||||
@@ -159,7 +159,7 @@ function LinkedThirdPartiesDialogContent({
|
||||
refetch({
|
||||
first: 20,
|
||||
filter: {
|
||||
firstLevel: true,
|
||||
level: 1,
|
||||
query: v,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -26,15 +26,12 @@ import {
|
||||
IconUpload,
|
||||
PageHeader,
|
||||
RiskBadge,
|
||||
TabItem,
|
||||
Tabs,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
} from "@probo/ui";
|
||||
import { useState, useTransition } from "react";
|
||||
import {
|
||||
type PreloadedQuery,
|
||||
usePaginationFragment,
|
||||
@@ -61,6 +58,12 @@ import { PublishThirdPartyListDialog } from "./dialogs/PublishThirdPartyListDial
|
||||
|
||||
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 = {
|
||||
queryRef: PreloadedQuery<ThirdPartyGraphListQuery>;
|
||||
};
|
||||
@@ -79,21 +82,9 @@ export default function ThirdPartiesPage(props: Props) {
|
||||
|
||||
const thirdParties = pagination.data.thirdParties?.edges.map(edge => edge.node);
|
||||
const connectionId = pagination.data.thirdParties.__id;
|
||||
const [, startTransition] = useTransition();
|
||||
const [firstLevelFilter, setFirstLevelFilter] = useState<boolean | null>(true);
|
||||
|
||||
usePageTitle(__("Third parties"));
|
||||
|
||||
const handleFilterChange = (firstLevel: boolean | null) => {
|
||||
setFirstLevelFilter(firstLevel);
|
||||
startTransition(() => {
|
||||
pagination.refetch(
|
||||
{ filter: firstLevel !== null ? { firstLevel } : {} },
|
||||
{ fetchPolicy: "store-and-network" },
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const hasAnyAction
|
||||
= thirdParties.some(({ canUpdate, canDelete }) => canUpdate || canDelete);
|
||||
|
||||
@@ -144,20 +135,6 @@ export default function ThirdPartiesPage(props: Props) {
|
||||
)}
|
||||
</div>
|
||||
</PageHeader>
|
||||
<Tabs>
|
||||
<TabItem
|
||||
active={firstLevelFilter === true}
|
||||
onClick={() => handleFilterChange(true)}
|
||||
>
|
||||
{__("First Level")}
|
||||
</TabItem>
|
||||
<TabItem
|
||||
active={firstLevelFilter === null}
|
||||
onClick={() => handleFilterChange(null)}
|
||||
>
|
||||
{__("All")}
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
<SortableTable {...pagination}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
@@ -198,6 +175,7 @@ function ThirdPartyRow({
|
||||
const { __ } = useTranslate();
|
||||
const latestAssessment = thirdParty.riskAssessments?.edges[0]?.node;
|
||||
const deleteThirdParty = useDeleteThirdParty(thirdParty, connectionId);
|
||||
const displayName = thirdPartyDisplayName(thirdParty);
|
||||
|
||||
const thirdPartyUrl = `/organizations/${organizationId}/third-parties/${thirdParty.id}/overview`;
|
||||
|
||||
@@ -207,7 +185,7 @@ function ThirdPartyRow({
|
||||
<Td>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Avatar name={thirdParty.name} src={faviconUrl(thirdParty.websiteUrl)} />
|
||||
<div>{thirdParty.name}</div>
|
||||
<div>{displayName}</div>
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
usePreloadedQuery,
|
||||
useRelayEnvironment,
|
||||
} from "react-relay";
|
||||
import { Outlet } from "react-router";
|
||||
import { Link, Outlet } from "react-router";
|
||||
import { fetchQuery } from "relay-runtime";
|
||||
|
||||
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 ancestors = thirdParty.ancestors ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{isVetting && (
|
||||
@@ -142,10 +144,26 @@ export default function ThirdPartyDetailPage(props: Props) {
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-2xl">{thirdParty.name}</div>
|
||||
<Badge variant={thirdParty.firstLevel ? "info" : "neutral"}>
|
||||
{thirdParty.firstLevel ? __("First Level") : __("Indirect")}
|
||||
<Badge variant={thirdParty.level === 1 ? "info" : "neutral"}>
|
||||
{`${__("Level")} ${thirdParty.level}`}
|
||||
</Badge>
|
||||
</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 className="flex gap-2 items-center">
|
||||
{thirdParty.canVet && !isVetting && (
|
||||
|
||||
@@ -12,15 +12,14 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { faviconUrl } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Avatar,
|
||||
Combobox,
|
||||
ComboboxItem,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
IconPlusLarge,
|
||||
useDialogRef,
|
||||
} from "@probo/ui";
|
||||
import { type ReactNode, Suspense, useCallback, useState } from "react";
|
||||
@@ -28,20 +27,18 @@ import { useMutation, useQueryLoader } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
import { useDebounceCallback } from "usehooks-ts";
|
||||
|
||||
import type { AddChildThirdPartyDialogCreateMappingMutation } from "#/__generated__/core/AddChildThirdPartyDialogCreateMappingMutation.graphql";
|
||||
import type { AddChildThirdPartyDialogCreateMutation } from "#/__generated__/core/AddChildThirdPartyDialogCreateMutation.graphql";
|
||||
import type { CommonThirdPartyComboboxQuery } from "#/__generated__/core/CommonThirdPartyComboboxQuery.graphql";
|
||||
import type { CreateThirdPartyInput } from "#/__generated__/core/ThirdPartyGraphCreateMutation.graphql";
|
||||
import { useThirdParties } from "#/hooks/graph/ThirdPartyGraph";
|
||||
|
||||
import { commonThirdPartiesQuery, CommonThirdPartyCombobox } from "./CommonThirdPartyCombobox";
|
||||
|
||||
const createMappingMutation = graphql`
|
||||
mutation AddChildThirdPartyDialogCreateMappingMutation(
|
||||
$input: CreateThirdPartyThirdPartyMappingInput!
|
||||
const createChildMutation = graphql`
|
||||
mutation AddChildThirdPartyDialogCreateMutation(
|
||||
$input: CreateThirdPartyInput!
|
||||
$connections: [ID!]!
|
||||
) {
|
||||
createThirdPartyThirdPartyMapping(input: $input) {
|
||||
createThirdParty(input: $input) {
|
||||
thirdPartyEdge @prependEdge(connections: $connections) {
|
||||
node {
|
||||
id
|
||||
@@ -54,40 +51,24 @@ const createMappingMutation = graphql`
|
||||
}
|
||||
`;
|
||||
|
||||
const createThirdPartyMutation = graphql`
|
||||
mutation AddChildThirdPartyDialogCreateMutation(
|
||||
$input: CreateThirdPartyInput!
|
||||
) {
|
||||
createThirdParty(input: $input) {
|
||||
thirdPartyEdge {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
parentThirdPartyId: string;
|
||||
parentNamePath: string[];
|
||||
organizationId: string;
|
||||
connectionId: string;
|
||||
existingChildIds: string[];
|
||||
};
|
||||
|
||||
export function AddChildThirdPartyDialog({
|
||||
children,
|
||||
parentThirdPartyId,
|
||||
parentNamePath,
|
||||
organizationId,
|
||||
connectionId,
|
||||
existingChildIds,
|
||||
}: Props) {
|
||||
const { __ } = useTranslate();
|
||||
const dialogRef = useDialogRef();
|
||||
const thirdParties = useThirdParties(organizationId);
|
||||
const [createMapping] = useMutation<AddChildThirdPartyDialogCreateMappingMutation>(createMappingMutation);
|
||||
const [createThirdParty] = useMutation<AddChildThirdPartyDialogCreateMutation>(createThirdPartyMutation);
|
||||
const [createChild] = useMutation<AddChildThirdPartyDialogCreateMutation>(createChildMutation);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [queryRef, loadQuery] = useQueryLoader<CommonThirdPartyComboboxQuery>(commonThirdPartiesQuery);
|
||||
|
||||
@@ -109,19 +90,18 @@ export function AddChildThirdPartyDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const existingThirdParties = thirdParties.filter(
|
||||
tp =>
|
||||
tp.id !== parentThirdPartyId
|
||||
&& !existingChildIds.includes(tp.id)
|
||||
&& tp.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
const handleCreate = (input: Omit<CreateThirdPartyInput, "organizationId">) => {
|
||||
const qualifiedName = parentNamePath.length > 0
|
||||
? `${input.name} (${parentNamePath.join("/")})`
|
||||
: input.name;
|
||||
|
||||
const handleSelectExisting = (childId: string) => {
|
||||
createMapping({
|
||||
void createChild({
|
||||
variables: {
|
||||
input: {
|
||||
...input,
|
||||
name: qualifiedName,
|
||||
organizationId,
|
||||
parentThirdPartyId,
|
||||
childThirdPartyId: childId,
|
||||
},
|
||||
connections: [connectionId],
|
||||
},
|
||||
@@ -131,54 +111,32 @@ export function AddChildThirdPartyDialog({
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectCommon = (common: Omit<CreateThirdPartyInput, "organizationId">) => {
|
||||
createThirdParty({
|
||||
variables: {
|
||||
input: {
|
||||
...common,
|
||||
organizationId,
|
||||
firstLevel: false,
|
||||
},
|
||||
},
|
||||
onCompleted: (response) => {
|
||||
const newId = response.createThirdParty.thirdPartyEdge.node.id;
|
||||
createMapping({
|
||||
variables: {
|
||||
input: {
|
||||
parentThirdPartyId,
|
||||
childThirdPartyId: newId,
|
||||
},
|
||||
connections: [connectionId],
|
||||
},
|
||||
onCompleted: () => {
|
||||
dialogRef.current?.close();
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
const handleCreateNew = (name: string) => {
|
||||
handleCreate({ name, category: null });
|
||||
};
|
||||
|
||||
const existingNames = new Set(thirdParties.map(tp => tp.name.toLowerCase()));
|
||||
|
||||
return (
|
||||
<Dialog ref={dialogRef} trigger={children} title={__("Add a third party")}>
|
||||
<DialogContent className="p-6">
|
||||
<Combobox onSearch={handleSearch} placeholder={__("Type third party's name")}>
|
||||
{existingThirdParties.map(tp => (
|
||||
<ComboboxItem key={tp.id} onClick={() => handleSelectExisting(tp.id)}>
|
||||
<Avatar name={tp.name} src={faviconUrl(tp.websiteUrl)} size="s" />
|
||||
{tp.name}
|
||||
</ComboboxItem>
|
||||
))}
|
||||
{searchQuery.trim().length >= 2 && queryRef && (
|
||||
<Suspense>
|
||||
<CommonThirdPartyCombobox
|
||||
queryRef={queryRef}
|
||||
excludeNames={existingNames}
|
||||
onSelect={handleSelectCommon}
|
||||
excludeNames={new Set()}
|
||||
onSelect={handleCreate}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
{searchQuery.trim().length >= 2 && (
|
||||
<ComboboxItem onClick={() => handleCreateNew(searchQuery.trim())}>
|
||||
<IconPlusLarge size={20} />
|
||||
{__("Create a new third party")}
|
||||
{" "}
|
||||
:
|
||||
{searchQuery}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</Combobox>
|
||||
</DialogContent>
|
||||
<DialogFooter />
|
||||
|
||||
@@ -12,10 +12,8 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { faviconUrl } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Avatar,
|
||||
Combobox,
|
||||
ComboboxItem,
|
||||
Dialog,
|
||||
@@ -25,31 +23,15 @@ import {
|
||||
useDialogRef,
|
||||
} from "@probo/ui";
|
||||
import { type ReactNode, Suspense, useCallback, useState } from "react";
|
||||
import { useMutation, useQueryLoader } from "react-relay";
|
||||
import { ConnectionHandler, graphql } from "relay-runtime";
|
||||
import { useQueryLoader } from "react-relay";
|
||||
import { useDebounceCallback } from "usehooks-ts";
|
||||
|
||||
import type { CommonThirdPartyComboboxQuery } from "#/__generated__/core/CommonThirdPartyComboboxQuery.graphql";
|
||||
import type { CreateThirdPartyDialogPromoteMutation } from "#/__generated__/core/CreateThirdPartyDialogPromoteMutation.graphql";
|
||||
import type { CreateThirdPartyInput } from "#/__generated__/core/ThirdPartyGraphCreateMutation.graphql";
|
||||
import { useCreateThirdPartyMutation, useThirdParties } from "#/hooks/graph/ThirdPartyGraph";
|
||||
import { useCreateThirdPartyMutation } from "#/hooks/graph/ThirdPartyGraph";
|
||||
|
||||
import { commonThirdPartiesQuery, CommonThirdPartyCombobox } from "./CommonThirdPartyCombobox";
|
||||
|
||||
const promoteMutation = graphql`
|
||||
mutation CreateThirdPartyDialogPromoteMutation(
|
||||
$input: UpdateThirdPartyInput!
|
||||
) {
|
||||
updateThirdParty(input: $input) {
|
||||
thirdParty {
|
||||
id
|
||||
name
|
||||
firstLevel
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
organizationId: string;
|
||||
@@ -63,48 +45,12 @@ export function CreateThirdPartyDialog({
|
||||
}: Props) {
|
||||
const { __ } = useTranslate();
|
||||
const [createThirdParty] = useCreateThirdPartyMutation();
|
||||
const [promoteThirdParty] = useMutation<CreateThirdPartyDialogPromoteMutation>(promoteMutation);
|
||||
const thirdParties = useThirdParties(organizationId);
|
||||
const dialogRef = useDialogRef();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [queryRef, loadQuery]
|
||||
= useQueryLoader<CommonThirdPartyComboboxQuery>(commonThirdPartiesQuery);
|
||||
|
||||
const nonFirstLevelByName = new Map(
|
||||
thirdParties
|
||||
.filter(tp => !tp.firstLevel)
|
||||
.map(tp => [tp.name.toLowerCase(), tp]),
|
||||
);
|
||||
|
||||
const existingNames = new Set(thirdParties.map(tp => tp.name.toLowerCase()));
|
||||
|
||||
const onSelect = async (thirdParty: Omit<CreateThirdPartyInput, "organizationId"> | string) => {
|
||||
const name = typeof thirdParty === "string" ? thirdParty : thirdParty.name;
|
||||
const existing = nonFirstLevelByName.get(name.toLowerCase());
|
||||
|
||||
if (existing) {
|
||||
promoteThirdParty({
|
||||
variables: {
|
||||
input: { id: existing.id, firstLevel: true },
|
||||
},
|
||||
updater: (store) => {
|
||||
const payload = store.getRootField("updateThirdParty");
|
||||
const node = payload?.getLinkedRecord("thirdParty");
|
||||
if (!node) return;
|
||||
|
||||
const connectionRecord = store.get(connection);
|
||||
if (!connectionRecord) return;
|
||||
|
||||
const edge = ConnectionHandler.createEdge(store, connectionRecord, node, "ThirdPartyEdge");
|
||||
ConnectionHandler.insertEdgeBefore(connectionRecord, edge);
|
||||
},
|
||||
onCompleted: () => {
|
||||
dialogRef.current?.close();
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const onSelect = (thirdParty: Omit<CreateThirdPartyInput, "organizationId"> | string) => {
|
||||
const input
|
||||
= typeof thirdParty === "string"
|
||||
? {
|
||||
@@ -116,7 +62,7 @@ export function CreateThirdPartyDialog({
|
||||
...thirdParty,
|
||||
organizationId,
|
||||
};
|
||||
await createThirdParty({
|
||||
void createThirdParty({
|
||||
variables: {
|
||||
input,
|
||||
connections: [connection],
|
||||
@@ -149,26 +95,11 @@ export function CreateThirdPartyDialog({
|
||||
<Dialog ref={dialogRef} trigger={children} title={__("Add a third party")}>
|
||||
<DialogContent className="p-6">
|
||||
<Combobox onSearch={handleSearch} placeholder={__("Type third party's name")}>
|
||||
{searchQuery.trim().length >= 2 && (
|
||||
<>
|
||||
{thirdParties
|
||||
.filter(tp =>
|
||||
!tp.firstLevel
|
||||
&& tp.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
.map(tp => (
|
||||
<ComboboxItem key={tp.id} onClick={() => void onSelect(tp.name)}>
|
||||
<Avatar name={tp.name} src={faviconUrl(tp.websiteUrl)} size="s" />
|
||||
{tp.name}
|
||||
</ComboboxItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{searchQuery.trim().length >= 2 && queryRef && (
|
||||
<Suspense>
|
||||
<CommonThirdPartyCombobox
|
||||
queryRef={queryRef}
|
||||
excludeNames={existingNames}
|
||||
excludeNames={new Set()}
|
||||
onSelect={thirdPartyRef => void onSelect(thirdPartyRef)}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
} from "react-relay";
|
||||
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 { ThirdPartyThirdPartiesPagePaginationQuery } from "#/__generated__/core/ThirdPartyThirdPartiesPagePaginationQuery.graphql";
|
||||
import type { ThirdPartyThirdPartiesPageQuery } from "#/__generated__/core/ThirdPartyThirdPartiesPageQuery.graphql";
|
||||
@@ -47,6 +47,9 @@ import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
import { AddChildThirdPartyDialog } from "../dialogs/AddChildThirdPartyDialog";
|
||||
|
||||
// Keep in sync with coredata.MaxThirdPartyLevel on the backend.
|
||||
const MAX_THIRD_PARTY_LEVEL = 4;
|
||||
|
||||
export const thirdPartyThirdPartiesPageQuery = graphql`
|
||||
query ThirdPartyThirdPartiesPageQuery($thirdPartyId: ID!) {
|
||||
node(id: $thirdPartyId) {
|
||||
@@ -54,6 +57,10 @@ export const thirdPartyThirdPartiesPageQuery = graphql`
|
||||
... on ThirdParty {
|
||||
id
|
||||
name
|
||||
level
|
||||
ancestors {
|
||||
name
|
||||
}
|
||||
canUpdate: permission(action: "core:thirdParty:update")
|
||||
...ThirdPartyThirdPartiesPageFragment
|
||||
}
|
||||
@@ -103,13 +110,13 @@ const paginatedFragment = graphql`
|
||||
}
|
||||
`;
|
||||
|
||||
const deleteMappingMutation = graphql`
|
||||
mutation ThirdPartyThirdPartiesPageDeleteMappingMutation(
|
||||
$input: DeleteThirdPartyThirdPartyMappingInput!
|
||||
const deleteChildMutation = graphql`
|
||||
mutation ThirdPartyThirdPartiesPageDeleteMutation(
|
||||
$input: DeleteThirdPartyInput!
|
||||
$connections: [ID!]!
|
||||
) {
|
||||
deleteThirdPartyThirdPartyMapping(input: $input) {
|
||||
removedThirdPartyId @deleteEdge(connections: $connections)
|
||||
deleteThirdParty(input: $input) {
|
||||
deletedThirdPartyId @deleteEdge(connections: $connections)
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -129,7 +136,7 @@ export default function ThirdPartyThirdPartiesPage({ queryRef }: Props) {
|
||||
ThirdPartyThirdPartiesPagePaginationQuery,
|
||||
ThirdPartyThirdPartiesPageFragment$key
|
||||
>(paginatedFragment, thirdParty as ThirdPartyThirdPartiesPageFragment$key);
|
||||
const [deleteMapping] = useMutation<ThirdPartyThirdPartiesPageDeleteMappingMutation>(deleteMappingMutation);
|
||||
const [deleteChild] = useMutation<ThirdPartyThirdPartiesPageDeleteMutation>(deleteChildMutation);
|
||||
|
||||
usePageTitle((thirdParty?.name ?? "") + " - " + __("Third Parties"));
|
||||
|
||||
@@ -140,16 +147,22 @@ export default function ThirdPartyThirdPartiesPage({ queryRef }: Props) {
|
||||
const connectionId = pagination.data.childThirdParties.__id;
|
||||
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(
|
||||
() =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
deleteMapping({
|
||||
deleteChild({
|
||||
variables: {
|
||||
input: {
|
||||
parentThirdPartyId: thirdParty.id,
|
||||
childThirdPartyId: childId,
|
||||
},
|
||||
input: { thirdPartyId: childId },
|
||||
connections: [connectionId],
|
||||
},
|
||||
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")}
|
||||
description={__("Manage third parties linked to this third party.")}
|
||||
>
|
||||
{thirdParty.canUpdate && (
|
||||
{thirdParty.canUpdate && thirdParty.level < MAX_THIRD_PARTY_LEVEL && (
|
||||
<AddChildThirdPartyDialog
|
||||
parentThirdPartyId={thirdParty.id}
|
||||
parentNamePath={parentNamePath}
|
||||
organizationId={organizationId}
|
||||
connectionId={connectionId}
|
||||
existingChildIds={childThirdParties.map(c => c.id)}
|
||||
>
|
||||
<Button icon={IconPlusLarge}>{__("Add third party")}</Button>
|
||||
</AddChildThirdPartyDialog>
|
||||
@@ -223,7 +236,7 @@ export default function ThirdPartyThirdPartiesPage({ queryRef }: Props) {
|
||||
<Button
|
||||
variant="tertiary"
|
||||
icon={IconTrashCan}
|
||||
onClick={() => handleRemove(child.id, child.name)}
|
||||
onClick={() => handleDelete(child.id, child.name)}
|
||||
/>
|
||||
)}
|
||||
</Td>
|
||||
|
||||
@@ -23,14 +23,12 @@ import (
|
||||
"go.probo.inc/probo/e2e/internal/testutil"
|
||||
)
|
||||
|
||||
func TestThirdPartyRelation_AddAndList(t *testing.T) {
|
||||
func TestThirdPartyRelation_CreateChildAndList(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
parentID := factory.NewThirdParty(owner).WithName("Parent Corp").Create()
|
||||
childID := factory.NewThirdParty(owner).WithName("Child Corp").Create()
|
||||
|
||||
addRelation(t, owner, parentID, childID)
|
||||
childID := createChildThirdParty(t, owner, parentID, "Child Corp")
|
||||
|
||||
t.Run("list child third parties", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -74,90 +72,146 @@ func TestThirdPartyRelation_AddAndList(t *testing.T) {
|
||||
require.Len(t, result.Node.ChildThirdParties.Edges, 1)
|
||||
assert.Equal(t, childID, result.Node.ChildThirdParties.Edges[0].Node.ID)
|
||||
})
|
||||
|
||||
t.Run("child has parent reference", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const query = `
|
||||
query($id: ID!) {
|
||||
node(id: $id) {
|
||||
... on ThirdParty {
|
||||
parentThirdParty {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
var result struct {
|
||||
Node struct {
|
||||
ParentThirdParty *struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"parentThirdParty"`
|
||||
} `json:"node"`
|
||||
}
|
||||
|
||||
err := owner.Execute(query, map[string]any{"id": childID}, &result)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result.Node.ParentThirdParty)
|
||||
assert.Equal(t, parentID, result.Node.ParentThirdParty.ID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestThirdPartyRelation_Remove(t *testing.T) {
|
||||
func TestThirdPartyRelation_Ancestors(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
rootID := factory.NewThirdParty(owner).WithName("Ancestor Root").Create()
|
||||
midID := createChildThirdParty(t, owner, rootID, "Ancestor Mid")
|
||||
leafID := createChildThirdParty(t, owner, midID, "Ancestor Leaf")
|
||||
|
||||
t.Run("root has no ancestors", 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": 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()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
parentID := factory.NewThirdParty(owner).WithName("Parent Remove").Create()
|
||||
childID := factory.NewThirdParty(owner).WithName("Child Remove").Create()
|
||||
|
||||
addRelation(t, owner, parentID, childID)
|
||||
|
||||
const removeQuery = `
|
||||
mutation($input: DeleteThirdPartyThirdPartyMappingInput!) {
|
||||
deleteThirdPartyThirdPartyMapping(input: $input) {
|
||||
removedThirdPartyId
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
var result struct {
|
||||
DeleteThirdPartyThirdPartyMapping struct {
|
||||
RemovedThirdPartyID string `json:"removedThirdPartyId"`
|
||||
} `json:"deleteThirdPartyThirdPartyMapping"`
|
||||
}
|
||||
|
||||
err := owner.Execute(removeQuery, map[string]any{
|
||||
"input": map[string]any{
|
||||
"parentThirdPartyId": parentID,
|
||||
"childThirdPartyId": childID,
|
||||
},
|
||||
}, &result)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, childID, result.DeleteThirdPartyThirdPartyMapping.RemovedThirdPartyID)
|
||||
|
||||
count := countChildThirdParties(t, owner, parentID)
|
||||
assert.Equal(t, 0, count)
|
||||
}
|
||||
|
||||
func TestThirdPartyRelation_Bidirectional(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
aID := factory.NewThirdParty(owner).WithName("Company A").Create()
|
||||
bID := factory.NewThirdParty(owner).WithName("Company B").Create()
|
||||
|
||||
addRelation(t, owner, aID, bID)
|
||||
addRelation(t, owner, bID, aID)
|
||||
|
||||
t.Run("A has B as child", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
count := countChildThirdParties(t, owner, aID)
|
||||
assert.Equal(t, 1, count)
|
||||
})
|
||||
|
||||
t.Run("B has A as child", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
count := countChildThirdParties(t, owner, bID)
|
||||
assert.Equal(t, 1, count)
|
||||
})
|
||||
}
|
||||
|
||||
func TestThirdPartyRelation_Idempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
parentID := factory.NewThirdParty(owner).WithName("Idempotent Parent").Create()
|
||||
childID := factory.NewThirdParty(owner).WithName("Idempotent Child").Create()
|
||||
|
||||
addRelation(t, owner, parentID, childID)
|
||||
addRelation(t, owner, parentID, childID)
|
||||
|
||||
count := countChildThirdParties(t, owner, parentID)
|
||||
assert.Equal(t, 1, count)
|
||||
}
|
||||
|
||||
func TestThirdPartyRelation_CascadeOnDelete(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
parentID := factory.NewThirdParty(owner).WithName("Cascade Parent").Create()
|
||||
childID := factory.NewThirdParty(owner).WithName("Cascade Child").Create()
|
||||
|
||||
addRelation(t, owner, parentID, childID)
|
||||
childID := createChildThirdParty(t, owner, parentID, "Child Remove")
|
||||
|
||||
const deleteQuery = `
|
||||
mutation($input: DeleteThirdPartyInput!) {
|
||||
@@ -178,7 +232,9 @@ func TestThirdPartyRelation_CascadeOnDelete(t *testing.T) {
|
||||
"thirdPartyId": childID,
|
||||
},
|
||||
}, &result)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, childID, result.DeleteThirdParty.DeletedThirdPartyID)
|
||||
|
||||
count := countChildThirdParties(t, owner, parentID)
|
||||
assert.Equal(t, 0, count)
|
||||
@@ -190,51 +246,6 @@ func TestThirdPartyRelation_Authorization(t *testing.T) {
|
||||
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
|
||||
|
||||
parentID := factory.NewThirdParty(owner).WithName("Auth Parent").Create()
|
||||
childID := factory.NewThirdParty(owner).WithName("Auth Child").Create()
|
||||
|
||||
t.Run("viewer cannot add relation", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const query = `
|
||||
mutation($input: CreateThirdPartyThirdPartyMappingInput!) {
|
||||
createThirdPartyThirdPartyMapping(input: $input) {
|
||||
thirdPartyEdge {
|
||||
node { id }
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
_, err := viewer.Do(query, map[string]any{
|
||||
"input": map[string]any{
|
||||
"parentThirdPartyId": parentID,
|
||||
"childThirdPartyId": childID,
|
||||
},
|
||||
})
|
||||
testutil.RequireForbiddenError(t, err)
|
||||
})
|
||||
|
||||
t.Run("viewer cannot remove relation", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
addRelation(t, owner, parentID, childID)
|
||||
|
||||
const query = `
|
||||
mutation($input: DeleteThirdPartyThirdPartyMappingInput!) {
|
||||
deleteThirdPartyThirdPartyMapping(input: $input) {
|
||||
removedThirdPartyId
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
_, err := viewer.Do(query, map[string]any{
|
||||
"input": map[string]any{
|
||||
"parentThirdPartyId": parentID,
|
||||
"childThirdPartyId": childID,
|
||||
},
|
||||
})
|
||||
testutil.RequireForbiddenError(t, err)
|
||||
})
|
||||
|
||||
t.Run("viewer can list child third parties", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -251,32 +262,7 @@ func TestThirdPartyRelation_TenantIsolation(t *testing.T) {
|
||||
org2Owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
parentID := factory.NewThirdParty(org1Owner).WithName("Org1 Parent").Create()
|
||||
childID := factory.NewThirdParty(org1Owner).WithName("Org1 Child").Create()
|
||||
org2ChildID := factory.NewThirdParty(org2Owner).WithName("Org2 Child").Create()
|
||||
|
||||
addRelation(t, org1Owner, parentID, childID)
|
||||
|
||||
t.Run("cannot add cross-org relation", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const query = `
|
||||
mutation($input: CreateThirdPartyThirdPartyMappingInput!) {
|
||||
createThirdPartyThirdPartyMapping(input: $input) {
|
||||
thirdPartyEdge {
|
||||
node { id }
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
_, err := org1Owner.Do(query, map[string]any{
|
||||
"input": map[string]any{
|
||||
"parentThirdPartyId": parentID,
|
||||
"childThirdPartyId": org2ChildID,
|
||||
},
|
||||
})
|
||||
require.Error(t, err)
|
||||
})
|
||||
createChildThirdParty(t, org1Owner, parentID, "Org1 Child")
|
||||
|
||||
t.Run("cannot list children of other org third party", func(t *testing.T) {
|
||||
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()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
factory.NewThirdParty(owner).WithName("Direct TP").Create()
|
||||
|
||||
const createNonDirect = `
|
||||
const createThirdParty = `
|
||||
mutation($input: CreateThirdPartyInput!) {
|
||||
createThirdParty(input: $input) {
|
||||
thirdPartyEdge {
|
||||
node {
|
||||
id
|
||||
firstLevel
|
||||
level
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
var createResult struct {
|
||||
type createThirdPartyResult struct {
|
||||
CreateThirdParty struct {
|
||||
ThirdPartyEdge struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
FirstLevel bool `json:"firstLevel"`
|
||||
ID string `json:"id"`
|
||||
Level int `json:"level"`
|
||||
} `json:"node"`
|
||||
} `json:"thirdPartyEdge"`
|
||||
} `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("NonDirect TP"),
|
||||
"firstLevel": false,
|
||||
"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{
|
||||
"organizationId": owner.GetOrganizationID().String(),
|
||||
"name": factory.SafeName("NonDirect TP"),
|
||||
"parentThirdPartyId": parentResult.CreateThirdParty.ThirdPartyEdge.Node.ID,
|
||||
},
|
||||
}, &createResult)
|
||||
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()
|
||||
|
||||
const query = `
|
||||
query($orgId: ID!) {
|
||||
node(id: $orgId) {
|
||||
... on Organization {
|
||||
thirdParties(first: 100, filter: { firstLevel: true }) {
|
||||
thirdParties(first: 100, filter: { level: 1 }) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
firstLevel
|
||||
level
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -374,8 +375,8 @@ func TestThirdParty_DirectFilter(t *testing.T) {
|
||||
ThirdParties struct {
|
||||
Edges []struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
FirstLevel bool `json:"firstLevel"`
|
||||
ID string `json:"id"`
|
||||
Level int `json:"level"`
|
||||
} `json:"node"`
|
||||
} `json:"edges"`
|
||||
} `json:"thirdParties"`
|
||||
@@ -388,7 +389,7 @@ func TestThirdParty_DirectFilter(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, edge := range result.Node.ThirdParties.Edges {
|
||||
assert.True(t, edge.Node.FirstLevel, "expected all third parties to be firstLevel when filtering direct=true")
|
||||
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 {
|
||||
node {
|
||||
id
|
||||
firstLevel
|
||||
level
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -417,8 +418,8 @@ func TestThirdParty_DirectFilter(t *testing.T) {
|
||||
ThirdParties struct {
|
||||
Edges []struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
FirstLevel bool `json:"firstLevel"`
|
||||
ID string `json:"id"`
|
||||
Level int `json:"level"`
|
||||
} `json:"node"`
|
||||
} `json:"edges"`
|
||||
} `json:"thirdParties"`
|
||||
@@ -430,28 +431,28 @@ func TestThirdParty_DirectFilter(t *testing.T) {
|
||||
}, &result)
|
||||
require.NoError(t, err)
|
||||
|
||||
hasFirstLevel := false
|
||||
hasNonFirstLevel := false
|
||||
hasLevel1 := false
|
||||
hasLevel2 := false
|
||||
|
||||
for _, edge := range result.Node.ThirdParties.Edges {
|
||||
if edge.Node.FirstLevel {
|
||||
hasFirstLevel = true
|
||||
} else {
|
||||
hasNonFirstLevel = true
|
||||
if edge.Node.Level == 1 {
|
||||
hasLevel1 = true
|
||||
} else if edge.Node.Level >= 2 {
|
||||
hasLevel2 = true
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, hasFirstLevel, "expected at least one first-level third party")
|
||||
assert.True(t, hasNonFirstLevel, "expected at least one non-first-level third party")
|
||||
assert.True(t, hasLevel1, "expected at least one level-1 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()
|
||||
|
||||
const query = `
|
||||
mutation($input: CreateThirdPartyThirdPartyMappingInput!) {
|
||||
createThirdPartyThirdPartyMapping(input: $input) {
|
||||
mutation($input: CreateThirdPartyInput!) {
|
||||
createThirdParty(input: $input) {
|
||||
thirdPartyEdge {
|
||||
node { id }
|
||||
}
|
||||
@@ -460,22 +461,25 @@ func addRelation(t *testing.T, c *testutil.Client, parentID, childID string) {
|
||||
`
|
||||
|
||||
var result struct {
|
||||
CreateThirdPartyThirdPartyMapping struct {
|
||||
CreateThirdParty struct {
|
||||
ThirdPartyEdge struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"node"`
|
||||
} `json:"thirdPartyEdge"`
|
||||
} `json:"createThirdPartyThirdPartyMapping"`
|
||||
} `json:"createThirdParty"`
|
||||
}
|
||||
|
||||
err := c.Execute(query, map[string]any{
|
||||
"input": map[string]any{
|
||||
"organizationId": c.GetOrganizationID().String(),
|
||||
"name": factory.SafeName(name),
|
||||
"parentThirdPartyId": parentID,
|
||||
"childThirdPartyId": childID,
|
||||
},
|
||||
}, &result)
|
||||
require.NoError(t, err)
|
||||
|
||||
return result.CreateThirdParty.ThirdPartyEdge.Node.ID
|
||||
}
|
||||
|
||||
func countChildThirdParties(t *testing.T, c *testutil.Client, parentID string) int {
|
||||
|
||||
@@ -177,19 +177,19 @@ export const description: INodeProperties[] = [
|
||||
type: 'string',
|
||||
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',
|
||||
name: 'privacyPolicyUrl',
|
||||
type: 'string',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Root',
|
||||
name: 'firstLevel',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether this is a first-level third party',
|
||||
},
|
||||
{
|
||||
displayName: 'Security Page URL',
|
||||
name: 'securityPageUrl',
|
||||
@@ -256,7 +256,7 @@ export async function execute(
|
||||
trustPageUrl?: string;
|
||||
certifications?: string;
|
||||
countries?: string;
|
||||
firstLevel?: boolean;
|
||||
parentThirdPartyId?: string;
|
||||
};
|
||||
|
||||
const query = `
|
||||
@@ -283,7 +283,7 @@ export async function execute(
|
||||
certifications
|
||||
countries
|
||||
showOnTrustCenter
|
||||
firstLevel
|
||||
level
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
@@ -312,7 +312,10 @@ export async function execute(
|
||||
if (additionalFields.subprocessorsListUrl) input.subprocessorsListUrl = additionalFields.subprocessorsListUrl;
|
||||
if (additionalFields.securityPageUrl) input.securityPageUrl = additionalFields.securityPageUrl;
|
||||
if (additionalFields.trustPageUrl) input.trustPageUrl = additionalFields.trustPageUrl;
|
||||
if (additionalFields.firstLevel !== undefined) input.firstLevel = additionalFields.firstLevel;
|
||||
if (additionalFields.parentThirdPartyId) {
|
||||
// The server derives the level from the parent (parent.level + 1).
|
||||
input.parentThirdPartyId = additionalFields.parentThirdPartyId;
|
||||
}
|
||||
if (additionalFields.certifications) {
|
||||
input.certifications = additionalFields.certifications.split(',').map((c) => c.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
@@ -74,11 +74,11 @@ export const description: INodeProperties[] = [
|
||||
},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Filter by Root',
|
||||
name: 'filterFirstLevel',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to filter by first-level third parties only',
|
||||
displayName: 'Filter by Level',
|
||||
name: 'filterLevel',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
description: 'Filter by third party level (1 = direct, 2+ = indirect, 0 = no filter)',
|
||||
},
|
||||
{
|
||||
displayName: 'Include Organization',
|
||||
@@ -113,7 +113,7 @@ export async function execute(
|
||||
const returnAll = this.getNodeParameter('returnAll', itemIndex) as boolean;
|
||||
const limit = this.getNodeParameter('limit', itemIndex, 50) as number;
|
||||
const options = this.getNodeParameter('options', itemIndex, {}) as {
|
||||
filterFirstLevel?: boolean;
|
||||
filterLevel?: number;
|
||||
includeOrganization?: boolean;
|
||||
includeBusinessOwner?: boolean;
|
||||
includeSecurityOwner?: boolean;
|
||||
@@ -142,8 +142,8 @@ export async function execute(
|
||||
}`
|
||||
: '';
|
||||
|
||||
const filterVariable = options.filterFirstLevel !== undefined ? ', $filter: ThirdPartyFilter' : '';
|
||||
const filterArgument = options.filterFirstLevel !== undefined ? ', filter: $filter' : '';
|
||||
const filterVariable = options.filterLevel ? ', $filter: ThirdPartyFilter' : '';
|
||||
const filterArgument = options.filterLevel ? ', filter: $filter' : '';
|
||||
|
||||
const query = `
|
||||
query GetThirdParties($organizationId: ID!, $first: Int, $after: CursorKey${filterVariable}) {
|
||||
@@ -171,7 +171,11 @@ export async function execute(
|
||||
certifications
|
||||
countries
|
||||
showOnTrustCenter
|
||||
firstLevel
|
||||
level
|
||||
ancestors {
|
||||
id
|
||||
name
|
||||
}
|
||||
${organizationFragment}
|
||||
${businessOwnerFragment}
|
||||
${securityOwnerFragment}
|
||||
@@ -190,8 +194,8 @@ export async function execute(
|
||||
`;
|
||||
|
||||
const variables: IDataObject = { organizationId };
|
||||
if (options.filterFirstLevel) {
|
||||
variables.filter = { firstLevel: true };
|
||||
if (options.filterLevel) {
|
||||
variables.filter = { level: options.filterLevel };
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -55,12 +55,12 @@ type thirdParty struct {
|
||||
|
||||
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
var (
|
||||
flagOrg string
|
||||
flagLimit int
|
||||
flagOrderBy string
|
||||
flagOrderDir string
|
||||
flagFirstLevel bool
|
||||
flagOutput *string
|
||||
flagOrg string
|
||||
flagLimit int
|
||||
flagOrderBy string
|
||||
flagOrderDir string
|
||||
flagLevel int
|
||||
flagOutput *string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
@@ -108,9 +108,13 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
"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{
|
||||
"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().StringVar(&flagOrderBy, "order-by", "", "Order by field (NAME, CREATED_AT, UPDATED_AT)")
|
||||
cmd.Flags().StringVar(&flagOrderDir, "order-direction", "DESC", "Sort direction (ASC, DESC)")
|
||||
cmd.Flags().BoolVar(&flagFirstLevel, "first-level", false, "Filter by first-level thirdParties only")
|
||||
cmd.Flags().IntVar(&flagLevel, "level", 0, "Filter by third party level (1 = direct, 2+ = indirect)")
|
||||
flagOutput = cmdutil.AddOutputFlag(cmd)
|
||||
|
||||
return cmd
|
||||
|
||||
@@ -19,10 +19,8 @@ import (
|
||||
"go.probo.inc/probo/pkg/cmd/cmdutil"
|
||||
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/create"
|
||||
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/delete"
|
||||
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/link"
|
||||
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/list"
|
||||
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/publish"
|
||||
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/unlink"
|
||||
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/update"
|
||||
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/vet"
|
||||
"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(vet.NewCmdVet(f))
|
||||
cmd.AddCommand(publish.NewCmdPublish(f))
|
||||
cmd.AddCommand(link.NewCmdLink(f))
|
||||
cmd.AddCommand(unlink.NewCmdUnlink(f))
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
firstLevel := 1
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -255,7 +255,7 @@ func TestPromoteThirdParty_FallbackCreate(t *testing.T) {
|
||||
require.NotNil(t, reloaded.CommonThirdPartyID)
|
||||
assert.Equal(t, fx.commonThirdPartyID, *reloaded.CommonThirdPartyID)
|
||||
assert.Equal(t, coredata.ThirdPartyCategoryAnalytics, reloaded.Category)
|
||||
assert.True(t, reloaded.FirstLevel)
|
||||
assert.Equal(t, 1, reloaded.Level)
|
||||
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"
|
||||
)
|
||||
|
||||
// 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(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
@@ -140,6 +145,7 @@ type (
|
||||
ThirdParty struct {
|
||||
ID gid.GID `db:"id"`
|
||||
OrganizationID gid.GID `db:"organization_id"`
|
||||
ParentThirdPartyID *gid.GID `db:"parent_third_party_id"`
|
||||
CommonThirdPartyID *gid.GID `db:"common_third_party_id"`
|
||||
Name string `db:"name"`
|
||||
Description *string `db:"description"`
|
||||
@@ -161,7 +167,7 @@ type (
|
||||
SecurityPageURL *string `db:"security_page_url"`
|
||||
TrustPageURL *string `db:"trust_page_url"`
|
||||
ShowOnTrustCenter bool `db:"show_on_trust_center"`
|
||||
FirstLevel bool `db:"first_level"`
|
||||
Level int `db:"level"`
|
||||
VettingStatus *ThirdPartyVettingStatus `db:"vetting_status"`
|
||||
VettingWebsiteURL *string `db:"vetting_website_url"`
|
||||
VettingProcedure *string `db:"vetting_procedure"`
|
||||
@@ -236,6 +242,7 @@ func (v *ThirdParty) LoadByID(
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
parent_third_party_id,
|
||||
common_third_party_id,
|
||||
name,
|
||||
description,
|
||||
@@ -257,7 +264,7 @@ SELECT
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
show_on_trust_center,
|
||||
first_level,
|
||||
level,
|
||||
vetting_status,
|
||||
vetting_website_url,
|
||||
vetting_procedure,
|
||||
@@ -308,6 +315,7 @@ func (v *ThirdParty) LoadByIDForUpdate(
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
parent_third_party_id,
|
||||
common_third_party_id,
|
||||
name,
|
||||
description,
|
||||
@@ -329,7 +337,7 @@ SELECT
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
show_on_trust_center,
|
||||
first_level,
|
||||
level,
|
||||
vetting_status,
|
||||
vetting_website_url,
|
||||
vetting_procedure,
|
||||
@@ -382,6 +390,7 @@ func (v *ThirdParty) LoadByNameAndOrganizationID(
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
parent_third_party_id,
|
||||
common_third_party_id,
|
||||
name,
|
||||
description,
|
||||
@@ -403,7 +412,7 @@ SELECT
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
show_on_trust_center,
|
||||
first_level,
|
||||
level,
|
||||
vetting_status,
|
||||
vetting_website_url,
|
||||
vetting_procedure,
|
||||
@@ -448,16 +457,18 @@ LIMIT 1;
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *ThirdParties) LoadByIDs(
|
||||
func (v *ThirdParty) LoadByNameAndParentThirdPartyID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
thirdPartyIDs []gid.GID,
|
||||
name string,
|
||||
parentThirdPartyID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
parent_third_party_id,
|
||||
common_third_party_id,
|
||||
name,
|
||||
description,
|
||||
@@ -479,7 +490,84 @@ SELECT
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
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_website_url,
|
||||
vetting_procedure,
|
||||
@@ -525,6 +613,7 @@ INSERT INTO
|
||||
tenant_id,
|
||||
id,
|
||||
organization_id,
|
||||
parent_third_party_id,
|
||||
common_third_party_id,
|
||||
name,
|
||||
description,
|
||||
@@ -546,7 +635,7 @@ INSERT INTO
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
show_on_trust_center,
|
||||
first_level,
|
||||
level,
|
||||
vetting_status,
|
||||
vetting_website_url,
|
||||
vetting_procedure,
|
||||
@@ -559,6 +648,7 @@ VALUES (
|
||||
@tenant_id,
|
||||
@third_party_id,
|
||||
@organization_id,
|
||||
@parent_third_party_id,
|
||||
@common_third_party_id,
|
||||
@name,
|
||||
@description,
|
||||
@@ -580,7 +670,7 @@ VALUES (
|
||||
@security_page_url,
|
||||
@trust_page_url,
|
||||
@show_on_trust_center,
|
||||
@first_level,
|
||||
@level,
|
||||
@vetting_status,
|
||||
@vetting_website_url,
|
||||
@vetting_procedure,
|
||||
@@ -595,6 +685,7 @@ VALUES (
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"third_party_id": v.ID,
|
||||
"organization_id": v.OrganizationID,
|
||||
"parent_third_party_id": v.ParentThirdPartyID,
|
||||
"common_third_party_id": v.CommonThirdPartyID,
|
||||
"name": v.Name,
|
||||
"description": v.Description,
|
||||
@@ -616,7 +707,7 @@ VALUES (
|
||||
"security_page_url": v.SecurityPageURL,
|
||||
"trust_page_url": v.TrustPageURL,
|
||||
"show_on_trust_center": v.ShowOnTrustCenter,
|
||||
"first_level": v.FirstLevel,
|
||||
"level": v.Level,
|
||||
"vetting_status": v.VettingStatus,
|
||||
"vetting_website_url": v.VettingWebsiteURL,
|
||||
"vetting_procedure": v.VettingProcedure,
|
||||
@@ -690,11 +781,13 @@ func (v *ThirdParties) LoadAllByOrganizationID(
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
organizationID gid.GID,
|
||||
filter *ThirdPartyFilter,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
parent_third_party_id,
|
||||
common_third_party_id,
|
||||
name,
|
||||
description,
|
||||
@@ -716,7 +809,7 @@ SELECT
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
show_on_trust_center,
|
||||
first_level,
|
||||
level,
|
||||
vetting_status,
|
||||
vetting_website_url,
|
||||
vetting_procedure,
|
||||
@@ -729,12 +822,14 @@ FROM
|
||||
WHERE
|
||||
%s
|
||||
AND organization_id = @organization_id
|
||||
AND %s
|
||||
ORDER BY name ASC
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
maps.Copy(args, filter.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
@@ -763,6 +858,7 @@ func (v *ThirdParties) LoadByOrganizationID(
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
parent_third_party_id,
|
||||
common_third_party_id,
|
||||
name,
|
||||
description,
|
||||
@@ -784,7 +880,7 @@ SELECT
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
show_on_trust_center,
|
||||
first_level,
|
||||
level,
|
||||
vetting_status,
|
||||
vetting_website_url,
|
||||
vetting_procedure,
|
||||
@@ -834,6 +930,7 @@ SET
|
||||
name = @name,
|
||||
description = @description,
|
||||
category = @category,
|
||||
parent_third_party_id = @parent_third_party_id,
|
||||
headquarter_address = @headquarter_address,
|
||||
legal_name = @legal_name,
|
||||
website_url = @website_url,
|
||||
@@ -851,7 +948,7 @@ SET
|
||||
business_owner_profile_id = @business_owner_profile_id,
|
||||
security_owner_profile_id = @security_owner_profile_id,
|
||||
show_on_trust_center = @show_on_trust_center,
|
||||
first_level = @first_level,
|
||||
level = @level,
|
||||
vetting_status = @vetting_status,
|
||||
vetting_website_url = @vetting_website_url,
|
||||
vetting_procedure = @vetting_procedure,
|
||||
@@ -870,6 +967,7 @@ WHERE %s
|
||||
"name": v.Name,
|
||||
"description": v.Description,
|
||||
"category": v.Category,
|
||||
"parent_third_party_id": v.ParentThirdPartyID,
|
||||
"headquarter_address": v.HeadquarterAddress,
|
||||
"legal_name": v.LegalName,
|
||||
"website_url": v.WebsiteURL,
|
||||
@@ -887,7 +985,7 @@ WHERE %s
|
||||
"business_owner_profile_id": v.BusinessOwnerID,
|
||||
"security_owner_profile_id": v.SecurityOwnerID,
|
||||
"show_on_trust_center": v.ShowOnTrustCenter,
|
||||
"first_level": v.FirstLevel,
|
||||
"level": v.Level,
|
||||
"vetting_status": v.VettingStatus,
|
||||
"vetting_website_url": v.VettingWebsiteURL,
|
||||
"vetting_procedure": v.VettingProcedure,
|
||||
@@ -996,6 +1094,7 @@ WITH vend AS (
|
||||
v.id,
|
||||
v.tenant_id,
|
||||
v.organization_id,
|
||||
v.parent_third_party_id,
|
||||
v.common_third_party_id,
|
||||
v.name,
|
||||
v.description,
|
||||
@@ -1017,7 +1116,7 @@ WITH vend AS (
|
||||
v.security_page_url,
|
||||
v.trust_page_url,
|
||||
v.show_on_trust_center,
|
||||
v.first_level,
|
||||
v.level,
|
||||
v.vetting_status,
|
||||
v.vetting_website_url,
|
||||
v.vetting_procedure,
|
||||
@@ -1035,6 +1134,7 @@ WITH vend AS (
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
parent_third_party_id,
|
||||
common_third_party_id,
|
||||
name,
|
||||
description,
|
||||
@@ -1056,7 +1156,7 @@ SELECT
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
show_on_trust_center,
|
||||
first_level,
|
||||
level,
|
||||
vetting_status,
|
||||
vetting_website_url,
|
||||
vetting_procedure,
|
||||
@@ -1142,6 +1242,7 @@ WITH vend AS (
|
||||
v.id,
|
||||
v.tenant_id,
|
||||
v.organization_id,
|
||||
v.parent_third_party_id,
|
||||
v.common_third_party_id,
|
||||
v.name,
|
||||
v.description,
|
||||
@@ -1163,7 +1264,7 @@ WITH vend AS (
|
||||
v.security_page_url,
|
||||
v.trust_page_url,
|
||||
v.show_on_trust_center,
|
||||
v.first_level,
|
||||
v.level,
|
||||
v.vetting_status,
|
||||
v.vetting_website_url,
|
||||
v.vetting_procedure,
|
||||
@@ -1181,6 +1282,7 @@ WITH vend AS (
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
parent_third_party_id,
|
||||
common_third_party_id,
|
||||
name,
|
||||
description,
|
||||
@@ -1202,7 +1304,7 @@ SELECT
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
show_on_trust_center,
|
||||
first_level,
|
||||
level,
|
||||
vetting_status,
|
||||
vetting_website_url,
|
||||
vetting_procedure,
|
||||
@@ -1248,6 +1350,7 @@ WITH vend AS (
|
||||
v.id,
|
||||
v.tenant_id,
|
||||
v.organization_id,
|
||||
v.parent_third_party_id,
|
||||
v.common_third_party_id,
|
||||
v.name,
|
||||
v.description,
|
||||
@@ -1269,7 +1372,7 @@ WITH vend AS (
|
||||
v.security_page_url,
|
||||
v.trust_page_url,
|
||||
v.show_on_trust_center,
|
||||
v.first_level,
|
||||
v.level,
|
||||
v.vetting_status,
|
||||
v.vetting_website_url,
|
||||
v.vetting_procedure,
|
||||
@@ -1287,6 +1390,7 @@ WITH vend AS (
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
parent_third_party_id,
|
||||
common_third_party_id,
|
||||
name,
|
||||
description,
|
||||
@@ -1308,7 +1412,7 @@ SELECT
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
show_on_trust_center,
|
||||
first_level,
|
||||
level,
|
||||
vetting_status,
|
||||
vetting_website_url,
|
||||
vetting_procedure,
|
||||
@@ -1355,6 +1459,7 @@ WITH vend AS (
|
||||
v.id,
|
||||
v.tenant_id,
|
||||
v.organization_id,
|
||||
v.parent_third_party_id,
|
||||
v.common_third_party_id,
|
||||
v.name,
|
||||
v.description,
|
||||
@@ -1376,7 +1481,7 @@ WITH vend AS (
|
||||
v.security_page_url,
|
||||
v.trust_page_url,
|
||||
v.show_on_trust_center,
|
||||
v.first_level,
|
||||
v.level,
|
||||
v.vetting_status,
|
||||
v.vetting_website_url,
|
||||
v.vetting_procedure,
|
||||
@@ -1394,6 +1499,7 @@ WITH vend AS (
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
parent_third_party_id,
|
||||
common_third_party_id,
|
||||
name,
|
||||
description,
|
||||
@@ -1415,7 +1521,7 @@ SELECT
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
show_on_trust_center,
|
||||
first_level,
|
||||
level,
|
||||
vetting_status,
|
||||
vetting_website_url,
|
||||
vetting_procedure,
|
||||
@@ -1530,6 +1636,7 @@ WITH vend AS (
|
||||
v.id,
|
||||
v.tenant_id,
|
||||
v.organization_id,
|
||||
v.parent_third_party_id,
|
||||
v.common_third_party_id,
|
||||
v.name,
|
||||
v.description,
|
||||
@@ -1551,7 +1658,7 @@ WITH vend AS (
|
||||
v.security_page_url,
|
||||
v.trust_page_url,
|
||||
v.show_on_trust_center,
|
||||
v.first_level,
|
||||
v.level,
|
||||
v.vetting_status,
|
||||
v.vetting_website_url,
|
||||
v.vetting_procedure,
|
||||
@@ -1569,6 +1676,7 @@ WITH vend AS (
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
parent_third_party_id,
|
||||
common_third_party_id,
|
||||
name,
|
||||
description,
|
||||
@@ -1590,7 +1698,7 @@ SELECT
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
show_on_trust_center,
|
||||
first_level,
|
||||
level,
|
||||
vetting_status,
|
||||
vetting_website_url,
|
||||
vetting_procedure,
|
||||
@@ -1634,6 +1742,7 @@ func (v *ThirdParty) LoadByOrganizationIDAndCommonThirdPartyID(
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
parent_third_party_id,
|
||||
common_third_party_id,
|
||||
name,
|
||||
description,
|
||||
@@ -1655,7 +1764,7 @@ SELECT
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
show_on_trust_center,
|
||||
first_level,
|
||||
level,
|
||||
vetting_status,
|
||||
vetting_website_url,
|
||||
vetting_procedure,
|
||||
@@ -1754,6 +1863,7 @@ WITH tps AS (
|
||||
v.id,
|
||||
v.tenant_id,
|
||||
v.organization_id,
|
||||
v.parent_third_party_id,
|
||||
v.common_third_party_id,
|
||||
v.name,
|
||||
v.description,
|
||||
@@ -1775,7 +1885,7 @@ WITH tps AS (
|
||||
v.security_page_url,
|
||||
v.trust_page_url,
|
||||
v.show_on_trust_center,
|
||||
v.first_level,
|
||||
v.level,
|
||||
v.vetting_status,
|
||||
v.vetting_website_url,
|
||||
v.vetting_procedure,
|
||||
@@ -1793,6 +1903,7 @@ WITH tps AS (
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
parent_third_party_id,
|
||||
common_third_party_id,
|
||||
name,
|
||||
description,
|
||||
@@ -1814,7 +1925,7 @@ SELECT
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
show_on_trust_center,
|
||||
first_level,
|
||||
level,
|
||||
vetting_status,
|
||||
vetting_website_url,
|
||||
vetting_procedure,
|
||||
@@ -1847,3 +1958,254 @@ WHERE %s
|
||||
|
||||
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 (
|
||||
ThirdPartyFilter struct {
|
||||
showOnTrustCenter *bool
|
||||
firstLevel *bool
|
||||
level *int
|
||||
query *string
|
||||
}
|
||||
)
|
||||
|
||||
func NewThirdPartyFilter(showOnTrustCenter *bool, firstLevel *bool, query *string) *ThirdPartyFilter {
|
||||
func NewThirdPartyFilter(showOnTrustCenter *bool, level *int, query *string) *ThirdPartyFilter {
|
||||
return &ThirdPartyFilter{
|
||||
showOnTrustCenter: showOnTrustCenter,
|
||||
firstLevel: firstLevel,
|
||||
level: level,
|
||||
query: query,
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ func (f *ThirdPartyFilter) SQLArguments() pgx.StrictNamedArgs {
|
||||
args := pgx.StrictNamedArgs{
|
||||
"show_on_trust_center": nil,
|
||||
"filter_query": nil,
|
||||
"level": nil,
|
||||
}
|
||||
|
||||
if f.showOnTrustCenter != nil {
|
||||
@@ -48,10 +49,8 @@ func (f *ThirdPartyFilter) SQLArguments() pgx.StrictNamedArgs {
|
||||
args["filter_query"] = *f.query
|
||||
}
|
||||
|
||||
if f.firstLevel != nil {
|
||||
args["first_level"] = *f.firstLevel
|
||||
} else {
|
||||
args["first_level"] = nil
|
||||
if f.level != nil {
|
||||
args["level"] = *f.level
|
||||
}
|
||||
|
||||
return args
|
||||
@@ -66,8 +65,8 @@ func (f *ThirdPartyFilter) SQLFragment() string {
|
||||
ELSE TRUE
|
||||
END
|
||||
AND CASE
|
||||
WHEN @first_level::boolean IS NOT NULL THEN
|
||||
first_level = @first_level::boolean
|
||||
WHEN @level::integer IS NOT NULL THEN
|
||||
level = @level::integer
|
||||
ELSE TRUE
|
||||
END
|
||||
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
|
||||
id,
|
||||
organization_id,
|
||||
parent_third_party_id,
|
||||
common_third_party_id,
|
||||
name,
|
||||
description,
|
||||
@@ -53,7 +54,7 @@ SELECT
|
||||
security_page_url,
|
||||
trust_page_url,
|
||||
show_on_trust_center,
|
||||
first_level,
|
||||
level,
|
||||
vetting_status,
|
||||
vetting_website_url,
|
||||
vetting_procedure,
|
||||
|
||||
@@ -767,6 +767,7 @@ func (s *OrganizationService) CreateOrganization(
|
||||
TermsOfServiceURL: &proboThirdParty.TermsOfServiceURL,
|
||||
SubprocessorsListURL: &proboThirdParty.SubprocessorsListURL,
|
||||
ShowOnTrustCenter: false,
|
||||
Level: 1,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
@@ -94,7 +94,6 @@ const (
|
||||
|
||||
// ThirdPartyRelation actions
|
||||
ActionThirdPartyRelationCreate = "core:thirdParty-relation:create"
|
||||
ActionThirdPartyRelationDelete = "core:thirdParty-relation:delete"
|
||||
ActionThirdPartyRelationList = "core:thirdParty-relation:list"
|
||||
|
||||
// ThirdPartyContact actions
|
||||
|
||||
@@ -2357,8 +2357,16 @@ func (s *GeneratedDocumentService) buildThirdPartyListDocumentData(
|
||||
conn pg.Querier,
|
||||
organization *coredata.Organization,
|
||||
) (docgen.ThirdPartyListData, error) {
|
||||
firstLevel := 1
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ type (
|
||||
StatusPageURL *string
|
||||
BusinessOwnerID *gid.GID
|
||||
SecurityOwnerID *gid.GID
|
||||
FirstLevel *bool
|
||||
ParentThirdPartyID *gid.GID
|
||||
}
|
||||
|
||||
UpdateThirdPartyRequest struct {
|
||||
@@ -79,7 +79,6 @@ type (
|
||||
BusinessOwnerID **gid.GID
|
||||
SecurityOwnerID **gid.GID
|
||||
ShowOnTrustCenter *bool
|
||||
FirstLevel *bool
|
||||
}
|
||||
|
||||
CreateThirdPartyRiskAssessmentRequest struct {
|
||||
@@ -398,10 +397,6 @@ func (s ThirdPartyService) Update(
|
||||
thirdParty.ShowOnTrustCenter = *req.ShowOnTrustCenter
|
||||
}
|
||||
|
||||
if req.FirstLevel != nil {
|
||||
thirdParty.FirstLevel = *req.FirstLevel
|
||||
}
|
||||
|
||||
if req.TrustPageURL != nil {
|
||||
thirdParty.TrustPageURL = *req.TrustPageURL
|
||||
}
|
||||
@@ -589,11 +584,7 @@ func (s ThirdPartyService) Create(
|
||||
StatusPageURL: req.StatusPageURL,
|
||||
TermsOfServiceURL: req.TermsOfServiceURL,
|
||||
ShowOnTrustCenter: false,
|
||||
FirstLevel: true,
|
||||
}
|
||||
|
||||
if req.FirstLevel != nil {
|
||||
thirdParty.FirstLevel = *req.FirstLevel
|
||||
Level: 1,
|
||||
}
|
||||
|
||||
err := s.svc.pg.WithTx(
|
||||
@@ -606,6 +597,29 @@ func (s ThirdPartyService) Create(
|
||||
|
||||
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 {
|
||||
businessOwner := &coredata.MembershipProfile{}
|
||||
if err := businessOwner.LoadByID(ctx, conn, scope, *req.BusinessOwnerID); err != nil {
|
||||
@@ -848,69 +862,24 @@ func (s ThirdPartyService) GetByRiskAssessmentID(
|
||||
return thirdParty, nil
|
||||
}
|
||||
|
||||
func (s ThirdPartyService) CreateThirdPartyMapping(
|
||||
func (s ThirdPartyService) GetAncestors(
|
||||
ctx context.Context,
|
||||
scope coredata.Scoper,
|
||||
parentThirdPartyID gid.GID,
|
||||
childThirdPartyID gid.GID,
|
||||
) (*coredata.ThirdParty, error) {
|
||||
childThirdParty := &coredata.ThirdParty{}
|
||||
thirdPartyID gid.GID,
|
||||
) (coredata.ThirdParties, error) {
|
||||
var ancestors coredata.ThirdParties
|
||||
|
||||
err := s.svc.pg.WithTx(
|
||||
err := s.svc.pg.WithConn(
|
||||
ctx,
|
||||
func(ctx context.Context, conn pg.Tx) error {
|
||||
parentThirdParty := &coredata.ThirdParty{}
|
||||
if err := parentThirdParty.LoadByID(ctx, conn, scope, parentThirdPartyID); err != nil {
|
||||
return fmt.Errorf("cannot load parent third party: %w", err)
|
||||
}
|
||||
|
||||
if err := childThirdParty.LoadByID(ctx, conn, scope, childThirdPartyID); err != nil {
|
||||
return fmt.Errorf("cannot load child third party: %w", err)
|
||||
}
|
||||
|
||||
if parentThirdParty.OrganizationID != childThirdParty.OrganizationID {
|
||||
return fmt.Errorf("cannot create mapping for third parties from different organizations: %w", coredata.ErrResourceNotFound)
|
||||
}
|
||||
|
||||
relation := &coredata.ThirdPartyThirdParty{
|
||||
ParentThirdPartyID: parentThirdPartyID,
|
||||
ChildThirdPartyID: childThirdPartyID,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := relation.Insert(ctx, conn, scope); err != nil {
|
||||
return fmt.Errorf("cannot create third party mapping: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
func(ctx context.Context, conn pg.Querier) error {
|
||||
return ancestors.LoadAllAncestorsByThirdPartyID(ctx, conn, scope, thirdPartyID)
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return childThirdParty, nil
|
||||
}
|
||||
|
||||
func (s ThirdPartyService) DeleteThirdPartyMapping(
|
||||
ctx context.Context,
|
||||
scope coredata.Scoper,
|
||||
parentThirdPartyID gid.GID,
|
||||
childThirdPartyID gid.GID,
|
||||
) error {
|
||||
return s.svc.pg.WithTx(
|
||||
ctx,
|
||||
func(ctx context.Context, conn pg.Tx) error {
|
||||
relation := &coredata.ThirdPartyThirdParty{
|
||||
ParentThirdPartyID: parentThirdPartyID,
|
||||
ChildThirdPartyID: childThirdPartyID,
|
||||
}
|
||||
if err := relation.Delete(ctx, conn, scope); err != nil {
|
||||
return fmt.Errorf("cannot delete third party mapping: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
return ancestors, nil
|
||||
}
|
||||
|
||||
func (s ThirdPartyService) CountForParentThirdPartyID(
|
||||
|
||||
@@ -198,7 +198,7 @@ input ThirdPartyOrder
|
||||
}
|
||||
|
||||
input ThirdPartyFilter {
|
||||
firstLevel: Boolean
|
||||
level: Int
|
||||
query: String
|
||||
}
|
||||
|
||||
@@ -303,7 +303,10 @@ type ThirdParty implements Node {
|
||||
legalName: String
|
||||
websiteUrl: String
|
||||
showOnTrustCenter: Boolean!
|
||||
firstLevel: Boolean!
|
||||
level: Int!
|
||||
|
||||
parentThirdParty: ThirdParty @goField(forceResolver: true)
|
||||
ancestors: [ThirdParty!]! @goField(forceResolver: true)
|
||||
|
||||
childThirdParties(
|
||||
first: Int
|
||||
@@ -506,12 +509,6 @@ extend type Mutation {
|
||||
publishThirdPartyList(
|
||||
input: PublishThirdPartyListInput!
|
||||
): PublishThirdPartyListPayload!
|
||||
createThirdPartyThirdPartyMapping(
|
||||
input: CreateThirdPartyThirdPartyMappingInput!
|
||||
): CreateThirdPartyThirdPartyMappingPayload!
|
||||
deleteThirdPartyThirdPartyMapping(
|
||||
input: DeleteThirdPartyThirdPartyMappingInput!
|
||||
): DeleteThirdPartyThirdPartyMappingPayload!
|
||||
}
|
||||
|
||||
input PublishThirdPartyListInput {
|
||||
@@ -546,7 +543,7 @@ input CreateThirdPartyInput {
|
||||
termsOfServiceUrl: String
|
||||
businessOwnerId: ID
|
||||
securityOwnerId: ID
|
||||
firstLevel: Boolean
|
||||
parentThirdPartyId: ID
|
||||
}
|
||||
|
||||
input UpdateThirdPartyInput {
|
||||
@@ -571,7 +568,6 @@ input UpdateThirdPartyInput {
|
||||
businessOwnerId: ID @goField(omittable: true)
|
||||
securityOwnerId: ID @goField(omittable: true)
|
||||
showOnTrustCenter: Boolean
|
||||
firstLevel: Boolean
|
||||
}
|
||||
|
||||
input DeleteThirdPartyInput {
|
||||
@@ -755,21 +751,3 @@ type CreateThirdPartyRiskAssessmentPayload {
|
||||
type VetThirdPartyPayload {
|
||||
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)
|
||||
|
||||
var (
|
||||
firstLevel *bool
|
||||
query *string
|
||||
level *int
|
||||
query *string
|
||||
)
|
||||
if filter != nil {
|
||||
firstLevel = filter.FirstLevel
|
||||
level = filter.Level
|
||||
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)
|
||||
if err != nil {
|
||||
|
||||
@@ -33,6 +33,12 @@ func (r *mutationResolver) CreateThirdParty(ctx context.Context, input types.Cre
|
||||
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(
|
||||
ctx, scope,
|
||||
probo.CreateThirdPartyRequest{
|
||||
@@ -56,7 +62,7 @@ func (r *mutationResolver) CreateThirdParty(ctx context.Context, input types.Cre
|
||||
BusinessOwnerID: input.BusinessOwnerID,
|
||||
SecurityOwnerID: input.SecurityOwnerID,
|
||||
Countries: input.Countries,
|
||||
FirstLevel: input.FirstLevel,
|
||||
ParentThirdPartyID: input.ParentThirdPartyID,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
@@ -108,7 +114,6 @@ func (r *mutationResolver) UpdateThirdParty(ctx context.Context, input types.Upd
|
||||
BusinessOwnerID: gqlutils.UnwrapOmittable(input.BusinessOwnerID),
|
||||
SecurityOwnerID: gqlutils.UnwrapOmittable(input.SecurityOwnerID),
|
||||
ShowOnTrustCenter: input.ShowOnTrustCenter,
|
||||
FirstLevel: input.FirstLevel,
|
||||
Countries: input.Countries,
|
||||
},
|
||||
)
|
||||
@@ -603,46 +608,6 @@ func (r *mutationResolver) PublishThirdPartyList(ctx context.Context, input type
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateThirdPartyThirdPartyMapping is the resolver for the linkThirdPartyThirdParty field.
|
||||
func (r *mutationResolver) CreateThirdPartyThirdPartyMapping(ctx context.Context, input types.CreateThirdPartyThirdPartyMappingInput) (*types.CreateThirdPartyThirdPartyMappingPayload, error) {
|
||||
scope, err := r.authorize(ctx, input.ParentThirdPartyID, probo.ActionThirdPartyRelationCreate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
childThirdParty, err := r.probo.ThirdParties.CreateThirdPartyMapping(ctx, scope, input.ParentThirdPartyID, input.ChildThirdPartyID)
|
||||
if err != nil {
|
||||
if errors.Is(err, coredata.ErrResourceNotFound) {
|
||||
return nil, gqlutils.NotFound(ctx, err)
|
||||
}
|
||||
|
||||
r.logger.ErrorCtx(ctx, "cannot create third party mapping", log.Error(err))
|
||||
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
return &types.CreateThirdPartyThirdPartyMappingPayload{
|
||||
ThirdPartyEdge: types.NewThirdPartyEdge(childThirdParty, coredata.ThirdPartyOrderFieldName),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeleteThirdPartyThirdPartyMapping is the resolver for the deleteThirdPartyThirdPartyMapping field.
|
||||
func (r *mutationResolver) DeleteThirdPartyThirdPartyMapping(ctx context.Context, input types.DeleteThirdPartyThirdPartyMappingInput) (*types.DeleteThirdPartyThirdPartyMappingPayload, error) {
|
||||
scope, err := r.authorize(ctx, input.ParentThirdPartyID, probo.ActionThirdPartyRelationDelete)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := r.probo.ThirdParties.DeleteThirdPartyMapping(ctx, scope, input.ParentThirdPartyID, input.ChildThirdPartyID); err != nil {
|
||||
r.logger.ErrorCtx(ctx, "cannot delete third party mapping", log.Error(err))
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
return &types.DeleteThirdPartyThirdPartyMappingPayload{
|
||||
RemovedThirdPartyID: input.ChildThirdPartyID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Organization is the resolver for the organization field.
|
||||
func (r *thirdPartyResolver) Organization(ctx context.Context, obj *types.ThirdParty) (*types.Organization, error) {
|
||||
if _, err := r.authorize(ctx, obj.ID, probo.ActionOrganizationGet); err != nil {
|
||||
@@ -913,6 +878,53 @@ func (r *thirdPartyResolver) SecurityOwner(ctx context.Context, obj *types.Third
|
||||
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.
|
||||
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)
|
||||
|
||||
@@ -86,7 +86,7 @@ func NewThirdParty(v *coredata.ThirdParty) *ThirdParty {
|
||||
WebsiteURL: v.WebsiteURL,
|
||||
Category: v.Category,
|
||||
ShowOnTrustCenter: v.ShowOnTrustCenter,
|
||||
FirstLevel: v.FirstLevel,
|
||||
Level: v.Level,
|
||||
Countries: v.Countries,
|
||||
UpdatedAt: v.UpdatedAt,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ func (r *Resolver) ListThirdPartiesTool(ctx context.Context, req *mcp.CallToolRe
|
||||
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
func (r *Resolver) CreateThirdPartyThirdPartyMappingTool(ctx context.Context, req *mcp.CallToolRequest, input *types.CreateThirdPartyThirdPartyMappingInput) (*mcp.CallToolResult, types.CreateThirdPartyThirdPartyMappingOutput, error) {
|
||||
scope, err := r.Authorize(ctx, input.ParentThirdPartyID, probo.ActionThirdPartyRelationCreate)
|
||||
if err != nil {
|
||||
return nil, types.CreateThirdPartyThirdPartyMappingOutput{}, err
|
||||
}
|
||||
|
||||
if _, err := r.proboSvc.ThirdParties.CreateThirdPartyMapping(ctx, scope, input.ParentThirdPartyID, input.ChildThirdPartyID); err != nil {
|
||||
return nil, types.CreateThirdPartyThirdPartyMappingOutput{}, fmt.Errorf("cannot create third party mapping: %w", err)
|
||||
}
|
||||
|
||||
return nil, types.CreateThirdPartyThirdPartyMappingOutput{}, nil
|
||||
}
|
||||
|
||||
func (r *Resolver) DeleteThirdPartyThirdPartyMappingTool(ctx context.Context, req *mcp.CallToolRequest, input *types.DeleteThirdPartyThirdPartyMappingInput) (*mcp.CallToolResult, types.DeleteThirdPartyThirdPartyMappingOutput, error) {
|
||||
scope, err := r.Authorize(ctx, input.ParentThirdPartyID, probo.ActionThirdPartyRelationDelete)
|
||||
if err != nil {
|
||||
return nil, types.DeleteThirdPartyThirdPartyMappingOutput{}, err
|
||||
}
|
||||
|
||||
if err := r.proboSvc.ThirdParties.DeleteThirdPartyMapping(ctx, scope, input.ParentThirdPartyID, input.ChildThirdPartyID); err != nil {
|
||||
return nil, types.DeleteThirdPartyThirdPartyMappingOutput{}, fmt.Errorf("cannot delete third party mapping: %w", err)
|
||||
}
|
||||
|
||||
return nil, types.DeleteThirdPartyThirdPartyMappingOutput{}, nil
|
||||
}
|
||||
|
||||
func (r *Resolver) ListChildThirdPartiesTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListChildThirdPartiesInput) (*mcp.CallToolResult, types.ListChildThirdPartiesOutput, error) {
|
||||
scope, err := r.Authorize(ctx, input.ParentThirdPartyID, probo.ActionThirdPartyRelationList)
|
||||
if err != nil {
|
||||
|
||||
@@ -641,9 +641,9 @@ components:
|
||||
organization_id:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Organization ID
|
||||
first_level:
|
||||
type: boolean
|
||||
description: Filter by first-level status
|
||||
level:
|
||||
type: integer
|
||||
description: Filter by level
|
||||
order_by:
|
||||
$ref: "#/components/schemas/ThirdPartyOrderBy"
|
||||
description: ThirdParty order by
|
||||
@@ -667,38 +667,6 @@ components:
|
||||
items:
|
||||
$ref: "#/components/schemas/ThirdParty"
|
||||
|
||||
CreateThirdPartyThirdPartyMappingInput:
|
||||
type: object
|
||||
required:
|
||||
- parent_third_party_id
|
||||
- child_third_party_id
|
||||
properties:
|
||||
parent_third_party_id:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Parent third party ID
|
||||
child_third_party_id:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Child third party ID
|
||||
|
||||
CreateThirdPartyThirdPartyMappingOutput:
|
||||
type: object
|
||||
|
||||
DeleteThirdPartyThirdPartyMappingInput:
|
||||
type: object
|
||||
required:
|
||||
- parent_third_party_id
|
||||
- child_third_party_id
|
||||
properties:
|
||||
parent_third_party_id:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Parent third party ID
|
||||
child_third_party_id:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Child third party ID
|
||||
|
||||
DeleteThirdPartyThirdPartyMappingOutput:
|
||||
type: object
|
||||
|
||||
ListChildThirdPartiesInput:
|
||||
type: object
|
||||
required:
|
||||
@@ -737,7 +705,7 @@ components:
|
||||
- name
|
||||
- organization_id
|
||||
- category
|
||||
- first_level
|
||||
- level
|
||||
- created_at
|
||||
- updated_at
|
||||
properties:
|
||||
@@ -861,9 +829,9 @@ components:
|
||||
- string
|
||||
- "null"
|
||||
description: Trust page URL
|
||||
first_level:
|
||||
type: boolean
|
||||
description: Whether this is a first-level third party
|
||||
level:
|
||||
type: integer
|
||||
description: Level of this third party (1 = direct, 2+ = indirect)
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -12055,22 +12023,6 @@ tools:
|
||||
$ref: "#/components/schemas/ListThirdPartiesInput"
|
||||
outputSchema:
|
||||
$ref: "#/components/schemas/ListThirdPartiesOutput"
|
||||
- name: createThirdPartyThirdPartyMapping
|
||||
description: Link a child third party to a parent third party
|
||||
hints:
|
||||
readonly: false
|
||||
inputSchema:
|
||||
$ref: "#/components/schemas/CreateThirdPartyThirdPartyMappingInput"
|
||||
outputSchema:
|
||||
$ref: "#/components/schemas/CreateThirdPartyThirdPartyMappingOutput"
|
||||
- name: deleteThirdPartyThirdPartyMapping
|
||||
description: Unlink a child third party from a parent third party
|
||||
hints:
|
||||
readonly: false
|
||||
inputSchema:
|
||||
$ref: "#/components/schemas/DeleteThirdPartyThirdPartyMappingInput"
|
||||
outputSchema:
|
||||
$ref: "#/components/schemas/DeleteThirdPartyThirdPartyMappingOutput"
|
||||
- name: listChildThirdParties
|
||||
description: List child third parties linked to a parent third party
|
||||
hints:
|
||||
|
||||
@@ -86,7 +86,7 @@ func NewThirdParty(v *coredata.ThirdParty) *ThirdParty {
|
||||
TermsOfServiceURL: v.TermsOfServiceURL,
|
||||
SecurityPageURL: v.SecurityPageURL,
|
||||
TrustPageURL: v.TrustPageURL,
|
||||
FirstLevel: v.FirstLevel,
|
||||
Level: v.Level,
|
||||
CreatedAt: v.CreatedAt,
|
||||
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
|
||||
// row (name, category, addresses, URLs, certifications, …). The new row
|
||||
// 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
|
||||
// organization's cookie banner, which makes it a first-level third
|
||||
// party by definition.
|
||||
@@ -259,7 +259,7 @@ func CreateFromCommon(
|
||||
SecurityPageURL: commonParty.SecurityPageURL,
|
||||
TrustPageURL: commonParty.TrustPageURL,
|
||||
ShowOnTrustCenter: false,
|
||||
FirstLevel: true,
|
||||
Level: 1,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -26,6 +27,11 @@ import (
|
||||
"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 (
|
||||
vettingRiskAssessmentValidity = 365 * 24 * time.Hour
|
||||
maxVettingNotesGaps = 5
|
||||
@@ -51,13 +57,28 @@ func PersistAssessmentResult(
|
||||
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()
|
||||
|
||||
if err := thirdParty.Update(ctx, conn, scope); err != nil {
|
||||
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 {
|
||||
if sub.Name == "" {
|
||||
continue
|
||||
@@ -68,6 +89,8 @@ func PersistAssessmentResult(
|
||||
conn,
|
||||
scope,
|
||||
pc,
|
||||
thirdParty.Level,
|
||||
childNamePath,
|
||||
linkSubThirdPartyParams{
|
||||
Name: sub.Name,
|
||||
Country: sub.Country,
|
||||
@@ -296,9 +319,12 @@ func applySaveParams(
|
||||
thirdParty *coredata.ThirdParty,
|
||||
websiteURL string,
|
||||
p saveThirdPartyInfoParams,
|
||||
nameSuffixPath []string,
|
||||
) {
|
||||
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
|
||||
@@ -371,78 +397,122 @@ func linkSubThirdParty(
|
||||
conn pg.Tx,
|
||||
scope coredata.Scoper,
|
||||
pc *PersistenceContext,
|
||||
parentLevel int,
|
||||
parentNamePath []string,
|
||||
p linkSubThirdPartyParams,
|
||||
) error {
|
||||
if p.Name == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
child := &coredata.ThirdParty{}
|
||||
|
||||
err := child.LoadByNameAndOrganizationID(ctx, conn, scope, p.Name, pc.OrganizationID)
|
||||
if err != nil {
|
||||
if !errors.Is(err, coredata.ErrResourceNotFound) {
|
||||
return fmt.Errorf("cannot find child third party %q: %w", p.Name, err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
child = &coredata.ThirdParty{
|
||||
ID: gid.New(scope.GetTenantID(), coredata.ThirdPartyEntityType),
|
||||
OrganizationID: pc.OrganizationID,
|
||||
Name: p.Name,
|
||||
Category: coredata.ThirdPartyCategoryOther,
|
||||
FirstLevel: false,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if p.Description != "" {
|
||||
child.Description = &p.Description
|
||||
}
|
||||
|
||||
if p.Category != "" {
|
||||
if category, err := parseThirdPartyCategory(p.Category); err == nil {
|
||||
child.Category = category
|
||||
}
|
||||
}
|
||||
|
||||
if p.WebsiteURL != "" {
|
||||
child.WebsiteURL = &p.WebsiteURL
|
||||
}
|
||||
|
||||
if countries := parseOptionalCountryCodes(p.Country); len(countries) > 0 {
|
||||
child.Countries = countries
|
||||
}
|
||||
|
||||
if err := child.Insert(ctx, conn, scope); err != nil {
|
||||
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 {
|
||||
// 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
|
||||
}
|
||||
|
||||
relation := &coredata.ThirdPartyThirdParty{
|
||||
ParentThirdPartyID: pc.ThirdPartyID,
|
||||
ChildThirdPartyID: child.ID,
|
||||
CreatedAt: time.Now(),
|
||||
// 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{}
|
||||
|
||||
// Sub-third-parties are scoped per parent, so a child is matched by name
|
||||
// within this parent only — a same-named third party under a different
|
||||
// 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)
|
||||
}
|
||||
|
||||
if p.Purpose != "" {
|
||||
relation.Purpose = &p.Purpose
|
||||
now := time.Now()
|
||||
parentID := pc.ThirdPartyID
|
||||
child = &coredata.ThirdParty{
|
||||
ID: gid.New(scope.GetTenantID(), coredata.ThirdPartyEntityType),
|
||||
OrganizationID: pc.OrganizationID,
|
||||
ParentThirdPartyID: &parentID,
|
||||
Name: qualifiedName,
|
||||
Category: coredata.ThirdPartyCategoryOther,
|
||||
Level: parentLevel + 1,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := relation.Insert(ctx, conn, scope); err != nil {
|
||||
return fmt.Errorf("cannot insert third party relation: %w", err)
|
||||
if p.Description != "" {
|
||||
child.Description = &p.Description
|
||||
}
|
||||
|
||||
if p.Category != "" {
|
||||
if category, err := parseThirdPartyCategory(p.Category); err == nil {
|
||||
child.Category = category
|
||||
}
|
||||
}
|
||||
|
||||
if p.WebsiteURL != "" {
|
||||
child.WebsiteURL = &p.WebsiteURL
|
||||
}
|
||||
|
||||
if countries := parseOptionalCountryCodes(p.Country); len(countries) > 0 {
|
||||
child.Countries = countries
|
||||
}
|
||||
|
||||
if err := child.Insert(ctx, conn, scope); err != nil {
|
||||
return fmt.Errorf("cannot create child third party %q: %w", p.Name, err)
|
||||
}
|
||||
|
||||
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{
|
||||
saveThirdPartyInfoToolParams: p,
|
||||
})
|
||||
}, ancestorBaseNames)
|
||||
thirdParty.UpdatedAt = time.Now()
|
||||
|
||||
if err := thirdParty.Update(ctx, conn, scope); err != nil {
|
||||
@@ -124,7 +129,20 @@ func LinkSubThirdPartyTool(pc *PersistenceContext) agent.Tool {
|
||||
err := pc.PG.WithTx(
|
||||
ctx,
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user