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 <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-05-22 16:07:48 +02:00
parent b6b1e801b1
commit 6dfdd7ca49
35 changed files with 2109 additions and 54 deletions

View File

@@ -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

View File

@@ -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) {
/>
<PageHeader title={measure.name} description={measure.description}>
{!measure.canUpdate && <MeasureBadge state={measure.state!} />}
{measure.canUpdate && (
<>
<MeasureFormDialog measure={measure}>
@@ -291,15 +298,16 @@ export default function MeasureDetailPage(props: Props) {
{__("Documents")}
<TabBadge>{documentsCount}</TabBadge>
</TabLink>
<TabLink
to={`/organizations/${organizationId}/measures/${measureId}/third-parties`}
>
<IconStore size={20} />
{__("Third parties")}
<TabBadge>{thirdPartiesCount}</TabBadge>
</TabLink>
</Tabs>
<Outlet context={{ measure }} />
<Drawer>
<PropertyRow label={__("State")}>
<MeasureBadge state={measure.state!} />
</PropertyRow>
</Drawer>
</div>
);
}

View File

@@ -0,0 +1,182 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { faviconUrl } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
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<Params> = (p: {
variables: {
input: {
thirdPartyId: string;
} & Params;
connections: string[];
};
}) => void;
type Props<Params> = {
thirdParties: (LinkedThirdPartiesCardFragment$key & { id: string })[];
params: Params;
disabled?: boolean;
connectionId: string;
onAttach: Mutation<Params>;
onDetach: Mutation<Params>;
readOnly?: boolean;
};
export function LinkedThirdPartiesCard<Params>(props: Props<Params>) {
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 (
<Table>
<Thead>
<Tr>
<Th>{__("Name")}</Th>
<Th>{__("Category")}</Th>
{!props.readOnly && <Th></Th>}
</Tr>
</Thead>
<Tbody>
{thirdParties.length === 0 && (
<Tr>
<Td
colSpan={props.readOnly ? 2 : 3}
className="text-center text-txt-secondary"
>
{__("No third parties linked")}
</Td>
</Tr>
)}
{thirdParties.map(thirdParty => (
<ThirdPartyRow
key={thirdParty.id}
thirdParty={thirdParty}
onClick={onDetach}
readOnly={props.readOnly}
/>
))}
{!props.readOnly && (
<LinkedThirdPartiesDialog
connectionId={props.connectionId}
disabled={props.disabled}
linkedThirdParties={thirdParties}
onLink={onAttach}
onUnlink={onDetach}
>
<TrButton colspan={3} icon={IconPlusLarge}>
{__("Link third party")}
</TrButton>
</LinkedThirdPartiesDialog>
)}
</Tbody>
</Table>
);
}
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 (
<Tr
to={`/organizations/${organizationId}/third-parties/${thirdParty.id}/overview`}
>
<Td>
<span className="inline-flex gap-2 items-center">
{logo && (
<img
src={logo}
alt={thirdParty.name}
className="rounded h-5 w-5"
/>
)}
{thirdParty.name}
</span>
</Td>
<Td>
<Badge size="md">{thirdParty.category}</Badge>
</Td>
{!props.readOnly && (
<Td noLink width={50} className="text-end">
<Button
variant="secondary"
onClick={() => props.onClick(thirdParty.id)}
icon={IconTrashCan}
>
{__("Unlink")}
</Button>
</Td>
)}
</Tr>
);
}

View File

@@ -0,0 +1,232 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { faviconUrl } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
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<HTMLDivElement>(null);
const [minHeight, setMinHeight] = useState(0);
const onSearch = (v: string) => {
setMinHeight(contentRef.current?.clientHeight ?? 0);
searchRef.current?.search(v);
};
return (
<Dialog trigger={props.children} title={__("Link third parties")}>
<DialogContent>
<div className="flex items-center gap-2 sticky top-0 py-4 bg-linear-to-b from-50% from-level-2 to-level-2/0 px-6">
<Input
icon={IconMagnifyingGlass}
placeholder={__("Search third parties...")}
onValueChange={onSearch}
/>
</div>
<div ref={contentRef}>
<Suspense
fallback={(
<div style={{ minHeight }}>
<Spinner centered />
</div>
)}
>
<LinkedThirdPartiesDialogContent {...props} ref={searchRef} />
</Suspense>
</div>
</DialogContent>
</Dialog>
);
}
function LinkedThirdPartiesDialogContent({
ref: searchRef,
...props
}: Props & { ref: SearchRef }) {
const organizationId = useOrganizationId();
const mainData = useLazyLoadQuery<LinkedThirdPartiesDialogQuery>(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 (
<div className="divide-y divide-border-low">
{thirdParties.map(thirdParty => (
<ThirdPartyRow
key={thirdParty.id}
thirdParty={thirdParty}
linkedIds={linkedIds}
{...props}
/>
))}
{hasNext && (
<InfiniteScrollTrigger
loading={isLoadingNext}
onView={() => loadNext(20)}
/>
)}
</div>
);
}
function ThirdPartyRow(
props: {
thirdParty: NodeOf<LinkedThirdPartiesDialogFragment$data["thirdParties"]>;
linkedIds: Set<string>;
} & 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 (
<button
type="button"
className="py-4 flex items-center gap-4 hover:bg-subtle cursor-pointer px-6 w-full text-start disabled:cursor-not-allowed disabled:opacity-50"
disabled={props.disabled}
onClick={() => onClick(props.thirdParty.id)}
>
{logo && (
<img src={logo} alt={props.thirdParty.name} className="rounded h-5 w-5" />
)}
{props.thirdParty.name}
<Badge>{props.thirdParty.category}</Badge>
<Button
disabled={props.disabled}
className="ml-auto"
variant={isLinked ? "secondary" : "primary"}
asChild
>
<span>
<IconComponent size={16} />
{" "}
{isLinked ? __("Unlink") : __("Link")}
</span>
</Button>
</button>
);
}

View File

@@ -0,0 +1,118 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { 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 (
<LinkedThirdPartiesCard
disabled={isLoading}
thirdParties={thirdParties}
onAttach={attachThirdParty}
onDetach={detachThirdParty}
params={{ measureId: data.id }}
connectionId={connectionId}
readOnly={readOnly}
/>
);
}

View File

@@ -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<ThirdPartyGraphNodeQuery>;
};
@@ -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) {
<TabLink to={`${baseThirdPartyUrl}/third-parties`}>
{__("Third Parties")}
</TabLink>
<TabLink to={`${baseThirdPartyUrl}/measures`}>
{__("Measures")}
{measuresCount > 0 && <TabBadge>{measuresCount}</TabBadge>}
</TabLink>
</Tabs>
<Outlet context={{ thirdParty }} />

View File

@@ -0,0 +1,117 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { 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 (
<LinkedMeasuresCard
disabled={isLoading}
measures={measures}
onAttach={attachMeasure}
onDetach={detachMeasure}
params={{ thirdPartyId: data.id }}
connectionId={connectionId}
readOnly={readOnly}
/>
);
}

View File

@@ -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[];

View File

@@ -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[];

View File

@@ -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")
})
}

