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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 }} />
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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[];
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
97
pkg/cmd/measure/link-third-party/link_third_party.go
vendored
Normal file
97
pkg/cmd/measure/link-third-party/link_third_party.go
vendored
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
93
pkg/cmd/measure/unlink-third-party/unlink_third_party.go
vendored
Normal file
93
pkg/cmd/measure/unlink-third-party/unlink_third_party.go
vendored
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
114
pkg/coredata/measure_third_party.go
Normal file
114
pkg/coredata/measure_third_party.go
Normal 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
|
||||
}
|
||||
22
pkg/coredata/migrations/20260522T120000Z.sql
Normal file
22
pkg/coredata/migrations/20260522T120000Z.sql
Normal 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)
|
||||
);
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)`
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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!
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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} }
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user