From 6dfdd7ca49cb8668df1b4cd4abee6f68d17b8000 Mon Sep 17 00:00:00 2001 From: Sacha Al Himdani Date: Fri, 22 May 2026 16:07:48 +0200 Subject: [PATCH] Link measures to third parties Add a many-to-many relationship between measures and third parties, surfaced as a measures tab on the third party detail page and a third parties tab on the measure detail page. Each side gets a paginated list with a link/unlink dialog. Also remove the right-hand drawer on the measure detail page and expose the state as a badge in the page header, mirroring how the compliance page surfaces its active flag. Signed-off-by: Sacha Al Himdani --- .../src/hooks/graph/ThirdPartyGraph.ts | 10 +- .../measures/MeasureDetailPage.tsx | 24 +- .../_components/LinkedThirdPartiesCard.tsx | 182 ++++++++++++++ .../_components/LinkedThirdPartiesDialog.tsx | 232 +++++++++++++++++ .../third-parties/MeasureThirdPartiesPage.tsx | 118 +++++++++ .../third-parties/ThirdPartyDetailPage.tsx | 8 + .../measures/ThirdPartyMeasuresPage.tsx | 117 +++++++++ apps/console/src/routes/measureRoutes.ts | 8 + apps/console/src/routes/thirdPartyRoutes.ts | 7 + e2e/console/measure_test.go | 237 ++++++++++++++++++ .../nodes/Probo/actions/measure/index.ts | 18 ++ .../measure/linkThirdParty.operation.ts | 81 ++++++ .../measure/unlinkThirdParty.operation.ts | 71 ++++++ .../link-third-party/link_third_party.go | 97 +++++++ pkg/cmd/measure/measure.go | 4 + .../unlink-third-party/unlink_third_party.go | 93 +++++++ pkg/coredata/measure.go | 112 +++++++++ pkg/coredata/measure_third_party.go | 114 +++++++++ pkg/coredata/migrations/20260522T120000Z.sql | 22 ++ pkg/coredata/third_party.go | 138 ++++++++++ pkg/coredata/third_party_filter.go | 25 +- pkg/probo/actions.go | 20 +- pkg/probo/measure_service.go | 132 ++++++++++ pkg/probo/third_party_service.go | 68 ++++- pkg/server/api/console/v1/asset_resolvers.go | 4 +- .../api/console/v1/graphql/measure.graphql | 34 +++ .../console/v1/graphql/third_party.graphql | 10 + .../api/console/v1/measure_resolvers.go | 75 ++++++ .../api/console/v1/organization_resolvers.go | 10 +- .../v1/processing_activity_resolvers.go | 2 +- .../api/console/v1/third_party_resolvers.go | 57 +++-- .../api/console/v1/types/third_party.go | 3 + pkg/server/api/mcp/v1/schema.resolvers.go | 18 +- pkg/server/api/mcp/v1/specification.yaml | 8 +- pkg/trust/third_party_service.go | 4 +- 35 files changed, 2109 insertions(+), 54 deletions(-) create mode 100644 apps/console/src/pages/organizations/measures/_components/LinkedThirdPartiesCard.tsx create mode 100644 apps/console/src/pages/organizations/measures/_components/LinkedThirdPartiesDialog.tsx create mode 100644 apps/console/src/pages/organizations/measures/third-parties/MeasureThirdPartiesPage.tsx create mode 100644 apps/console/src/pages/organizations/third-parties/measures/ThirdPartyMeasuresPage.tsx create mode 100644 packages/n8n-node/nodes/Probo/actions/measure/linkThirdParty.operation.ts create mode 100644 packages/n8n-node/nodes/Probo/actions/measure/unlinkThirdParty.operation.ts create mode 100644 pkg/cmd/measure/link-third-party/link_third_party.go create mode 100644 pkg/cmd/measure/unlink-third-party/unlink_third_party.go create mode 100644 pkg/coredata/measure_third_party.go create mode 100644 pkg/coredata/migrations/20260522T120000Z.sql diff --git a/apps/console/src/hooks/graph/ThirdPartyGraph.ts b/apps/console/src/hooks/graph/ThirdPartyGraph.ts index 32cee1d21..6951b3191 100644 --- a/apps/console/src/hooks/graph/ThirdPartyGraph.ts +++ b/apps/console/src/hooks/graph/ThirdPartyGraph.ts @@ -202,6 +202,9 @@ export const thirdPartyNodeQuery = graphql` canUploadDPA: permission( action: "core:thirdParty-data-privacy-agreement:upload" ) + measuresInfos: measures(first: 0) { + totalCount + } ...useThirdPartyFormFragment ...ThirdPartyComplianceTabFragment ...ThirdPartyContactsTabFragment @@ -209,6 +212,7 @@ export const thirdPartyNodeQuery = graphql` ...ThirdPartyRiskAssessmentTabFragment ...ThirdPartyOverviewTabBusinessAssociateAgreementFragment ...ThirdPartyOverviewTabDataPrivacyAgreementFragment + ...ThirdPartyMeasuresPageFragment } } viewer { @@ -221,7 +225,11 @@ export const thirdPartiesSelectQuery = graphql` query ThirdPartyGraphSelectQuery($organizationId: ID!) { organization: node(id: $organizationId) { ... on Organization { - thirdParties(first: 100, orderBy: { direction: ASC, field: NAME }) { + thirdParties( + first: 100 + orderBy: { direction: ASC, field: NAME } + filter: { firstLevel: true } + ) { edges { node { id diff --git a/apps/console/src/pages/organizations/measures/MeasureDetailPage.tsx b/apps/console/src/pages/organizations/measures/MeasureDetailPage.tsx index 1a817f435..5ea7d1798 100644 --- a/apps/console/src/pages/organizations/measures/MeasureDetailPage.tsx +++ b/apps/console/src/pages/organizations/measures/MeasureDetailPage.tsx @@ -22,18 +22,17 @@ import { ActionDropdown, Breadcrumb, Button, - Drawer, DropdownItem, IconCheckmark1, IconFrame2, IconPageCheck, IconPageTextLine, IconPencil, + IconStore, IconTrashCan, IconWarning, Option, PageHeader, - PropertyRow, Select, TabBadge, TabLink, @@ -65,11 +64,13 @@ import { controlsFragment } from "./tabs/MeasureControlsTab"; import { documentsFragment } from "./tabs/MeasureDocumentsTab"; import { evidencesFragment } from "./tabs/MeasureEvidencesTab"; import { risksFragment } from "./tabs/MeasureRisksTab"; +import { thirdPartiesFragment } from "./third-parties/MeasureThirdPartiesPage"; void controlsFragment; void documentsFragment; void evidencesFragment; void risksFragment; +void thirdPartiesFragment; export const measureNodeQuery = graphql` query MeasureDetailPageNodeQuery($measureId: ID!) { @@ -94,11 +95,15 @@ export const measureNodeQuery = graphql` documentsInfos: documents(first: 0) { totalCount } + thirdPartiesInfos: thirdParties(first: 0) { + totalCount + } ...MeasureRisksTabFragment ...MeasureControlsTabFragment ...MeasureDocumentsTabFragment ...MeasureFormDialogMeasureFragment ...MeasureEvidencesTabFragment + ...MeasureThirdPartiesPageFragment } } } @@ -149,6 +154,7 @@ export default function MeasureDetailPage(props: Props) { const controlsCount = measure.controlsInfos?.totalCount ?? 0; const risksCount = measure.risksInfos?.totalCount ?? 0; const documentsCount = measure.documentsInfos?.totalCount ?? 0; + const thirdPartiesCount = measure.thirdPartiesInfos?.totalCount ?? 0; const onDelete = () => { const connectionId = ConnectionHandler.getConnectionID( @@ -215,6 +221,7 @@ export default function MeasureDetailPage(props: Props) { /> + {!measure.canUpdate && } {measure.canUpdate && ( <> @@ -291,15 +298,16 @@ export default function MeasureDetailPage(props: Props) { {__("Documents")} {documentsCount} + + + {__("Third parties")} + {thirdPartiesCount} + - - - - - - ); } diff --git a/apps/console/src/pages/organizations/measures/_components/LinkedThirdPartiesCard.tsx b/apps/console/src/pages/organizations/measures/_components/LinkedThirdPartiesCard.tsx new file mode 100644 index 000000000..7b121132e --- /dev/null +++ b/apps/console/src/pages/organizations/measures/_components/LinkedThirdPartiesCard.tsx @@ -0,0 +1,182 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import { faviconUrl } from "@probo/helpers"; +import { useTranslate } from "@probo/i18n"; +import { + Badge, + Button, + IconPlusLarge, + IconTrashCan, + Table, + Tbody, + Td, + Th, + Thead, + Tr, + TrButton, +} from "@probo/ui"; +import { useFragment } from "react-relay"; +import { graphql } from "relay-runtime"; + +import type { LinkedThirdPartiesCardFragment$key } from "#/__generated__/core/LinkedThirdPartiesCardFragment.graphql"; +import { useOrganizationId } from "#/hooks/useOrganizationId"; + +import { LinkedThirdPartiesDialog } from "./LinkedThirdPartiesDialog"; + +const linkedThirdPartyFragment = graphql` + fragment LinkedThirdPartiesCardFragment on ThirdParty { + id + name + category + websiteUrl + } +`; + +type Mutation = (p: { + variables: { + input: { + thirdPartyId: string; + } & Params; + connections: string[]; + }; +}) => void; + +type Props = { + thirdParties: (LinkedThirdPartiesCardFragment$key & { id: string })[]; + params: Params; + disabled?: boolean; + connectionId: string; + onAttach: Mutation; + onDetach: Mutation; + readOnly?: boolean; +}; + +export function LinkedThirdPartiesCard(props: Props) { + const { __ } = useTranslate(); + const thirdParties = props.thirdParties; + + const onAttach = (thirdPartyId: string) => { + props.onAttach({ + variables: { + input: { + thirdPartyId, + ...props.params, + }, + connections: [props.connectionId], + }, + }); + }; + + const onDetach = (thirdPartyId: string) => { + props.onDetach({ + variables: { + input: { + thirdPartyId, + ...props.params, + }, + connections: [props.connectionId], + }, + }); + }; + + return ( + + + + + + {!props.readOnly && } + + + + {thirdParties.length === 0 && ( + + + + )} + {thirdParties.map(thirdParty => ( + + ))} + {!props.readOnly && ( + + + {__("Link third party")} + + + )} + +
{__("Name")}{__("Category")}
+ {__("No third parties linked")} +
+ ); +} + +function ThirdPartyRow(props: { + thirdParty: LinkedThirdPartiesCardFragment$key & { id: string }; + onClick: (thirdPartyId: string) => void; + readOnly?: boolean; +}) { + const thirdParty = useFragment(linkedThirdPartyFragment, props.thirdParty); + const organizationId = useOrganizationId(); + const { __ } = useTranslate(); + const logo = faviconUrl(thirdParty.websiteUrl); + + return ( + + + + {logo && ( + {thirdParty.name} + )} + {thirdParty.name} + + + + {thirdParty.category} + + {!props.readOnly && ( + + + + )} + + ); +} diff --git a/apps/console/src/pages/organizations/measures/_components/LinkedThirdPartiesDialog.tsx b/apps/console/src/pages/organizations/measures/_components/LinkedThirdPartiesDialog.tsx new file mode 100644 index 000000000..e1b111140 --- /dev/null +++ b/apps/console/src/pages/organizations/measures/_components/LinkedThirdPartiesDialog.tsx @@ -0,0 +1,232 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import { faviconUrl } from "@probo/helpers"; +import { useTranslate } from "@probo/i18n"; +import { + Badge, + Button, + Dialog, + DialogContent, + IconMagnifyingGlass, + IconPlusLarge, + IconTrashCan, + InfiniteScrollTrigger, + Input, + Spinner, +} from "@probo/ui"; +import { + type ReactNode, + type RefObject, + Suspense, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useLazyLoadQuery, usePaginationFragment } from "react-relay"; +import { graphql } from "relay-runtime"; +import { useDebounceCallback } from "usehooks-ts"; + +import type { + LinkedThirdPartiesDialogFragment$data, + LinkedThirdPartiesDialogFragment$key, +} from "#/__generated__/core/LinkedThirdPartiesDialogFragment.graphql"; +import type { LinkedThirdPartiesDialogQuery } from "#/__generated__/core/LinkedThirdPartiesDialogQuery.graphql"; +import type { LinkedThirdPartiesDialogRefetchQuery } from "#/__generated__/core/LinkedThirdPartiesDialogRefetchQuery.graphql"; +import { useOrganizationId } from "#/hooks/useOrganizationId"; +import type { NodeOf } from "#/types"; + +const query = graphql` + query LinkedThirdPartiesDialogQuery($organizationId: ID!) { + organization: node(id: $organizationId) { + id + ...LinkedThirdPartiesDialogFragment + } + } +`; + +const thirdPartiesFragment = graphql` + fragment LinkedThirdPartiesDialogFragment on Organization + @argumentDefinitions( + first: { type: "Int", defaultValue: 20 } + after: { type: "CursorKey" } + last: { type: "Int", defaultValue: null } + before: { type: "CursorKey", defaultValue: null } + order: { type: "ThirdPartyOrder", defaultValue: null } + filter: { type: "ThirdPartyFilter", defaultValue: { firstLevel: true } } + ) + @refetchable(queryName: "LinkedThirdPartiesDialogRefetchQuery") { + thirdParties( + first: $first + after: $after + last: $last + before: $before + orderBy: $order + filter: $filter + ) @connection(key: "LinkedThirdPartiesDialogRefetchQuery_thirdParties", filters: ["filter"]) { + edges { + node { + id + name + category + websiteUrl + } + } + } + } +`; + +type Props = { + children: ReactNode; + connectionId: string; + disabled?: boolean; + linkedThirdParties?: { id: string }[]; + onLink: (thirdPartyId: string) => void; + onUnlink: (thirdPartyId: string) => void; +}; + +type SearchRef = RefObject<{ search: (v: string) => void } | null>; + +export function LinkedThirdPartiesDialog(props: Props) { + const { __ } = useTranslate(); + const searchRef: SearchRef = useRef(null); + const contentRef = useRef(null); + const [minHeight, setMinHeight] = useState(0); + const onSearch = (v: string) => { + setMinHeight(contentRef.current?.clientHeight ?? 0); + searchRef.current?.search(v); + }; + return ( + + +
+ +
+
+ + +
+ )} + > + + + +
+
+ ); +} + +function LinkedThirdPartiesDialogContent({ + ref: searchRef, + ...props +}: Props & { ref: SearchRef }) { + const organizationId = useOrganizationId(); + const mainData = useLazyLoadQuery(query, { + organizationId, + }); + const { data, loadNext, hasNext, isLoadingNext, refetch } = usePaginationFragment< + LinkedThirdPartiesDialogRefetchQuery, + LinkedThirdPartiesDialogFragment$key + >( + thirdPartiesFragment, + mainData.organization as LinkedThirdPartiesDialogFragment$key, + ); + + const thirdParties = data.thirdParties?.edges?.map(edge => edge.node) ?? []; + const linkedIds = useMemo(() => { + return new Set(props.linkedThirdParties?.map(t => t.id) ?? []); + }, [props.linkedThirdParties]); + + const handleSearch = useDebounceCallback((v: string) => { + refetch({ + first: 20, + filter: { + firstLevel: true, + query: v, + }, + }); + }, 500); + + useEffect(() => { + searchRef.current = { search: handleSearch }; + return () => { + searchRef.current = null; + }; + }, [handleSearch, searchRef]); + + return ( +
+ {thirdParties.map(thirdParty => ( + + ))} + {hasNext && ( + loadNext(20)} + /> + )} +
+ ); +} + +function ThirdPartyRow( + props: { + thirdParty: NodeOf; + linkedIds: Set; + } & Props, +) { + const { __ } = useTranslate(); + const isLinked = props.linkedIds.has(props.thirdParty.id); + const onClick = isLinked ? props.onUnlink : props.onLink; + const IconComponent = isLinked ? IconTrashCan : IconPlusLarge; + const logo = faviconUrl(props.thirdParty.websiteUrl); + return ( + + + ); +} diff --git a/apps/console/src/pages/organizations/measures/third-parties/MeasureThirdPartiesPage.tsx b/apps/console/src/pages/organizations/measures/third-parties/MeasureThirdPartiesPage.tsx new file mode 100644 index 000000000..e5731f36d --- /dev/null +++ b/apps/console/src/pages/organizations/measures/third-parties/MeasureThirdPartiesPage.tsx @@ -0,0 +1,118 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import { graphql, useFragment } from "react-relay"; +import { useOutletContext, useParams } from "react-router"; + +import type { MeasureThirdPartiesPageFragment$key } from "#/__generated__/core/MeasureThirdPartiesPageFragment.graphql"; +import { useMutationWithIncrement } from "#/hooks/useMutationWithIncrement"; + +import { LinkedThirdPartiesCard } from "../_components/LinkedThirdPartiesCard"; + +export const thirdPartiesFragment = graphql` + fragment MeasureThirdPartiesPageFragment on Measure { + id + canCreateMeasureThirdPartyMapping: permission( + action: "core:measure:create-third-party-mapping" + ) + canDeleteMeasureThirdPartyMapping: permission( + action: "core:measure:delete-third-party-mapping" + ) + thirdParties(first: 100) @connection(key: "MeasureThirdPartiesPage_thirdParties") { + __id + edges { + node { + id + ...LinkedThirdPartiesCardFragment + } + } + } + } +`; + +const attachThirdPartyMutation = graphql` + mutation MeasureThirdPartiesPageAttachMutation( + $input: CreateMeasureThirdPartyMappingInput! + $connections: [ID!]! + ) { + createMeasureThirdPartyMapping(input: $input) { + thirdPartyEdge @prependEdge(connections: $connections) { + node { + id + ...LinkedThirdPartiesCardFragment + } + } + } + } +`; + +const detachThirdPartyMutation = graphql` + mutation MeasureThirdPartiesPageDetachMutation( + $input: DeleteMeasureThirdPartyMappingInput! + $connections: [ID!]! + ) { + deleteMeasureThirdPartyMapping(input: $input) { + deletedThirdPartyId @deleteEdge(connections: $connections) + } + } +`; + +export default function MeasureThirdPartiesPage() { + const { measureId } = useParams<{ measureId: string }>(); + if (!measureId) { + throw new Error("Missing :measureId param in route"); + } + const { measure } = useOutletContext<{ + measure: MeasureThirdPartiesPageFragment$key; + }>(); + const data = useFragment(thirdPartiesFragment, measure); + const connectionId = data.thirdParties.__id; + const thirdParties = data.thirdParties?.edges?.map(edge => edge.node) ?? []; + + const canLink = data.canCreateMeasureThirdPartyMapping; + const canUnlink = data.canDeleteMeasureThirdPartyMapping; + const readOnly = !canLink && !canUnlink; + + const incrementOptions = { + id: data.id, + node: "thirdParties(first:0)", + }; + const [detachThirdParty, isDetaching] = useMutationWithIncrement( + detachThirdPartyMutation, + { + ...incrementOptions, + value: -1, + }, + ); + const [attachThirdParty, isAttaching] = useMutationWithIncrement( + attachThirdPartyMutation, + { + ...incrementOptions, + value: 1, + }, + ); + const isLoading = isDetaching || isAttaching; + + return ( + + ); +} diff --git a/apps/console/src/pages/organizations/third-parties/ThirdPartyDetailPage.tsx b/apps/console/src/pages/organizations/third-parties/ThirdPartyDetailPage.tsx index 50547f773..45a684b80 100644 --- a/apps/console/src/pages/organizations/third-parties/ThirdPartyDetailPage.tsx +++ b/apps/console/src/pages/organizations/third-parties/ThirdPartyDetailPage.tsx @@ -44,8 +44,11 @@ import { import { useOrganizationId } from "#/hooks/useOrganizationId"; import { ImportAssessmentDialog } from "./dialogs/ImportAssessmentDialog"; +import { measuresFragment } from "./measures/ThirdPartyMeasuresPage"; import { complianceReportsFragment } from "./tabs/ThirdPartyComplianceTab"; +void measuresFragment; + type Props = { queryRef: PreloadedQuery; }; @@ -64,6 +67,7 @@ export default function ThirdPartyDetailPage(props: Props) { complianceReportsFragment, thirdParty as ThirdPartyComplianceTabFragment$key, ).complianceReports.edges.length; + const measuresCount = thirdParty.measuresInfos?.totalCount ?? 0; const thirdPartiesUrl = `/organizations/${organizationId}/third-parties`; @@ -136,6 +140,10 @@ export default function ThirdPartyDetailPage(props: Props) { {__("Third Parties")} + + {__("Measures")} + {measuresCount > 0 && {measuresCount}} + diff --git a/apps/console/src/pages/organizations/third-parties/measures/ThirdPartyMeasuresPage.tsx b/apps/console/src/pages/organizations/third-parties/measures/ThirdPartyMeasuresPage.tsx new file mode 100644 index 000000000..b7f2d147a --- /dev/null +++ b/apps/console/src/pages/organizations/third-parties/measures/ThirdPartyMeasuresPage.tsx @@ -0,0 +1,117 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import { graphql, useFragment } from "react-relay"; +import { useOutletContext, useParams } from "react-router"; + +import type { ThirdPartyMeasuresPageFragment$key } from "#/__generated__/core/ThirdPartyMeasuresPageFragment.graphql"; +import { LinkedMeasuresCard } from "#/components/measures/LinkedMeasuresCard"; +import { useMutationWithIncrement } from "#/hooks/useMutationWithIncrement"; + +export const measuresFragment = graphql` + fragment ThirdPartyMeasuresPageFragment on ThirdParty { + id + canCreateMeasureThirdPartyMapping: permission( + action: "core:measure:create-third-party-mapping" + ) + canDeleteMeasureThirdPartyMapping: permission( + action: "core:measure:delete-third-party-mapping" + ) + measures(first: 100) @connection(key: "ThirdPartyMeasuresPage_measures") { + __id + edges { + node { + id + ...LinkedMeasuresCardFragment + } + } + } + } +`; + +const attachMeasureMutation = graphql` + mutation ThirdPartyMeasuresPageAttachMutation( + $input: CreateMeasureThirdPartyMappingInput! + $connections: [ID!]! + ) { + createMeasureThirdPartyMapping(input: $input) { + measureEdge @prependEdge(connections: $connections) { + node { + id + ...LinkedMeasuresCardFragment + } + } + } + } +`; + +const detachMeasureMutation = graphql` + mutation ThirdPartyMeasuresPageDetachMutation( + $input: DeleteMeasureThirdPartyMappingInput! + $connections: [ID!]! + ) { + deleteMeasureThirdPartyMapping(input: $input) { + deletedMeasureId @deleteEdge(connections: $connections) + } + } +`; + +export default function ThirdPartyMeasuresPage() { + const { thirdPartyId } = useParams<{ thirdPartyId: string }>(); + if (!thirdPartyId) { + throw new Error("Missing :thirdPartyId param in route"); + } + const { thirdParty } = useOutletContext<{ + thirdParty: ThirdPartyMeasuresPageFragment$key; + }>(); + const data = useFragment(measuresFragment, thirdParty); + const connectionId = data.measures.__id; + const measures = data.measures?.edges?.map(edge => edge.node) ?? []; + + const canLink = data.canCreateMeasureThirdPartyMapping; + const canUnlink = data.canDeleteMeasureThirdPartyMapping; + const readOnly = !canLink && !canUnlink; + + const incrementOptions = { + id: data.id, + node: "measures(first:0)", + }; + const [detachMeasure, isDetaching] = useMutationWithIncrement( + detachMeasureMutation, + { + ...incrementOptions, + value: -1, + }, + ); + const [attachMeasure, isAttaching] = useMutationWithIncrement( + attachMeasureMutation, + { + ...incrementOptions, + value: 1, + }, + ); + const isLoading = isDetaching || isAttaching; + + return ( + + ); +} diff --git a/apps/console/src/routes/measureRoutes.ts b/apps/console/src/routes/measureRoutes.ts index 6a6c51cc7..9aec1c0f3 100644 --- a/apps/console/src/routes/measureRoutes.ts +++ b/apps/console/src/routes/measureRoutes.ts @@ -97,6 +97,14 @@ export const measureRoutes = [ import("#/pages/organizations/measures/tabs/MeasureDocumentsTab"), ), }, + { + path: "third-parties", + Fallback: LinkCardSkeleton, + Component: lazy( + () => + import("#/pages/organizations/measures/third-parties/MeasureThirdPartiesPage"), + ), + }, ], }, ] satisfies AppRoute[]; diff --git a/apps/console/src/routes/thirdPartyRoutes.ts b/apps/console/src/routes/thirdPartyRoutes.ts index 36c23c9e9..e5ab7aaf3 100644 --- a/apps/console/src/routes/thirdPartyRoutes.ts +++ b/apps/console/src/routes/thirdPartyRoutes.ts @@ -105,6 +105,13 @@ export const thirdPartyRoutes = [ import("../pages/organizations/third-parties/third-parties/ThirdPartyThirdPartiesPageLoader"), ), }, + { + path: "measures", + Fallback: LinkCardSkeleton, + Component: lazy( + () => import("../pages/organizations/third-parties/measures/ThirdPartyMeasuresPage"), + ), + }, ], }, ] satisfies AppRoute[]; diff --git a/e2e/console/measure_test.go b/e2e/console/measure_test.go index a836db4cb..4bd5ea721 100644 --- a/e2e/console/measure_test.go +++ b/e2e/console/measure_test.go @@ -2239,3 +2239,240 @@ func TestMeasure_Ordering(t *testing.T) { testutil.AssertTimesOrderedDescending(t, times, "createdAt") }) } + +func TestMeasure_ThirdPartyMapping(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + + const createMutation = ` + mutation($input: CreateMeasureThirdPartyMappingInput!) { + createMeasureThirdPartyMapping(input: $input) { + measureEdge { node { id } } + thirdPartyEdge { node { id } } + } + } + ` + + const deleteMutation = ` + mutation($input: DeleteMeasureThirdPartyMappingInput!) { + deleteMeasureThirdPartyMapping(input: $input) { + deletedMeasureId + deletedThirdPartyId + } + } + ` + + t.Run("create mapping links measure to third party on both sides", func(t *testing.T) { + t.Parallel() + + measureID := factory.NewMeasure(owner).WithName("Mapping Measure").Create() + thirdPartyID := factory.NewThirdParty(owner).WithName("Mapping Third Party").Create() + + _, err := owner.Do(createMutation, map[string]any{ + "input": map[string]any{ + "measureId": measureID, + "thirdPartyId": thirdPartyID, + }, + }) + require.NoError(t, err) + + const measureQuery = ` + query($id: ID!) { + node(id: $id) { + ... on Measure { + thirdParties(first: 10) { + edges { node { id } } + totalCount + } + } + } + } + ` + + var measureResult struct { + Node struct { + ThirdParties struct { + Edges []struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"edges"` + TotalCount int `json:"totalCount"` + } `json:"thirdParties"` + } `json:"node"` + } + + err = owner.Execute(measureQuery, map[string]any{"id": measureID}, &measureResult) + require.NoError(t, err) + assert.Equal(t, 1, measureResult.Node.ThirdParties.TotalCount) + assert.Equal(t, thirdPartyID, measureResult.Node.ThirdParties.Edges[0].Node.ID) + + const thirdPartyQuery = ` + query($id: ID!) { + node(id: $id) { + ... on ThirdParty { + measures(first: 10) { + edges { node { id } } + totalCount + } + } + } + } + ` + + var tpResult struct { + Node struct { + Measures struct { + Edges []struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"edges"` + TotalCount int `json:"totalCount"` + } `json:"measures"` + } `json:"node"` + } + + err = owner.Execute(thirdPartyQuery, map[string]any{"id": thirdPartyID}, &tpResult) + require.NoError(t, err) + assert.Equal(t, 1, tpResult.Node.Measures.TotalCount) + assert.Equal(t, measureID, tpResult.Node.Measures.Edges[0].Node.ID) + }) + + t.Run("create mapping is idempotent", func(t *testing.T) { + t.Parallel() + + measureID := factory.NewMeasure(owner).WithName("Idempotent Measure").Create() + thirdPartyID := factory.NewThirdParty(owner).WithName("Idempotent Third Party").Create() + + input := map[string]any{ + "measureId": measureID, + "thirdPartyId": thirdPartyID, + } + + _, err := owner.Do(createMutation, map[string]any{"input": input}) + require.NoError(t, err) + + _, err = owner.Do(createMutation, map[string]any{"input": input}) + require.NoError(t, err, "second mapping creation should be idempotent") + + const countQuery = ` + query($id: ID!) { + node(id: $id) { + ... on Measure { + thirdParties(first: 10) { totalCount } + } + } + } + ` + + var result struct { + Node struct { + ThirdParties struct { + TotalCount int `json:"totalCount"` + } `json:"thirdParties"` + } `json:"node"` + } + + err = owner.Execute(countQuery, map[string]any{"id": measureID}, &result) + require.NoError(t, err) + assert.Equal(t, 1, result.Node.ThirdParties.TotalCount, "duplicate create must not produce a second row") + }) + + t.Run("delete mapping removes link from both sides", func(t *testing.T) { + t.Parallel() + + measureID := factory.NewMeasure(owner).WithName("Unlink Measure").Create() + thirdPartyID := factory.NewThirdParty(owner).WithName("Unlink Third Party").Create() + + input := map[string]any{ + "measureId": measureID, + "thirdPartyId": thirdPartyID, + } + + _, err := owner.Do(createMutation, map[string]any{"input": input}) + require.NoError(t, err) + + _, err = owner.Do(deleteMutation, map[string]any{"input": input}) + require.NoError(t, err) + + const measureCountQuery = ` + query($id: ID!) { + node(id: $id) { + ... on Measure { + thirdParties(first: 10) { totalCount } + } + } + } + ` + + var measureResult struct { + Node struct { + ThirdParties struct { + TotalCount int `json:"totalCount"` + } `json:"thirdParties"` + } `json:"node"` + } + + err = owner.Execute(measureCountQuery, map[string]any{"id": measureID}, &measureResult) + require.NoError(t, err) + assert.Equal(t, 0, measureResult.Node.ThirdParties.TotalCount, "measure side should have no linked third parties") + + const thirdPartyCountQuery = ` + query($id: ID!) { + node(id: $id) { + ... on ThirdParty { + measures(first: 10) { totalCount } + } + } + } + ` + + var thirdPartyResult struct { + Node struct { + Measures struct { + TotalCount int `json:"totalCount"` + } `json:"measures"` + } `json:"node"` + } + + err = owner.Execute(thirdPartyCountQuery, map[string]any{"id": thirdPartyID}, &thirdPartyResult) + require.NoError(t, err) + assert.Equal(t, 0, thirdPartyResult.Node.Measures.TotalCount, "third-party side should have no linked measures") + }) + + t.Run("viewer cannot create mapping", func(t *testing.T) { + t.Parallel() + + viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner) + + measureID := factory.NewMeasure(owner).WithName("RBAC Measure").Create() + thirdPartyID := factory.NewThirdParty(owner).WithName("RBAC Third Party").Create() + + _, err := viewer.Do(createMutation, map[string]any{ + "input": map[string]any{ + "measureId": measureID, + "thirdPartyId": thirdPartyID, + }, + }) + require.Error(t, err) + }) + + t.Run("tenant isolation on mapping creation", func(t *testing.T) { + t.Parallel() + + otherOwner := testutil.NewClient(t, testutil.RoleOwner) + + measureID := factory.NewMeasure(owner).WithName("Tenant Measure").Create() + otherThirdPartyID := factory.NewThirdParty(otherOwner).WithName("Other Tenant Third Party").Create() + + _, err := owner.Do(createMutation, map[string]any{ + "input": map[string]any{ + "measureId": measureID, + "thirdPartyId": otherThirdPartyID, + }, + }) + require.Error(t, err, "should not link a third party from another tenant") + }) +} diff --git a/packages/n8n-node/nodes/Probo/actions/measure/index.ts b/packages/n8n-node/nodes/Probo/actions/measure/index.ts index bffdf054d..518d62ada 100644 --- a/packages/n8n-node/nodes/Probo/actions/measure/index.ts +++ b/packages/n8n-node/nodes/Probo/actions/measure/index.ts @@ -19,7 +19,9 @@ import * as deleteOp from './delete.operation'; import * as getOp from './get.operation'; import * as getAllOp from './getAll.operation'; import * as linkDocumentOp from './linkDocument.operation'; +import * as linkThirdPartyOp from './linkThirdParty.operation'; import * as unlinkDocumentOp from './unlinkDocument.operation'; +import * as unlinkThirdPartyOp from './unlinkThirdParty.operation'; export const description: INodeProperties[] = [ { @@ -63,12 +65,24 @@ export const description: INodeProperties[] = [ description: 'Link a document to a measure', action: 'Link a document to a measure', }, + { + name: 'Link Third Party', + value: 'linkThirdParty', + description: 'Link a third party to a measure', + action: 'Link a third party to a measure', + }, { name: 'Unlink Document', value: 'unlinkDocument', description: 'Unlink a document from a measure', action: 'Unlink a document from a measure', }, + { + name: 'Unlink Third Party', + value: 'unlinkThirdParty', + description: 'Unlink a third party from a measure', + action: 'Unlink a third party from a measure', + }, { name: 'Update', value: 'update', @@ -85,6 +99,8 @@ export const description: INodeProperties[] = [ ...getAllOp.description, ...linkDocumentOp.description, ...unlinkDocumentOp.description, + ...linkThirdPartyOp.description, + ...unlinkThirdPartyOp.description, ]; export { @@ -95,4 +111,6 @@ export { getAllOp as getAll, linkDocumentOp as linkDocument, unlinkDocumentOp as unlinkDocument, + linkThirdPartyOp as linkThirdParty, + unlinkThirdPartyOp as unlinkThirdParty, }; diff --git a/packages/n8n-node/nodes/Probo/actions/measure/linkThirdParty.operation.ts b/packages/n8n-node/nodes/Probo/actions/measure/linkThirdParty.operation.ts new file mode 100644 index 000000000..50538c53c --- /dev/null +++ b/packages/n8n-node/nodes/Probo/actions/measure/linkThirdParty.operation.ts @@ -0,0 +1,81 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; +import { proboApiRequest } from '../../GenericFunctions'; + +export const description: INodeProperties[] = [ + { + displayName: 'Measure ID', + name: 'measureId', + type: 'string', + displayOptions: { + show: { + resource: ['measure'], + operation: ['linkThirdParty'], + }, + }, + default: '', + description: 'The ID of the measure', + required: true, + }, + { + displayName: 'Third Party ID', + name: 'thirdPartyId', + type: 'string', + displayOptions: { + show: { + resource: ['measure'], + operation: ['linkThirdParty'], + }, + }, + default: '', + description: 'The ID of the third party to link', + required: true, + }, +]; + +export async function execute( + this: IExecuteFunctions, + itemIndex: number, +): Promise { + const measureId = this.getNodeParameter('measureId', itemIndex) as string; + const thirdPartyId = this.getNodeParameter('thirdPartyId', itemIndex) as string; + + const query = ` + mutation CreateMeasureThirdPartyMapping($input: CreateMeasureThirdPartyMappingInput!) { + createMeasureThirdPartyMapping(input: $input) { + measureEdge { + node { + id + name + } + } + thirdPartyEdge { + node { + id + name + } + } + } + } + `; + + const responseData = await proboApiRequest.call(this, query, { input: { measureId, thirdPartyId } }); + + return { + json: responseData, + pairedItem: { item: itemIndex }, + }; +} diff --git a/packages/n8n-node/nodes/Probo/actions/measure/unlinkThirdParty.operation.ts b/packages/n8n-node/nodes/Probo/actions/measure/unlinkThirdParty.operation.ts new file mode 100644 index 000000000..89574612d --- /dev/null +++ b/packages/n8n-node/nodes/Probo/actions/measure/unlinkThirdParty.operation.ts @@ -0,0 +1,71 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; +import { proboApiRequest } from '../../GenericFunctions'; + +export const description: INodeProperties[] = [ + { + displayName: 'Measure ID', + name: 'measureId', + type: 'string', + displayOptions: { + show: { + resource: ['measure'], + operation: ['unlinkThirdParty'], + }, + }, + default: '', + description: 'The ID of the measure', + required: true, + }, + { + displayName: 'Third Party ID', + name: 'thirdPartyId', + type: 'string', + displayOptions: { + show: { + resource: ['measure'], + operation: ['unlinkThirdParty'], + }, + }, + default: '', + description: 'The ID of the third party to unlink', + required: true, + }, +]; + +export async function execute( + this: IExecuteFunctions, + itemIndex: number, +): Promise { + const measureId = this.getNodeParameter('measureId', itemIndex) as string; + const thirdPartyId = this.getNodeParameter('thirdPartyId', itemIndex) as string; + + const query = ` + mutation DeleteMeasureThirdPartyMapping($input: DeleteMeasureThirdPartyMappingInput!) { + deleteMeasureThirdPartyMapping(input: $input) { + deletedMeasureId + deletedThirdPartyId + } + } + `; + + const responseData = await proboApiRequest.call(this, query, { input: { measureId, thirdPartyId } }); + + return { + json: responseData, + pairedItem: { item: itemIndex }, + }; +} diff --git a/pkg/cmd/measure/link-third-party/link_third_party.go b/pkg/cmd/measure/link-third-party/link_third_party.go new file mode 100644 index 000000000..080064517 --- /dev/null +++ b/pkg/cmd/measure/link-third-party/link_third_party.go @@ -0,0 +1,97 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 linkthirdparty + +import ( + "fmt" + + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const linkThirdPartyMutation = ` +mutation($input: CreateMeasureThirdPartyMappingInput!) { + createMeasureThirdPartyMapping(input: $input) { + measureEdge { + node { id } + } + thirdPartyEdge { + node { id } + } + } +} +` + +func NewCmdLinkThirdParty(f *cmdutil.Factory) *cobra.Command { + var ( + flagMeasureID string + flagThirdPartyID string + ) + + cmd := &cobra.Command{ + Use: "link-third-party", + Short: "Link a third party to a measure", + 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( + linkThirdPartyMutation, + map[string]any{ + "input": map[string]any{ + "measureId": flagMeasureID, + "thirdPartyId": flagThirdPartyID, + }, + }, + ) + if err != nil { + return err + } + + _, _ = fmt.Fprintf( + f.IOStreams.Out, + "Linked third party %s to measure %s\n", + flagThirdPartyID, + flagMeasureID, + ) + + return nil + }, + } + + cmd.Flags().StringVar(&flagMeasureID, "measure-id", "", "Measure ID (required)") + cmd.Flags().StringVar(&flagThirdPartyID, "third-party-id", "", "Third party ID (required)") + + _ = cmd.MarkFlagRequired("measure-id") + _ = cmd.MarkFlagRequired("third-party-id") + + return cmd +} diff --git a/pkg/cmd/measure/measure.go b/pkg/cmd/measure/measure.go index 0da652204..153f8706e 100644 --- a/pkg/cmd/measure/measure.go +++ b/pkg/cmd/measure/measure.go @@ -19,7 +19,9 @@ import ( "go.probo.inc/probo/pkg/cmd/cmdutil" "go.probo.inc/probo/pkg/cmd/measure/create" "go.probo.inc/probo/pkg/cmd/measure/delete" + linkthirdparty "go.probo.inc/probo/pkg/cmd/measure/link-third-party" "go.probo.inc/probo/pkg/cmd/measure/list" + unlinkthirdparty "go.probo.inc/probo/pkg/cmd/measure/unlink-third-party" "go.probo.inc/probo/pkg/cmd/measure/update" "go.probo.inc/probo/pkg/cmd/measure/view" ) @@ -35,6 +37,8 @@ func NewCmdMeasure(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(view.NewCmdView(f)) cmd.AddCommand(update.NewCmdUpdate(f)) cmd.AddCommand(delete.NewCmdDelete(f)) + cmd.AddCommand(linkthirdparty.NewCmdLinkThirdParty(f)) + cmd.AddCommand(unlinkthirdparty.NewCmdUnlinkThirdParty(f)) return cmd } diff --git a/pkg/cmd/measure/unlink-third-party/unlink_third_party.go b/pkg/cmd/measure/unlink-third-party/unlink_third_party.go new file mode 100644 index 000000000..6a49c8a0e --- /dev/null +++ b/pkg/cmd/measure/unlink-third-party/unlink_third_party.go @@ -0,0 +1,93 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 unlinkthirdparty + +import ( + "fmt" + + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const unlinkThirdPartyMutation = ` +mutation($input: DeleteMeasureThirdPartyMappingInput!) { + deleteMeasureThirdPartyMapping(input: $input) { + deletedMeasureId + deletedThirdPartyId + } +} +` + +func NewCmdUnlinkThirdParty(f *cmdutil.Factory) *cobra.Command { + var ( + flagMeasureID string + flagThirdPartyID string + ) + + cmd := &cobra.Command{ + Use: "unlink-third-party", + Short: "Unlink a third party from a measure", + 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( + unlinkThirdPartyMutation, + map[string]any{ + "input": map[string]any{ + "measureId": flagMeasureID, + "thirdPartyId": flagThirdPartyID, + }, + }, + ) + if err != nil { + return err + } + + _, _ = fmt.Fprintf( + f.IOStreams.Out, + "Unlinked third party %s from measure %s\n", + flagThirdPartyID, + flagMeasureID, + ) + + return nil + }, + } + + cmd.Flags().StringVar(&flagMeasureID, "measure-id", "", "Measure ID (required)") + cmd.Flags().StringVar(&flagThirdPartyID, "third-party-id", "", "Third party ID (required)") + + _ = cmd.MarkFlagRequired("measure-id") + _ = cmd.MarkFlagRequired("third-party-id") + + return cmd +} diff --git a/pkg/coredata/measure.go b/pkg/coredata/measure.go index f7b86026d..81ffde915 100644 --- a/pkg/coredata/measure.go +++ b/pkg/coredata/measure.go @@ -720,3 +720,115 @@ WHERE %s return err } + +func (m *Measures) CountByThirdPartyID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + thirdPartyID gid.GID, + filter *MeasureFilter, +) (int, error) { + q := ` +WITH mtgtns AS ( + SELECT + m.id, + m.tenant_id, + m.search_vector, + m.state, + m.category + FROM + measures m + INNER JOIN + measures_third_parties mtp ON m.id = mtp.measure_id + WHERE + mtp.third_party_id = @third_party_id + ) + SELECT + COUNT(id) + FROM + mtgtns + WHERE %s + AND %s + ` + q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment()) + + args := pgx.NamedArgs{"third_party_id": thirdPartyID} + maps.Copy(args, scope.SQLArguments()) + maps.Copy(args, filter.SQLArguments()) + + row := conn.QueryRow(ctx, q, args) + + var count int + if err := row.Scan(&count); err != nil { + return 0, fmt.Errorf("cannot scan count: %w", err) + } + + return count, nil +} + +func (m *Measures) LoadByThirdPartyID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + thirdPartyID gid.GID, + cursor *page.Cursor[MeasureOrderField], + filter *MeasureFilter, +) error { + q := ` +WITH mtgtns AS ( + SELECT + m.id, + m.tenant_id, + m.organization_id, + m.category, + m.name, + m.description, + m.state, + m.reference_id, + m.search_vector, + m.created_at, + m.updated_at + FROM + measures m + INNER JOIN + measures_third_parties mtp ON m.id = mtp.measure_id + WHERE + mtp.third_party_id = @third_party_id +) +SELECT + id, + organization_id, + category, + name, + description, + state, + reference_id, + created_at, + updated_at +FROM + mtgtns +WHERE %s + AND %s + AND %s +` + q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment()) + + args := pgx.NamedArgs{"third_party_id": thirdPartyID} + maps.Copy(args, scope.SQLArguments()) + maps.Copy(args, filter.SQLArguments()) + maps.Copy(args, cursor.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query measures: %w", err) + } + + measures, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Measure]) + if err != nil { + return fmt.Errorf("cannot collect measures: %w", err) + } + + *m = measures + + return nil +} diff --git a/pkg/coredata/measure_third_party.go b/pkg/coredata/measure_third_party.go new file mode 100644 index 000000000..1766d365e --- /dev/null +++ b/pkg/coredata/measure_third_party.go @@ -0,0 +1,114 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// 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" +) + +type ( + MeasureThirdParty struct { + MeasureID gid.GID `db:"measure_id"` + ThirdPartyID gid.GID `db:"third_party_id"` + OrganizationID gid.GID `db:"organization_id"` + CreatedAt time.Time `db:"created_at"` + } + + MeasureThirdParties []*MeasureThirdParty +) + +// Upsert links a measure to a third party. The organization_id stored in the +// junction row is derived from the measures table inside the INSERT, so a +// caller cannot place the mapping into a different organization than the +// measure actually belongs to. Idempotent: re-linking an existing pair is a +// no-op. +func (mtp MeasureThirdParty) Upsert( + ctx context.Context, + conn pg.Tx, + scope Scoper, +) error { + q := ` +INSERT INTO + measures_third_parties ( + measure_id, + third_party_id, + organization_id, + tenant_id, + created_at + ) +SELECT + @measure_id, + @third_party_id, + m.organization_id, + @tenant_id, + @created_at +FROM + measures m +WHERE + m.id = @measure_id + AND m.tenant_id = @tenant_id +ON CONFLICT (measure_id, third_party_id) DO NOTHING; +` + + args := pgx.StrictNamedArgs{ + "measure_id": mtp.MeasureID, + "third_party_id": mtp.ThirdPartyID, + "tenant_id": scope.GetTenantID(), + "created_at": mtp.CreatedAt, + } + + if _, err := conn.Exec(ctx, q, args); err != nil { + return fmt.Errorf("cannot upsert measure third party: %w", err) + } + + return nil +} + +func (mtp MeasureThirdParty) Delete( + ctx context.Context, + conn pg.Tx, + scope Scoper, + measureID gid.GID, + thirdPartyID gid.GID, +) error { + q := ` +DELETE +FROM + measures_third_parties +WHERE + %s + AND measure_id = @measure_id + AND third_party_id = @third_party_id; +` + + args := pgx.StrictNamedArgs{ + "measure_id": measureID, + "third_party_id": thirdPartyID, + } + maps.Copy(args, scope.SQLArguments()) + + q = fmt.Sprintf(q, scope.SQLFragment()) + + _, err := conn.Exec(ctx, q, args) + + return err +} diff --git a/pkg/coredata/migrations/20260522T120000Z.sql b/pkg/coredata/migrations/20260522T120000Z.sql new file mode 100644 index 000000000..94a3040b0 --- /dev/null +++ b/pkg/coredata/migrations/20260522T120000Z.sql @@ -0,0 +1,22 @@ +-- Copyright (c) 2025-2026 Probo Inc . +-- +-- 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. + +CREATE TABLE measures_third_parties ( + measure_id TEXT NOT NULL REFERENCES measures(id) ON DELETE CASCADE, + third_party_id TEXT NOT NULL REFERENCES third_parties(id) ON DELETE CASCADE, + organization_id TEXT NOT NULL, + tenant_id TEXT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + PRIMARY KEY (measure_id, third_party_id) +); diff --git a/pkg/coredata/third_party.go b/pkg/coredata/third_party.go index 638152aff..6a51df4f9 100644 --- a/pkg/coredata/third_party.go +++ b/pkg/coredata/third_party.go @@ -1448,3 +1448,141 @@ LIMIT 1; return nil } + +func (v *ThirdParties) CountByMeasureID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + measureID gid.GID, +) (int, error) { + q := ` +WITH tps AS ( + SELECT + v.id, + v.tenant_id + FROM + third_parties v + INNER JOIN + measures_third_parties mtp ON v.id = mtp.third_party_id + WHERE + mtp.measure_id = @measure_id +) +SELECT + COUNT(id) +FROM + tps +WHERE %s +` + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"measure_id": measureID} + maps.Copy(args, scope.SQLArguments()) + + row := conn.QueryRow(ctx, q, args) + + var count int + + if err := row.Scan(&count); err != nil { + return 0, fmt.Errorf("cannot count thirdParties: %w", err) + } + + return count, nil +} + +func (v *ThirdParties) LoadByMeasureID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + measureID gid.GID, + cursor *page.Cursor[ThirdPartyOrderField], +) error { + q := ` +WITH tps AS ( + SELECT + v.id, + v.tenant_id, + v.organization_id, + v.common_third_party_id, + v.name, + v.description, + v.category, + v.headquarter_address, + v.legal_name, + v.website_url, + v.privacy_policy_url, + v.service_level_agreement_url, + v.data_processing_agreement_url, + v.business_associate_agreement_url, + v.subprocessors_list_url, + v.certifications, + v.countries, + v.business_owner_profile_id, + v.security_owner_profile_id, + v.status_page_url, + v.terms_of_service_url, + v.security_page_url, + v.trust_page_url, + v.show_on_trust_center, + v.first_level, + v.created_at, + v.updated_at + FROM + third_parties v + INNER JOIN + measures_third_parties mtp ON v.id = mtp.third_party_id + WHERE + mtp.measure_id = @measure_id +) +SELECT + id, + tenant_id, + organization_id, + common_third_party_id, + name, + description, + category, + headquarter_address, + legal_name, + website_url, + privacy_policy_url, + service_level_agreement_url, + data_processing_agreement_url, + business_associate_agreement_url, + subprocessors_list_url, + certifications, + countries, + business_owner_profile_id, + security_owner_profile_id, + status_page_url, + terms_of_service_url, + security_page_url, + trust_page_url, + show_on_trust_center, + first_level, + created_at, + updated_at +FROM + tps +WHERE %s + AND %s +` + q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment()) + + args := pgx.StrictNamedArgs{"measure_id": measureID} + 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 thirdParties: %w", err) + } + + thirdParties, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[ThirdParty]) + if err != nil { + return fmt.Errorf("cannot collect thirdParties: %w", err) + } + + *v = thirdParties + + return nil +} diff --git a/pkg/coredata/third_party_filter.go b/pkg/coredata/third_party_filter.go index aa50eda8f..f5e50ee41 100644 --- a/pkg/coredata/third_party_filter.go +++ b/pkg/coredata/third_party_filter.go @@ -22,23 +22,30 @@ type ( ThirdPartyFilter struct { showOnTrustCenter *bool firstLevel *bool + query *string } ) -func NewThirdPartyFilter(showOnTrustCenter *bool, firstLevel *bool) *ThirdPartyFilter { +func NewThirdPartyFilter(showOnTrustCenter *bool, firstLevel *bool, query *string) *ThirdPartyFilter { return &ThirdPartyFilter{ showOnTrustCenter: showOnTrustCenter, firstLevel: firstLevel, + query: query, } } func (f *ThirdPartyFilter) SQLArguments() pgx.StrictNamedArgs { - args := pgx.StrictNamedArgs{} + args := pgx.StrictNamedArgs{ + "show_on_trust_center": nil, + "filter_query": nil, + } if f.showOnTrustCenter != nil { args["show_on_trust_center"] = *f.showOnTrustCenter - } else { - args["show_on_trust_center"] = nil + } + + if f.query != nil && *f.query != "" { + args["filter_query"] = *f.query } if f.firstLevel != nil { @@ -58,13 +65,15 @@ func (f *ThirdPartyFilter) SQLFragment() string { show_on_trust_center = @show_on_trust_center::boolean ELSE TRUE END -) -AND -( - CASE + AND CASE WHEN @first_level::boolean IS NOT NULL THEN first_level = @first_level::boolean ELSE TRUE END + AND CASE + WHEN @filter_query::text IS NOT NULL AND @filter_query::text <> '' THEN + name ILIKE '%' || @filter_query || '%' + ELSE TRUE + END )` } diff --git a/pkg/probo/actions.go b/pkg/probo/actions.go index 9df8bc8b5..43a1e0db8 100644 --- a/pkg/probo/actions.go +++ b/pkg/probo/actions.go @@ -158,15 +158,17 @@ const ( ActionControlObligationMappingDelete = "core:control:delete-obligation-mapping" // Measure actions - ActionMeasureGet = "core:measure:get" - ActionMeasureList = "core:measure:list" - ActionMeasureCreate = "core:measure:create" - ActionMeasureUpdate = "core:measure:update" - ActionMeasureDelete = "core:measure:delete" - ActionMeasureEvidenceUpload = "core:measure:upload-evidence" - ActionMeasureImport = "core:measure:import" - ActionMeasureDocumentMappingCreate = "core:measure:create-document-mapping" - ActionMeasureDocumentMappingDelete = "core:measure:delete-document-mapping" + ActionMeasureGet = "core:measure:get" + ActionMeasureList = "core:measure:list" + ActionMeasureCreate = "core:measure:create" + ActionMeasureUpdate = "core:measure:update" + ActionMeasureDelete = "core:measure:delete" + ActionMeasureEvidenceUpload = "core:measure:upload-evidence" + ActionMeasureImport = "core:measure:import" + ActionMeasureDocumentMappingCreate = "core:measure:create-document-mapping" + ActionMeasureDocumentMappingDelete = "core:measure:delete-document-mapping" + ActionMeasureThirdPartyMappingCreate = "core:measure:create-third-party-mapping" + ActionMeasureThirdPartyMappingDelete = "core:measure:delete-third-party-mapping" // Task actions ActionTaskGet = "core:task:get" diff --git a/pkg/probo/measure_service.go b/pkg/probo/measure_service.go index b4f093672..8c75ede4b 100644 --- a/pkg/probo/measure_service.go +++ b/pkg/probo/measure_service.go @@ -644,6 +644,138 @@ func (s MeasureService) CreateDocumentMapping( return measure, document, nil } +func (s MeasureService) CountForThirdPartyID( + ctx context.Context, scope coredata.Scoper, + thirdPartyID gid.GID, + filter *coredata.MeasureFilter, +) (int, error) { + var count int + + err := s.svc.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) (err error) { + measures := &coredata.Measures{} + + count, err = measures.CountByThirdPartyID(ctx, conn, scope, thirdPartyID, filter) + if err != nil { + return fmt.Errorf("cannot count measures: %w", err) + } + + return nil + }, + ) + if err != nil { + return 0, err + } + + return count, nil +} + +func (s MeasureService) ListForThirdPartyID( + ctx context.Context, scope coredata.Scoper, + thirdPartyID gid.GID, + cursor *page.Cursor[coredata.MeasureOrderField], + filter *coredata.MeasureFilter, +) (*page.Page[*coredata.Measure, coredata.MeasureOrderField], error) { + var measures coredata.Measures + + thirdParty := &coredata.ThirdParty{} + + err := s.svc.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + if err := thirdParty.LoadByID(ctx, conn, scope, thirdPartyID); err != nil { + return fmt.Errorf("cannot load third party: %w", err) + } + + err := measures.LoadByThirdPartyID(ctx, conn, scope, thirdParty.ID, cursor, filter) + if err != nil { + return fmt.Errorf("cannot load measures: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return page.NewPage(measures, cursor), nil +} + +func (s MeasureService) CreateThirdPartyMapping( + ctx context.Context, scope coredata.Scoper, + measureID gid.GID, + thirdPartyID gid.GID, +) (*coredata.Measure, *coredata.ThirdParty, error) { + measure := &coredata.Measure{} + thirdParty := &coredata.ThirdParty{} + + err := s.svc.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + if err := measure.LoadByID(ctx, tx, scope, measureID); err != nil { + return fmt.Errorf("cannot load measure: %w", err) + } + + if err := thirdParty.LoadByID(ctx, tx, scope, thirdPartyID); err != nil { + return fmt.Errorf("cannot load third party: %w", err) + } + + measureThirdParty := &coredata.MeasureThirdParty{ + MeasureID: measure.ID, + ThirdPartyID: thirdParty.ID, + CreatedAt: time.Now(), + } + + if err := measureThirdParty.Upsert(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot upsert measure third party: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, nil, err + } + + return measure, thirdParty, nil +} + +func (s MeasureService) DeleteThirdPartyMapping( + ctx context.Context, scope coredata.Scoper, + measureID gid.GID, + thirdPartyID gid.GID, +) (*coredata.Measure, *coredata.ThirdParty, error) { + measure := &coredata.Measure{} + thirdParty := &coredata.ThirdParty{} + + err := s.svc.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + if err := measure.LoadByID(ctx, tx, scope, measureID); err != nil { + return fmt.Errorf("cannot load measure: %w", err) + } + + if err := thirdParty.LoadByID(ctx, tx, scope, thirdPartyID); err != nil { + return fmt.Errorf("cannot load third party: %w", err) + } + + measureThirdParty := &coredata.MeasureThirdParty{} + if err := measureThirdParty.Delete(ctx, tx, scope, measure.ID, thirdParty.ID); err != nil { + return fmt.Errorf("cannot delete measure third party mapping: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, nil, err + } + + return measure, thirdParty, nil +} + func (s MeasureService) DeleteDocumentMapping( ctx context.Context, scope coredata.Scoper, measureID gid.GID, diff --git a/pkg/probo/third_party_service.go b/pkg/probo/third_party_service.go index 79eb1b2cd..7282be344 100644 --- a/pkg/probo/third_party_service.go +++ b/pkg/probo/third_party_service.go @@ -211,14 +211,18 @@ func (cvrar *CreateThirdPartyRiskAssessmentRequest) Validate() error { func (s ThirdPartyService) CountForOrganizationID( ctx context.Context, scope coredata.Scoper, organizationID gid.GID, + filter *coredata.ThirdPartyFilter, ) (int, error) { var count int + if filter == nil { + filter = coredata.NewThirdPartyFilter(nil, nil, nil) + } + err := s.svc.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) (err error) { thirdParties := coredata.ThirdParties{} - filter := &coredata.ThirdPartyFilter{} count, err = thirdParties.CountByOrganizationID(ctx, conn, scope, organizationID, filter) if err != nil { @@ -269,6 +273,68 @@ func (s ThirdPartyService) ListForOrganizationID( return page.NewPage(thirdParties, cursor), nil } +func (s ThirdPartyService) CountForMeasureID( + ctx context.Context, scope coredata.Scoper, + measureID gid.GID, +) (int, error) { + var count int + + err := s.svc.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) (err error) { + thirdParties := coredata.ThirdParties{} + + count, err = thirdParties.CountByMeasureID(ctx, conn, scope, measureID) + if err != nil { + return fmt.Errorf("cannot count thirdParties: %w", err) + } + + return nil + }, + ) + if err != nil { + return 0, err + } + + return count, nil +} + +func (s ThirdPartyService) ListForMeasureID( + ctx context.Context, scope coredata.Scoper, + measureID gid.GID, + cursor *page.Cursor[coredata.ThirdPartyOrderField], +) (*page.Page[*coredata.ThirdParty, coredata.ThirdPartyOrderField], error) { + var thirdParties coredata.ThirdParties + + measure := &coredata.Measure{} + + err := s.svc.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + if err := measure.LoadByID(ctx, conn, scope, measureID); err != nil { + return fmt.Errorf("cannot load measure: %w", err) + } + + if err := thirdParties.LoadByMeasureID( + ctx, + conn, + scope, + measure.ID, + cursor, + ); err != nil { + return fmt.Errorf("cannot load thirdParties: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return page.NewPage(thirdParties, cursor), nil +} + func (s ThirdPartyService) CountForDatumID( ctx context.Context, scope coredata.Scoper, datumID gid.GID, diff --git a/pkg/server/api/console/v1/asset_resolvers.go b/pkg/server/api/console/v1/asset_resolvers.go index 17b9c957f..fc2a09c0d 100644 --- a/pkg/server/api/console/v1/asset_resolvers.go +++ b/pkg/server/api/console/v1/asset_resolvers.go @@ -72,7 +72,7 @@ func (r *assetResolver) ThirdParties(ctx context.Context, obj *types.Asset, firs return nil, gqlutils.Internal(ctx) } - return types.NewThirdPartyConnection(page, r, obj.ID), nil + return types.NewThirdPartyConnection(page, r, obj.ID, nil), nil } // Organization is the resolver for the organization field. @@ -177,7 +177,7 @@ func (r *datumResolver) ThirdParties(ctx context.Context, obj *types.Datum, firs return nil, gqlutils.Internal(ctx) } - return types.NewThirdPartyConnection(page, r, obj.ID), nil + return types.NewThirdPartyConnection(page, r, obj.ID, nil), nil } // Organization is the resolver for the organization field. diff --git a/pkg/server/api/console/v1/graphql/measure.graphql b/pkg/server/api/console/v1/graphql/measure.graphql index 988b2a889..3c53b7a67 100644 --- a/pkg/server/api/console/v1/graphql/measure.graphql +++ b/pkg/server/api/console/v1/graphql/measure.graphql @@ -95,6 +95,14 @@ type Measure implements Node { filter: DocumentFilter ): DocumentConnection! @goField(forceResolver: true) + thirdParties( + first: Int + after: CursorKey + last: Int + before: CursorKey + orderBy: ThirdPartyOrder + ): ThirdPartyConnection! @goField(forceResolver: true) + createdAt: Datetime! updatedAt: Datetime! @@ -126,6 +134,12 @@ extend type Mutation { deleteMeasureDocumentMapping( input: DeleteMeasureDocumentMappingInput! ): DeleteMeasureDocumentMappingPayload! + createMeasureThirdPartyMapping( + input: CreateMeasureThirdPartyMappingInput! + ): CreateMeasureThirdPartyMappingPayload! + deleteMeasureThirdPartyMapping( + input: DeleteMeasureThirdPartyMappingInput! + ): DeleteMeasureThirdPartyMappingPayload! } input CreateMeasureInput { @@ -187,3 +201,23 @@ type DeleteMeasureDocumentMappingPayload { deletedMeasureId: ID! deletedDocumentId: ID! } + +input CreateMeasureThirdPartyMappingInput { + measureId: ID! + thirdPartyId: ID! +} + +input DeleteMeasureThirdPartyMappingInput { + measureId: ID! + thirdPartyId: ID! +} + +type CreateMeasureThirdPartyMappingPayload { + measureEdge: MeasureEdge! + thirdPartyEdge: ThirdPartyEdge! +} + +type DeleteMeasureThirdPartyMappingPayload { + deletedMeasureId: ID! + deletedThirdPartyId: ID! +} diff --git a/pkg/server/api/console/v1/graphql/third_party.graphql b/pkg/server/api/console/v1/graphql/third_party.graphql index 85aed15b6..1f801f099 100644 --- a/pkg/server/api/console/v1/graphql/third_party.graphql +++ b/pkg/server/api/console/v1/graphql/third_party.graphql @@ -179,6 +179,7 @@ input ThirdPartyOrder input ThirdPartyFilter { firstLevel: Boolean + query: String } input ThirdPartyComplianceReportOrder @@ -255,6 +256,15 @@ type ThirdParty implements Node { orderBy: ThirdPartyRiskAssessmentOrder ): ThirdPartyRiskAssessmentConnection! @goField(forceResolver: true) + measures( + first: Int + after: CursorKey + last: Int + before: CursorKey + orderBy: MeasureOrder + filter: MeasureFilter + ): MeasureConnection! @goField(forceResolver: true) + businessOwner: Profile @goField(forceResolver: true) securityOwner: Profile @goField(forceResolver: true) diff --git a/pkg/server/api/console/v1/measure_resolvers.go b/pkg/server/api/console/v1/measure_resolvers.go index bb99bd585..92b4ce120 100644 --- a/pkg/server/api/console/v1/measure_resolvers.go +++ b/pkg/server/api/console/v1/measure_resolvers.go @@ -188,6 +188,35 @@ func (r *measureResolver) Documents(ctx context.Context, obj *types.Measure, fir return types.NewDocumentConnection(pg, r, obj.ID, documentFilter), nil } +// ThirdParties is the resolver for the thirdParties field. +func (r *measureResolver) ThirdParties(ctx context.Context, obj *types.Measure, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ThirdPartyOrderBy) (*types.ThirdPartyConnection, error) { + scope, err := r.authorize(ctx, obj.ID, probo.ActionThirdPartyList) + if err != nil { + return nil, err + } + + pageOrderBy := page.OrderBy[coredata.ThirdPartyOrderField]{ + Field: coredata.ThirdPartyOrderFieldCreatedAt, + Direction: page.OrderDirectionDesc, + } + if orderBy != nil { + pageOrderBy = page.OrderBy[coredata.ThirdPartyOrderField]{ + Field: orderBy.Field, + Direction: orderBy.Direction, + } + } + + cursor := types.NewCursor(first, after, last, before, pageOrderBy) + + page, err := r.probo.ThirdParties.ListForMeasureID(ctx, scope, obj.ID, cursor) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot list measure third parties", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return types.NewThirdPartyConnection(page, r, obj.ID, nil), nil +} + // Permission is the resolver for the permission field. func (r *measureResolver) Permission(ctx context.Context, obj *types.Measure, action string) (bool, error) { return r.Resolver.Permission(ctx, obj, action) @@ -224,6 +253,14 @@ func (r *measureConnectionResolver) TotalCount(ctx context.Context, obj *types.M return 0, gqlutils.Internal(ctx) } + return count, nil + case *thirdPartyResolver: + count, err := r.probo.Measures.CountForThirdPartyID(ctx, scope, obj.ParentID, obj.Filters) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot count measures", log.Error(err)) + return 0, gqlutils.Internal(ctx) + } + return count, nil } @@ -388,6 +425,44 @@ func (r *mutationResolver) DeleteMeasureDocumentMapping(ctx context.Context, inp }, nil } +// CreateMeasureThirdPartyMapping is the resolver for the createMeasureThirdPartyMapping field. +func (r *mutationResolver) CreateMeasureThirdPartyMapping(ctx context.Context, input types.CreateMeasureThirdPartyMappingInput) (*types.CreateMeasureThirdPartyMappingPayload, error) { + scope, err := r.authorize(ctx, input.MeasureID, probo.ActionMeasureThirdPartyMappingCreate) + if err != nil { + return nil, err + } + + measure, thirdParty, err := r.probo.Measures.CreateThirdPartyMapping(ctx, scope, input.MeasureID, input.ThirdPartyID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot create measure third party mapping", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return &types.CreateMeasureThirdPartyMappingPayload{ + MeasureEdge: types.NewMeasureEdge(measure, coredata.MeasureOrderFieldCreatedAt), + ThirdPartyEdge: types.NewThirdPartyEdge(thirdParty, coredata.ThirdPartyOrderFieldCreatedAt), + }, nil +} + +// DeleteMeasureThirdPartyMapping is the resolver for the deleteMeasureThirdPartyMapping field. +func (r *mutationResolver) DeleteMeasureThirdPartyMapping(ctx context.Context, input types.DeleteMeasureThirdPartyMappingInput) (*types.DeleteMeasureThirdPartyMappingPayload, error) { + scope, err := r.authorize(ctx, input.MeasureID, probo.ActionMeasureThirdPartyMappingDelete) + if err != nil { + return nil, err + } + + measure, thirdParty, err := r.probo.Measures.DeleteThirdPartyMapping(ctx, scope, input.MeasureID, input.ThirdPartyID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot delete measure third party mapping", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return &types.DeleteMeasureThirdPartyMappingPayload{ + DeletedMeasureID: measure.ID, + DeletedThirdPartyID: thirdParty.ID, + }, nil +} + // Measure returns schema.MeasureResolver implementation. func (r *Resolver) Measure() schema.MeasureResolver { return &measureResolver{r} } diff --git a/pkg/server/api/console/v1/organization_resolvers.go b/pkg/server/api/console/v1/organization_resolvers.go index 34cccce52..1f213e519 100644 --- a/pkg/server/api/console/v1/organization_resolvers.go +++ b/pkg/server/api/console/v1/organization_resolvers.go @@ -1291,12 +1291,16 @@ func (r *organizationResolver) ThirdParties(ctx context.Context, obj *types.Orga cursor := types.NewCursor(first, after, last, before, pageOrderBy) - var firstLevel *bool + var ( + firstLevel *bool + query *string + ) if filter != nil { firstLevel = filter.FirstLevel + query = filter.Query } - thirdPartyFilter := coredata.NewThirdPartyFilter(nil, firstLevel) + thirdPartyFilter := coredata.NewThirdPartyFilter(nil, firstLevel, query) page, err := r.probo.ThirdParties.ListForOrganizationID(ctx, scope, obj.ID, cursor, thirdPartyFilter) if err != nil { @@ -1304,7 +1308,7 @@ func (r *organizationResolver) ThirdParties(ctx context.Context, obj *types.Orga return nil, gqlutils.Internal(ctx) } - return types.NewThirdPartyConnection(page, r, obj.ID), nil + return types.NewThirdPartyConnection(page, r, obj.ID, thirdPartyFilter), nil } // ThirdPartiesDocument is the resolver for the thirdPartiesDocument field. diff --git a/pkg/server/api/console/v1/processing_activity_resolvers.go b/pkg/server/api/console/v1/processing_activity_resolvers.go index 52224700f..12dc962fa 100644 --- a/pkg/server/api/console/v1/processing_activity_resolvers.go +++ b/pkg/server/api/console/v1/processing_activity_resolvers.go @@ -223,7 +223,7 @@ func (r *processingActivityResolver) ThirdParties(ctx context.Context, obj *type return nil, gqlutils.Internal(ctx) } - return types.NewThirdPartyConnection(page, r, obj.ID), nil + return types.NewThirdPartyConnection(page, r, obj.ID, nil), nil } // DataProtectionImpactAssessment is the resolver for the dataProtectionImpactAssessment field. diff --git a/pkg/server/api/console/v1/third_party_resolvers.go b/pkg/server/api/console/v1/third_party_resolvers.go index 227da8a71..f7762a047 100644 --- a/pkg/server/api/console/v1/third_party_resolvers.go +++ b/pkg/server/api/console/v1/third_party_resolvers.go @@ -820,6 +820,40 @@ func (r *thirdPartyResolver) RiskAssessments(ctx context.Context, obj *types.Thi return types.NewThirdPartyRiskAssessmentConnection(page), nil } +// Measures is the resolver for the measures field. +func (r *thirdPartyResolver) Measures(ctx context.Context, obj *types.ThirdParty, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.MeasureOrderBy, filter *types.MeasureFilter) (*types.MeasureConnection, error) { + scope, err := r.authorize(ctx, obj.ID, probo.ActionMeasureList) + if err != nil { + return nil, err + } + + pageOrderBy := page.OrderBy[coredata.MeasureOrderField]{ + Field: coredata.MeasureOrderFieldCreatedAt, + Direction: page.OrderDirectionDesc, + } + if orderBy != nil { + pageOrderBy = page.OrderBy[coredata.MeasureOrderField]{ + Field: orderBy.Field, + Direction: orderBy.Direction, + } + } + + cursor := types.NewCursor(first, after, last, before, pageOrderBy) + + var measureFilter = coredata.NewMeasureFilter(nil, nil, nil) + if filter != nil { + measureFilter = coredata.NewMeasureFilter(filter.Query, filter.State, filter.Category) + } + + page, err := r.probo.Measures.ListForThirdPartyID(ctx, scope, obj.ID, cursor, measureFilter) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot list third party measures", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return types.NewMeasureConnection(page, r, obj.ID, measureFilter), nil +} + // BusinessOwner is the resolver for the businessOwner field. func (r *thirdPartyResolver) BusinessOwner(ctx context.Context, obj *types.ThirdParty) (*types.Profile, error) { if _, err := r.authorize(ctx, obj.ID, iam.ActionMembershipProfileGet); err != nil { @@ -898,7 +932,7 @@ func (r *thirdPartyResolver) ChildThirdParties(ctx context.Context, obj *types.T return nil, gqlutils.Internal(ctx) } - return types.NewThirdPartyConnection(page, r, obj.ID), nil + return types.NewThirdPartyConnection(page, r, obj.ID, nil), nil } // Permission is the resolver for the permission field. @@ -1012,7 +1046,7 @@ func (r *thirdPartyConnectionResolver) TotalCount(ctx context.Context, obj *type switch obj.Resolver.(type) { case *organizationResolver: - count, err := r.probo.ThirdParties.CountForOrganizationID(ctx, scope, obj.ParentID) + count, err := r.probo.ThirdParties.CountForOrganizationID(ctx, scope, obj.ParentID, obj.Filters) if err != nil { r.logger.ErrorCtx(ctx, "cannot count thirdParties", log.Error(err)) return 0, gqlutils.Internal(ctx) @@ -1043,7 +1077,14 @@ func (r *thirdPartyConnectionResolver) TotalCount(ctx context.Context, obj *type count, err := r.probo.ThirdParties.CountForParentThirdPartyID(ctx, scope, obj.ParentID) if err != nil { r.logger.ErrorCtx(ctx, "cannot count child third parties", log.Error(err)) + return 0, gqlutils.Internal(ctx) + } + return count, nil + case *measureResolver: + count, err := r.probo.ThirdParties.CountForMeasureID(ctx, scope, obj.ParentID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot count thirdParties", log.Error(err)) return 0, gqlutils.Internal(ctx) } @@ -1229,15 +1270,3 @@ type thirdPartyContactResolver struct{ *Resolver } type thirdPartyDataPrivacyAgreementResolver struct{ *Resolver } type thirdPartyRiskAssessmentResolver struct{ *Resolver } type thirdPartyServiceResolver struct{ *Resolver } - -// !!! WARNING !!! -// The code below was going to be deleted when updating resolvers. It has been copied here so you have -// one last chance to move it out of harms way if you want. There are two reasons this happens: -// - When renaming or deleting a resolver the old code will be put in here. You can safely delete -// it when you're done. -// - You have helper methods in this file. Move them out to keep these resolver files clean. -/* - func (r *mutationResolver) UncreateThirdPartyThirdPartyMapping(ctx context.Context, input types.UncreateThirdPartyThirdPartyMappingInput) (*types.UncreateThirdPartyThirdPartyMappingPayload, error) { - panic(fmt.Errorf("not implemented: UncreateThirdPartyThirdPartyMapping - uncreateThirdPartyThirdPartyMapping")) -} -*/ diff --git a/pkg/server/api/console/v1/types/third_party.go b/pkg/server/api/console/v1/types/third_party.go index 6cbefbfd9..d9fd6af9f 100644 --- a/pkg/server/api/console/v1/types/third_party.go +++ b/pkg/server/api/console/v1/types/third_party.go @@ -31,6 +31,7 @@ type ( Resolver any ParentID gid.GID + Filters *coredata.ThirdPartyFilter } ) @@ -38,6 +39,7 @@ func NewThirdPartyConnection( p *page.Page[*coredata.ThirdParty, coredata.ThirdPartyOrderField], parentType any, parentID gid.GID, + filters *coredata.ThirdPartyFilter, ) *ThirdPartyConnection { var edges = make([]*ThirdPartyEdge, len(p.Data)) @@ -51,6 +53,7 @@ func NewThirdPartyConnection( Resolver: parentType, ParentID: parentID, + Filters: filters, } } diff --git a/pkg/server/api/mcp/v1/schema.resolvers.go b/pkg/server/api/mcp/v1/schema.resolvers.go index 25323ebfb..1063e8fef 100644 --- a/pkg/server/api/mcp/v1/schema.resolvers.go +++ b/pkg/server/api/mcp/v1/schema.resolvers.go @@ -70,7 +70,7 @@ func (r *Resolver) ListThirdPartiesTool(ctx context.Context, req *mcp.CallToolRe cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy) - thirdPartyFilter := coredata.NewThirdPartyFilter(nil, input.FirstLevel) + thirdPartyFilter := coredata.NewThirdPartyFilter(nil, input.FirstLevel, nil) page, err := prb.ThirdParties.ListForOrganizationID(ctx, scope, input.OrganizationID, cursor, thirdPartyFilter) if err != nil { @@ -2664,6 +2664,14 @@ func (r *Resolver) LinkMeasureTool(ctx context.Context, req *mcp.CallToolRequest if _, _, err := svc.Measures.CreateDocumentMapping(ctx, scope, input.MeasureID, input.ResourceID); err != nil { return nil, types.LinkMeasureOutput{}, fmt.Errorf("failed to link measure to document: %w", err) } + case coredata.ThirdPartyEntityType: + if _, err := r.Authorize(ctx, input.MeasureID, probo.ActionMeasureThirdPartyMappingCreate); err != nil { + return nil, types.LinkMeasureOutput{}, err + } + + if _, _, err := svc.Measures.CreateThirdPartyMapping(ctx, scope, input.MeasureID, input.ResourceID); err != nil { + return nil, types.LinkMeasureOutput{}, fmt.Errorf("failed to link measure to third party: %w", err) + } default: return nil, types.LinkMeasureOutput{}, fmt.Errorf("unsupported resource type for measure linking: entity type %d", input.ResourceID.EntityType()) } @@ -2700,6 +2708,14 @@ func (r *Resolver) UnlinkMeasureTool(ctx context.Context, req *mcp.CallToolReque if _, _, err := svc.Measures.DeleteDocumentMapping(ctx, scope, input.MeasureID, input.ResourceID); err != nil { return nil, types.UnlinkMeasureOutput{}, fmt.Errorf("failed to unlink measure from document: %w", err) } + case coredata.ThirdPartyEntityType: + if _, err := r.Authorize(ctx, input.MeasureID, probo.ActionMeasureThirdPartyMappingDelete); err != nil { + return nil, types.UnlinkMeasureOutput{}, err + } + + if _, _, err := svc.Measures.DeleteThirdPartyMapping(ctx, scope, input.MeasureID, input.ResourceID); err != nil { + return nil, types.UnlinkMeasureOutput{}, fmt.Errorf("failed to unlink measure from third party: %w", err) + } default: return nil, types.UnlinkMeasureOutput{}, fmt.Errorf("unsupported resource type for measure unlinking: entity type %d", input.ResourceID.EntityType()) } diff --git a/pkg/server/api/mcp/v1/specification.yaml b/pkg/server/api/mcp/v1/specification.yaml index adc721aad..0c1abb3f9 100644 --- a/pkg/server/api/mcp/v1/specification.yaml +++ b/pkg/server/api/mcp/v1/specification.yaml @@ -2396,7 +2396,7 @@ components: description: Measure ID resource_id: $ref: "#/components/schemas/GID" - description: ID of the resource to link (control, risk, or document) + description: ID of the resource to link (control, risk, document, or third party) LinkMeasureOutput: type: object @@ -2412,7 +2412,7 @@ components: description: Measure ID resource_id: $ref: "#/components/schemas/GID" - description: ID of the resource to unlink (control, risk, or document) + description: ID of the resource to unlink (control, risk, document, or third party) UnlinkMeasureOutput: type: object @@ -12182,7 +12182,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListMeasureEvidencesOutput" - name: linkMeasure - description: Link a measure to a resource (control, risk, or document). The resource type is determined from the resource_id GID. + description: Link a measure to a resource (control, risk, document, or third party). The resource type is determined from the resource_id GID. hints: readonly: false inputSchema: @@ -12190,7 +12190,7 @@ tools: outputSchema: $ref: "#/components/schemas/LinkMeasureOutput" - name: unlinkMeasure - description: Unlink a measure from a resource (control, risk, or document). The resource type is determined from the resource_id GID. + description: Unlink a measure from a resource (control, risk, document, or third party). The resource type is determined from the resource_id GID. hints: readonly: false inputSchema: diff --git a/pkg/trust/third_party_service.go b/pkg/trust/third_party_service.go index 9b01ddb51..ba87635fe 100644 --- a/pkg/trust/third_party_service.go +++ b/pkg/trust/third_party_service.go @@ -65,7 +65,7 @@ func (s ThirdPartyService) ListForOrganizationId( ctx, func(ctx context.Context, conn pg.Querier) error { showOnTrustCenter := true - filter := coredata.NewThirdPartyFilter(&showOnTrustCenter, nil) + filter := coredata.NewThirdPartyFilter(&showOnTrustCenter, nil, nil) err := thirdParties.LoadByOrganizationID(ctx, conn, scope, organizationID, cursor, filter) if err != nil { @@ -99,7 +99,7 @@ func (s ThirdPartyService) CountForTrustCenterId( thirdParties := &coredata.ThirdParties{} showOnTrustCenter := true - filter := coredata.NewThirdPartyFilter(&showOnTrustCenter, nil) + filter := coredata.NewThirdPartyFilter(&showOnTrustCenter, nil, nil) count, err = thirdParties.CountByOrganizationID(ctx, conn, scope, trustCenter.OrganizationID, filter) if err != nil {