View File

@@ -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,
};

View File

@@ -0,0 +1,81 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { proboApiRequest } from '../../GenericFunctions';
export const description: INodeProperties[] = [
{
displayName: '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<INodeExecutionData> {
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 },
};
}

View File

@@ -0,0 +1,71 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { proboApiRequest } from '../../GenericFunctions';
export const description: INodeProperties[] = [
{
displayName: '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<INodeExecutionData> {
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 },
};
}

View File

@@ -0,0 +1,97 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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
}

View File

@@ -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
}

View File

@@ -0,0 +1,93 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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
}

View File

@@ -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
}

View File

@@ -0,0 +1,114 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package coredata
import (
"context"
"fmt"
"maps"
"time"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
)
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
}

View File

@@ -0,0 +1,22 @@
-- Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
--
-- Permission to use, copy, modify, and/or distribute this software for any
-- purpose with or without fee is hereby granted, provided that the above
-- copyright notice and this permission notice appear in all copies.
--
-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
-- PERFORMANCE OF THIS SOFTWARE.
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)
);

View File

@@ -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
}

View File

@@ -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
)`
}

View File

@@ -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"

View File

@@ -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,

View File

@@ -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,

View File

@@ -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.

View File

@@ -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!
}

View File

@@ -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)

View File

@@ -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} }

View File

@@ -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.

View File

@@ -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.

View File

@@ -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"))
}
*/

View File

@@ -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,
}
}

View File

@@ -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())
}

View File

@@ -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:

View File

@@ -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 {