Add measure-document linking
Introduce the ability to link measures to documents, following the existing pattern used by controls and risks. This includes: - Database migration for measures_documents join table - Coredata MeasureDocument struct with insert/delete operations - Document service methods for listing/counting by measure ID - Measure service CreateDocumentMapping/DeleteDocumentMapping methods - Cleanup of measure-document mappings on document archive - GraphQL mutations, inputs, payloads, and Measure.documents field - DocumentConnection.TotalCount support for measure resolver - MCP linkMeasure/unlinkMeasure updated to support documents - MCP listMeasureDocuments tool - Frontend MeasureDocumentsTab with LinkedDocumentsCard integration - Authorization actions for measure document mapping - E2e tests for measure document mapping Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
@@ -19,8 +19,6 @@ import type { MeasureGraphDeleteMutation } from "#/__generated__/core/MeasureGra
|
|||||||
|
|
||||||
import { useMutationWithToasts } from "../useMutationWithToasts";
|
import { useMutationWithToasts } from "../useMutationWithToasts";
|
||||||
|
|
||||||
/* eslint-disable relay/unused-fields, relay/must-colocate-fragment-spreads */
|
|
||||||
|
|
||||||
export const MeasureConnectionKey = "MeasuresPage_measures";
|
export const MeasureConnectionKey = "MeasuresPage_measures";
|
||||||
|
|
||||||
const deleteMeasureMutation = graphql`
|
const deleteMeasureMutation = graphql`
|
||||||
@@ -46,36 +44,6 @@ export function useDeleteMeasureMutation() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const measureNodeQuery = graphql`
|
|
||||||
query MeasureGraphNodeQuery($measureId: ID!) {
|
|
||||||
node(id: $measureId) {
|
|
||||||
... on Measure {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
description
|
|
||||||
state
|
|
||||||
category
|
|
||||||
canUpdate: permission(action: "core:measure:update")
|
|
||||||
canDelete: permission(action: "core:measure:delete")
|
|
||||||
canListTasks: permission(action: "core:task:list")
|
|
||||||
evidencesInfos: evidences(first: 0) {
|
|
||||||
totalCount
|
|
||||||
}
|
|
||||||
risksInfos: risks(first: 0) {
|
|
||||||
totalCount
|
|
||||||
}
|
|
||||||
controlsInfos: controls(first: 0) {
|
|
||||||
totalCount
|
|
||||||
}
|
|
||||||
...MeasureRisksTabFragment
|
|
||||||
...MeasureControlsTabFragment
|
|
||||||
...MeasureFormDialogMeasureFragment
|
|
||||||
...MeasureEvidencesTabFragment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const measureUpdateMutation = graphql`
|
const measureUpdateMutation = graphql`
|
||||||
mutation MeasureGraphUpdateMutation($input: UpdateMeasureInput!) {
|
mutation MeasureGraphUpdateMutation($input: UpdateMeasureInput!) {
|
||||||
updateMeasure(input: $input) {
|
updateMeasure(input: $input) {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
DropdownItem,
|
DropdownItem,
|
||||||
IconCheckmark1,
|
IconCheckmark1,
|
||||||
IconFrame2,
|
IconFrame2,
|
||||||
|
IconPageCheck,
|
||||||
IconPageTextLine,
|
IconPageTextLine,
|
||||||
IconPencil,
|
IconPencil,
|
||||||
IconTrashCan,
|
IconTrashCan,
|
||||||
@@ -50,17 +51,58 @@ import {
|
|||||||
} from "react-relay";
|
} from "react-relay";
|
||||||
import { Outlet, useNavigate, useParams } from "react-router";
|
import { Outlet, useNavigate, useParams } from "react-router";
|
||||||
|
|
||||||
|
import type { MeasureDetailPageNodeQuery } from "#/__generated__/core/MeasureDetailPageNodeQuery.graphql";
|
||||||
import type { MeasureDetailPageTasksCountQuery } from "#/__generated__/core/MeasureDetailPageTasksCountQuery.graphql";
|
import type { MeasureDetailPageTasksCountQuery } from "#/__generated__/core/MeasureDetailPageTasksCountQuery.graphql";
|
||||||
import type { MeasureGraphNodeQuery } from "#/__generated__/core/MeasureGraphNodeQuery.graphql";
|
|
||||||
import {
|
import {
|
||||||
MeasureConnectionKey,
|
MeasureConnectionKey,
|
||||||
measureNodeQuery,
|
|
||||||
useDeleteMeasureMutation,
|
useDeleteMeasureMutation,
|
||||||
useUpdateMeasure,
|
useUpdateMeasure,
|
||||||
} from "#/hooks/graph/MeasureGraph";
|
} from "#/hooks/graph/MeasureGraph";
|
||||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||||
|
|
||||||
import MeasureFormDialog from "./dialog/MeasureFormDialog";
|
import MeasureFormDialog from "./dialog/MeasureFormDialog";
|
||||||
|
import { controlsFragment } from "./tabs/MeasureControlsTab";
|
||||||
|
import { documentsFragment } from "./tabs/MeasureDocumentsTab";
|
||||||
|
import { evidencesFragment } from "./tabs/MeasureEvidencesTab";
|
||||||
|
import { risksFragment } from "./tabs/MeasureRisksTab";
|
||||||
|
|
||||||
|
void controlsFragment;
|
||||||
|
void documentsFragment;
|
||||||
|
void evidencesFragment;
|
||||||
|
void risksFragment;
|
||||||
|
|
||||||
|
export const measureNodeQuery = graphql`
|
||||||
|
query MeasureDetailPageNodeQuery($measureId: ID!) {
|
||||||
|
node(id: $measureId) {
|
||||||
|
... on Measure {
|
||||||
|
name
|
||||||
|
description
|
||||||
|
state
|
||||||
|
category
|
||||||
|
canUpdate: permission(action: "core:measure:update")
|
||||||
|
canDelete: permission(action: "core:measure:delete")
|
||||||
|
canListTasks: permission(action: "core:task:list")
|
||||||
|
evidencesInfos: evidences(first: 0) {
|
||||||
|
totalCount
|
||||||
|
}
|
||||||
|
risksInfos: risks(first: 0) {
|
||||||
|
totalCount
|
||||||
|
}
|
||||||
|
controlsInfos: controls(first: 0) {
|
||||||
|
totalCount
|
||||||
|
}
|
||||||
|
documentsInfos: documents(first: 0) {
|
||||||
|
totalCount
|
||||||
|
}
|
||||||
|
...MeasureRisksTabFragment
|
||||||
|
...MeasureControlsTabFragment
|
||||||
|
...MeasureDocumentsTabFragment
|
||||||
|
...MeasureFormDialogMeasureFragment
|
||||||
|
...MeasureEvidencesTabFragment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
const tasksCountQuery = graphql`
|
const tasksCountQuery = graphql`
|
||||||
query MeasureDetailPageTasksCountQuery($measureId: ID!) {
|
query MeasureDetailPageTasksCountQuery($measureId: ID!) {
|
||||||
@@ -84,7 +126,7 @@ function TasksCountBadge({ measureId }: { measureId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
queryRef: PreloadedQuery<MeasureGraphNodeQuery>;
|
queryRef: PreloadedQuery<MeasureDetailPageNodeQuery>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function MeasureDetailPage(props: Props) {
|
export default function MeasureDetailPage(props: Props) {
|
||||||
@@ -106,6 +148,7 @@ export default function MeasureDetailPage(props: Props) {
|
|||||||
const evidencesCount = measure.evidencesInfos?.totalCount ?? 0;
|
const evidencesCount = measure.evidencesInfos?.totalCount ?? 0;
|
||||||
const controlsCount = measure.controlsInfos?.totalCount ?? 0;
|
const controlsCount = measure.controlsInfos?.totalCount ?? 0;
|
||||||
const risksCount = measure.risksInfos?.totalCount ?? 0;
|
const risksCount = measure.risksInfos?.totalCount ?? 0;
|
||||||
|
const documentsCount = measure.documentsInfos?.totalCount ?? 0;
|
||||||
|
|
||||||
const onDelete = () => {
|
const onDelete = () => {
|
||||||
const connectionId = ConnectionHandler.getConnectionID(
|
const connectionId = ConnectionHandler.getConnectionID(
|
||||||
@@ -212,7 +255,7 @@ export default function MeasureDetailPage(props: Props) {
|
|||||||
<TabLink
|
<TabLink
|
||||||
to={`/organizations/${organizationId}/measures/${measureId}/evidences`}
|
to={`/organizations/${organizationId}/measures/${measureId}/evidences`}
|
||||||
>
|
>
|
||||||
<IconPageTextLine size={20} />
|
<IconPageCheck size={20} />
|
||||||
{__("Evidences")}
|
{__("Evidences")}
|
||||||
<TabBadge>{evidencesCount}</TabBadge>
|
<TabBadge>{evidencesCount}</TabBadge>
|
||||||
</TabLink>
|
</TabLink>
|
||||||
@@ -241,6 +284,13 @@ export default function MeasureDetailPage(props: Props) {
|
|||||||
{__("Risks")}
|
{__("Risks")}
|
||||||
<TabBadge>{risksCount}</TabBadge>
|
<TabBadge>{risksCount}</TabBadge>
|
||||||
</TabLink>
|
</TabLink>
|
||||||
|
<TabLink
|
||||||
|
to={`/organizations/${organizationId}/measures/${measureId}/documents`}
|
||||||
|
>
|
||||||
|
<IconPageTextLine size={20} />
|
||||||
|
{__("Documents")}
|
||||||
|
<TabBadge>{documentsCount}</TabBadge>
|
||||||
|
</TabLink>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<Outlet context={{ measure }} />
|
<Outlet context={{ measure }} />
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// 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 } from "react-router";
|
||||||
|
|
||||||
|
import type { MeasureDocumentsTabFragment$key } from "#/__generated__/core/MeasureDocumentsTabFragment.graphql";
|
||||||
|
import { LinkedDocumentsCard } from "#/components/documents/LinkedDocumentsCard";
|
||||||
|
import { useMutationWithIncrement } from "#/hooks/useMutationWithIncrement";
|
||||||
|
|
||||||
|
export const documentsFragment = graphql`
|
||||||
|
fragment MeasureDocumentsTabFragment on Measure {
|
||||||
|
id
|
||||||
|
canCreateDocumentMapping: permission(
|
||||||
|
action: "core:measure:create-document-mapping"
|
||||||
|
)
|
||||||
|
canDeleteDocumentMapping: permission(
|
||||||
|
action: "core:measure:delete-document-mapping"
|
||||||
|
)
|
||||||
|
documents(first: 100) @connection(key: "Measure__documents") {
|
||||||
|
__id
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
...LinkedDocumentsCardFragment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const attachDocumentMutation = graphql`
|
||||||
|
mutation MeasureDocumentsTabCreateMutation(
|
||||||
|
$input: CreateMeasureDocumentMappingInput!
|
||||||
|
$connections: [ID!]!
|
||||||
|
) {
|
||||||
|
createMeasureDocumentMapping(input: $input) {
|
||||||
|
documentEdge @prependEdge(connections: $connections) {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
...LinkedDocumentsCardFragment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const detachDocumentMutation = graphql`
|
||||||
|
mutation MeasureDocumentsTabDetachMutation(
|
||||||
|
$input: DeleteMeasureDocumentMappingInput!
|
||||||
|
$connections: [ID!]!
|
||||||
|
) {
|
||||||
|
deleteMeasureDocumentMapping(input: $input) {
|
||||||
|
deletedDocumentId @deleteEdge(connections: $connections)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default function MeasureDocumentsTab() {
|
||||||
|
const { measure } = useOutletContext<{
|
||||||
|
measure: MeasureDocumentsTabFragment$key;
|
||||||
|
}>();
|
||||||
|
const data = useFragment<MeasureDocumentsTabFragment$key>(
|
||||||
|
documentsFragment,
|
||||||
|
measure,
|
||||||
|
);
|
||||||
|
const connectionId = data.documents.__id;
|
||||||
|
const documents = data.documents?.edges?.map(edge => edge.node) ?? [];
|
||||||
|
|
||||||
|
const canLinkDocument = data.canCreateDocumentMapping;
|
||||||
|
const canUnlinkDocument = data.canDeleteDocumentMapping;
|
||||||
|
const readOnly = !canLinkDocument && !canUnlinkDocument;
|
||||||
|
|
||||||
|
const incrementOptions = {
|
||||||
|
id: data.id,
|
||||||
|
node: "documents(first:0)",
|
||||||
|
};
|
||||||
|
const [detachDocument, isDetaching] = useMutationWithIncrement(
|
||||||
|
detachDocumentMutation,
|
||||||
|
{
|
||||||
|
...incrementOptions,
|
||||||
|
value: -1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const [attachDocument, isAttaching] = useMutationWithIncrement(
|
||||||
|
attachDocumentMutation,
|
||||||
|
{
|
||||||
|
...incrementOptions,
|
||||||
|
value: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const isLoading = isDetaching || isAttaching;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LinkedDocumentsCard
|
||||||
|
disabled={isLoading}
|
||||||
|
documents={documents}
|
||||||
|
onAttach={attachDocument}
|
||||||
|
onDetach={detachDocument}
|
||||||
|
params={{ measureId: data.id }}
|
||||||
|
connectionId={connectionId}
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,11 +22,11 @@ import { Fragment } from "react";
|
|||||||
import { loadQuery } from "react-relay";
|
import { loadQuery } from "react-relay";
|
||||||
import { redirect } from "react-router";
|
import { redirect } from "react-router";
|
||||||
|
|
||||||
import type { MeasureGraphNodeQuery } from "#/__generated__/core/MeasureGraphNodeQuery.graphql";
|
import type { MeasureDetailPageNodeQuery } from "#/__generated__/core/MeasureDetailPageNodeQuery.graphql";
|
||||||
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
|
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
|
||||||
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
|
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
|
||||||
import { coreEnvironment } from "#/environments";
|
import { coreEnvironment } from "#/environments";
|
||||||
import { measureNodeQuery } from "#/hooks/graph/MeasureGraph";
|
import { measureNodeQuery } from "#/pages/organizations/measures/MeasureDetailPage";
|
||||||
|
|
||||||
export const measureRoutes = [
|
export const measureRoutes = [
|
||||||
{
|
{
|
||||||
@@ -41,7 +41,7 @@ export const measureRoutes = [
|
|||||||
path: "measures/:measureId",
|
path: "measures/:measureId",
|
||||||
Fallback: PageSkeleton,
|
Fallback: PageSkeleton,
|
||||||
loader: loaderFromQueryLoader(({ measureId }) =>
|
loader: loaderFromQueryLoader(({ measureId }) =>
|
||||||
loadQuery<MeasureGraphNodeQuery>(coreEnvironment, measureNodeQuery, {
|
loadQuery<MeasureDetailPageNodeQuery>(coreEnvironment, measureNodeQuery, {
|
||||||
measureId: measureId,
|
measureId: measureId,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -89,6 +89,14 @@ export const measureRoutes = [
|
|||||||
import("#/pages/organizations/measures/tabs/MeasureEvidencesTab"),
|
import("#/pages/organizations/measures/tabs/MeasureEvidencesTab"),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "documents",
|
||||||
|
Fallback: LinkCardSkeleton,
|
||||||
|
Component: lazy(
|
||||||
|
() =>
|
||||||
|
import("#/pages/organizations/measures/tabs/MeasureDocumentsTab"),
|
||||||
|
),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
] satisfies AppRoute[];
|
] satisfies AppRoute[];
|
||||||
|
|||||||
@@ -945,3 +945,93 @@ func TestRiskObligationMapping_CreateDelete(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMeasureDocumentMapping_CreateDelete(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
|
measureID := factory.NewMeasure(owner).Create()
|
||||||
|
|
||||||
|
t.Run("create mapping", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
documentID := factory.NewDocument(owner).Create()
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
CreateMeasureDocumentMapping struct {
|
||||||
|
MeasureEdge struct {
|
||||||
|
Node struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"node"`
|
||||||
|
} `json:"measureEdge"`
|
||||||
|
DocumentEdge struct {
|
||||||
|
Node struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"node"`
|
||||||
|
} `json:"documentEdge"`
|
||||||
|
} `json:"createMeasureDocumentMapping"`
|
||||||
|
}
|
||||||
|
err := owner.Execute(`
|
||||||
|
mutation($input: CreateMeasureDocumentMappingInput!) {
|
||||||
|
createMeasureDocumentMapping(input: $input) {
|
||||||
|
measureEdge {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
documentEdge {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`, map[string]any{
|
||||||
|
"input": map[string]any{
|
||||||
|
"measureId": measureID,
|
||||||
|
"documentId": documentID,
|
||||||
|
},
|
||||||
|
}, &result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, measureID, result.CreateMeasureDocumentMapping.MeasureEdge.Node.ID)
|
||||||
|
assert.Equal(t, documentID, result.CreateMeasureDocumentMapping.DocumentEdge.Node.ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("delete mapping", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
documentID := factory.NewDocument(owner).Create()
|
||||||
|
|
||||||
|
// Create the mapping first
|
||||||
|
_, err := owner.Do(`
|
||||||
|
mutation($input: CreateMeasureDocumentMappingInput!) {
|
||||||
|
createMeasureDocumentMapping(input: $input) {
|
||||||
|
documentEdge {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`, map[string]any{
|
||||||
|
"input": map[string]any{
|
||||||
|
"measureId": measureID,
|
||||||
|
"documentId": documentID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Delete it
|
||||||
|
_, err = owner.Do(`
|
||||||
|
mutation($input: DeleteMeasureDocumentMappingInput!) {
|
||||||
|
deleteMeasureDocumentMapping(input: $input) {
|
||||||
|
deletedMeasureId
|
||||||
|
deletedDocumentId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`, map[string]any{
|
||||||
|
"input": map[string]any{
|
||||||
|
"measureId": measureID,
|
||||||
|
"documentId": documentID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -837,6 +837,103 @@ WHERE rp.risk_id = @risk_id
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *Documents) CountByMeasureID(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Querier,
|
||||||
|
scope Scoper,
|
||||||
|
measureID gid.GID,
|
||||||
|
filter *DocumentFilter,
|
||||||
|
) (int, error) {
|
||||||
|
q := `
|
||||||
|
WITH scoped_documents AS (
|
||||||
|
SELECT *
|
||||||
|
FROM documents
|
||||||
|
WHERE %s
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
AND %s
|
||||||
|
)
|
||||||
|
SELECT COUNT(scoped_documents.id)
|
||||||
|
FROM scoped_documents
|
||||||
|
INNER JOIN measures_documents md ON scoped_documents.id = md.document_id
|
||||||
|
WHERE md.measure_id = @measure_id
|
||||||
|
`
|
||||||
|
|
||||||
|
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
|
||||||
|
|
||||||
|
args := pgx.NamedArgs{"measure_id": measureID}
|
||||||
|
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 (p *Documents) LoadByMeasureID(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Querier,
|
||||||
|
scope Scoper,
|
||||||
|
measureID gid.GID,
|
||||||
|
cursor *page.Cursor[DocumentOrderField],
|
||||||
|
filter *DocumentFilter,
|
||||||
|
) error {
|
||||||
|
q := `
|
||||||
|
WITH latest_versions AS (
|
||||||
|
SELECT DISTINCT ON (document_id) document_id, document_type
|
||||||
|
FROM document_versions
|
||||||
|
ORDER BY document_id, major DESC, minor DESC
|
||||||
|
),
|
||||||
|
scoped_documents AS (
|
||||||
|
SELECT *
|
||||||
|
FROM documents
|
||||||
|
WHERE %s
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
AND %s
|
||||||
|
AND %s
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
scoped_documents.id,
|
||||||
|
scoped_documents.organization_id,
|
||||||
|
scoped_documents.title,
|
||||||
|
scoped_documents.current_published_major,
|
||||||
|
scoped_documents.current_published_minor,
|
||||||
|
scoped_documents.trust_center_visibility,
|
||||||
|
scoped_documents.status,
|
||||||
|
scoped_documents.archived_at,
|
||||||
|
scoped_documents.created_at,
|
||||||
|
scoped_documents.updated_at,
|
||||||
|
COALESCE(lv.document_type, 'OTHER') AS document_type
|
||||||
|
FROM scoped_documents
|
||||||
|
INNER JOIN measures_documents md ON scoped_documents.id = md.document_id
|
||||||
|
LEFT JOIN latest_versions lv ON lv.document_id = scoped_documents.id
|
||||||
|
WHERE md.measure_id = @measure_id
|
||||||
|
`
|
||||||
|
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment())
|
||||||
|
|
||||||
|
args := pgx.NamedArgs{"measure_id": measureID}
|
||||||
|
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 documents: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
documents, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Document])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot collect documents: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
*p = documents
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Documents) BulkSoftDelete(
|
func (p *Documents) BulkSoftDelete(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
conn pg.Tx,
|
conn pg.Tx,
|
||||||
|
|||||||
144
pkg/coredata/measure_document.go
Normal file
144
pkg/coredata/measure_document.go
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
// 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"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"maps"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"go.gearno.de/kit/pg"
|
||||||
|
"go.probo.inc/probo/pkg/gid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
MeasureDocument struct {
|
||||||
|
MeasureID gid.GID `db:"measure_id"`
|
||||||
|
DocumentID gid.GID `db:"document_id"`
|
||||||
|
OrganizationID gid.GID `db:"organization_id"`
|
||||||
|
TenantID gid.TenantID `db:"tenant_id"`
|
||||||
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
MeasureDocuments []*MeasureDocument
|
||||||
|
)
|
||||||
|
|
||||||
|
func (md MeasureDocument) Insert(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Tx,
|
||||||
|
scope Scoper,
|
||||||
|
) error {
|
||||||
|
q := `
|
||||||
|
INSERT INTO
|
||||||
|
measures_documents (
|
||||||
|
measure_id,
|
||||||
|
document_id,
|
||||||
|
organization_id,
|
||||||
|
tenant_id,
|
||||||
|
created_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
@measure_id,
|
||||||
|
@document_id,
|
||||||
|
@organization_id,
|
||||||
|
@tenant_id,
|
||||||
|
@created_at
|
||||||
|
);
|
||||||
|
`
|
||||||
|
|
||||||
|
args := pgx.StrictNamedArgs{
|
||||||
|
"measure_id": md.MeasureID,
|
||||||
|
"document_id": md.DocumentID,
|
||||||
|
"organization_id": md.OrganizationID,
|
||||||
|
"tenant_id": scope.GetTenantID(),
|
||||||
|
"created_at": md.CreatedAt,
|
||||||
|
}
|
||||||
|
_, err := conn.Exec(ctx, q, args)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) {
|
||||||
|
if pgErr.Code == "23505" && pgErr.ConstraintName == "measures_documents_pkey" {
|
||||||
|
return ErrResourceAlreadyExists
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("cannot insert measure document: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (md MeasureDocument) Delete(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Tx,
|
||||||
|
scope Scoper,
|
||||||
|
measureID gid.GID,
|
||||||
|
documentID gid.GID,
|
||||||
|
) error {
|
||||||
|
q := `
|
||||||
|
DELETE
|
||||||
|
FROM
|
||||||
|
measures_documents
|
||||||
|
WHERE
|
||||||
|
%s
|
||||||
|
AND measure_id = @measure_id
|
||||||
|
AND document_id = @document_id;
|
||||||
|
`
|
||||||
|
|
||||||
|
args := pgx.StrictNamedArgs{
|
||||||
|
"measure_id": measureID,
|
||||||
|
"document_id": documentID,
|
||||||
|
}
|
||||||
|
maps.Copy(args, scope.SQLArguments())
|
||||||
|
|
||||||
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||||
|
|
||||||
|
_, err := conn.Exec(ctx, q, args)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (md MeasureDocument) DeleteByDocumentIDs(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Tx,
|
||||||
|
scope Scoper,
|
||||||
|
documentIDs []gid.GID,
|
||||||
|
) error {
|
||||||
|
q := `
|
||||||
|
DELETE
|
||||||
|
FROM
|
||||||
|
measures_documents
|
||||||
|
WHERE
|
||||||
|
%s
|
||||||
|
AND document_id = ANY(@document_ids);
|
||||||
|
`
|
||||||
|
|
||||||
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||||
|
|
||||||
|
args := pgx.StrictNamedArgs{
|
||||||
|
"document_ids": documentIDs,
|
||||||
|
}
|
||||||
|
maps.Copy(args, scope.SQLArguments())
|
||||||
|
|
||||||
|
if _, err := conn.Exec(ctx, q, args); err != nil {
|
||||||
|
return fmt.Errorf("cannot delete measure document mappings by document ids: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
22
pkg/coredata/migrations/20260403T120000Z.sql
Normal file
22
pkg/coredata/migrations/20260403T120000Z.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_documents (
|
||||||
|
measure_id TEXT NOT NULL REFERENCES measures(id) ON DELETE CASCADE,
|
||||||
|
document_id TEXT NOT NULL REFERENCES documents(id),
|
||||||
|
organization_id TEXT NOT NULL,
|
||||||
|
tenant_id TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
PRIMARY KEY (measure_id, document_id)
|
||||||
|
);
|
||||||
@@ -161,6 +161,8 @@ const (
|
|||||||
ActionMeasureDelete = "core:measure:delete"
|
ActionMeasureDelete = "core:measure:delete"
|
||||||
ActionMeasureEvidenceUpload = "core:measure:upload-evidence"
|
ActionMeasureEvidenceUpload = "core:measure:upload-evidence"
|
||||||
ActionMeasureImport = "core:measure:import"
|
ActionMeasureImport = "core:measure:import"
|
||||||
|
ActionMeasureDocumentMappingCreate = "core:measure:create-document-mapping"
|
||||||
|
ActionMeasureDocumentMappingDelete = "core:measure:delete-document-mapping"
|
||||||
|
|
||||||
// Task actions
|
// Task actions
|
||||||
ActionTaskGet = "core:task:get"
|
ActionTaskGet = "core:task:get"
|
||||||
|
|||||||
@@ -1133,6 +1133,11 @@ func (s *DocumentService) BulkArchive(
|
|||||||
return fmt.Errorf("cannot delete risk mappings: %w", err)
|
return fmt.Errorf("cannot delete risk mappings: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
measureDocument := coredata.MeasureDocument{}
|
||||||
|
if err := measureDocument.DeleteByDocumentIDs(ctx, tx, s.svc.scope, documentIDs); err != nil {
|
||||||
|
return fmt.Errorf("cannot delete measure mappings: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return documents.BulkArchive(ctx, tx, s.svc.scope)
|
return documents.BulkArchive(ctx, tx, s.svc.scope)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -1532,6 +1537,58 @@ func (s *DocumentService) ListForRiskID(
|
|||||||
return page.NewPage(documents, cursor), nil
|
return page.NewPage(documents, cursor), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *DocumentService) CountForMeasureID(
|
||||||
|
ctx context.Context,
|
||||||
|
measureID gid.GID,
|
||||||
|
filter *coredata.DocumentFilter,
|
||||||
|
) (int, error) {
|
||||||
|
var count int
|
||||||
|
|
||||||
|
err := s.svc.pg.WithConn(
|
||||||
|
ctx,
|
||||||
|
func(ctx context.Context, conn pg.Querier) (err error) {
|
||||||
|
documents := &coredata.Documents{}
|
||||||
|
count, err = documents.CountByMeasureID(ctx, conn, s.svc.scope, measureID, filter)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot count documents: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DocumentService) ListForMeasureID(
|
||||||
|
ctx context.Context,
|
||||||
|
measureID gid.GID,
|
||||||
|
cursor *page.Cursor[coredata.DocumentOrderField],
|
||||||
|
filter *coredata.DocumentFilter,
|
||||||
|
) (*page.Page[*coredata.Document, coredata.DocumentOrderField], error) {
|
||||||
|
var documents coredata.Documents
|
||||||
|
|
||||||
|
err := s.svc.pg.WithConn(
|
||||||
|
ctx,
|
||||||
|
func(ctx context.Context, conn pg.Querier) error {
|
||||||
|
if err := documents.LoadByMeasureID(ctx, conn, s.svc.scope, measureID, cursor, filter); err != nil {
|
||||||
|
return fmt.Errorf("cannot list documents for measure: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return page.NewPage(documents, cursor), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *DocumentService) Update(
|
func (s *DocumentService) Update(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req UpdateDocumentRequest,
|
req UpdateDocumentRequest,
|
||||||
@@ -1618,6 +1675,11 @@ func (s *DocumentService) Archive(
|
|||||||
return fmt.Errorf("cannot delete risk mappings: %w", err)
|
return fmt.Errorf("cannot delete risk mappings: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
measureDocument := coredata.MeasureDocument{}
|
||||||
|
if err := measureDocument.DeleteByDocumentIDs(ctx, tx, s.svc.scope, []gid.GID{documentID}); err != nil {
|
||||||
|
return fmt.Errorf("cannot delete measure mappings: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
document.Status = coredata.DocumentStatusArchived
|
document.Status = coredata.DocumentStatusArchived
|
||||||
document.ArchivedAt = &now
|
document.ArchivedAt = &now
|
||||||
document.UpdatedAt = now
|
document.UpdatedAt = now
|
||||||
|
|||||||
@@ -601,3 +601,80 @@ func (s MeasureService) Delete(
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s MeasureService) CreateDocumentMapping(
|
||||||
|
ctx context.Context,
|
||||||
|
measureID gid.GID,
|
||||||
|
documentID gid.GID,
|
||||||
|
) (*coredata.Measure, *coredata.Document, error) {
|
||||||
|
measure := &coredata.Measure{}
|
||||||
|
document := &coredata.Document{}
|
||||||
|
|
||||||
|
err := s.svc.pg.WithTx(
|
||||||
|
ctx,
|
||||||
|
func(ctx context.Context, tx pg.Tx) error {
|
||||||
|
if err := measure.LoadByID(ctx, tx, s.svc.scope, measureID); err != nil {
|
||||||
|
return fmt.Errorf("cannot load measure: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := document.LoadByID(ctx, tx, s.svc.scope, documentID); err != nil {
|
||||||
|
return fmt.Errorf("cannot load document: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
measureDocument := &coredata.MeasureDocument{
|
||||||
|
MeasureID: measure.ID,
|
||||||
|
DocumentID: document.ID,
|
||||||
|
OrganizationID: measure.OrganizationID,
|
||||||
|
TenantID: s.svc.scope.GetTenantID(),
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := measureDocument.Insert(ctx, tx, s.svc.scope); err != nil {
|
||||||
|
return fmt.Errorf("cannot insert measure document: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return measure, document, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s MeasureService) DeleteDocumentMapping(
|
||||||
|
ctx context.Context,
|
||||||
|
measureID gid.GID,
|
||||||
|
documentID gid.GID,
|
||||||
|
) (*coredata.Measure, *coredata.Document, error) {
|
||||||
|
measure := &coredata.Measure{}
|
||||||
|
document := &coredata.Document{}
|
||||||
|
|
||||||
|
err := s.svc.pg.WithTx(
|
||||||
|
ctx,
|
||||||
|
func(ctx context.Context, tx pg.Tx) error {
|
||||||
|
if err := measure.LoadByID(ctx, tx, s.svc.scope, measureID); err != nil {
|
||||||
|
return fmt.Errorf("cannot load measure: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := document.LoadByID(ctx, tx, s.svc.scope, documentID); err != nil {
|
||||||
|
return fmt.Errorf("cannot load document: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
measureDocument := &coredata.MeasureDocument{}
|
||||||
|
if err := measureDocument.Delete(ctx, tx, s.svc.scope, measure.ID, document.ID); err != nil {
|
||||||
|
return fmt.Errorf("cannot delete measure document mapping: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return measure, document, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -2492,6 +2492,15 @@ type Measure implements Node {
|
|||||||
filter: ControlFilter
|
filter: ControlFilter
|
||||||
): ControlConnection! @goField(forceResolver: true)
|
): ControlConnection! @goField(forceResolver: true)
|
||||||
|
|
||||||
|
documents(
|
||||||
|
first: Int
|
||||||
|
after: CursorKey
|
||||||
|
last: Int
|
||||||
|
before: CursorKey
|
||||||
|
orderBy: DocumentOrder
|
||||||
|
filter: DocumentFilter
|
||||||
|
): DocumentConnection! @goField(forceResolver: true)
|
||||||
|
|
||||||
createdAt: Datetime!
|
createdAt: Datetime!
|
||||||
updatedAt: Datetime!
|
updatedAt: Datetime!
|
||||||
|
|
||||||
@@ -3830,6 +3839,12 @@ type Mutation {
|
|||||||
deleteRiskDocumentMapping(
|
deleteRiskDocumentMapping(
|
||||||
input: DeleteRiskDocumentMappingInput!
|
input: DeleteRiskDocumentMappingInput!
|
||||||
): DeleteRiskDocumentMappingPayload!
|
): DeleteRiskDocumentMappingPayload!
|
||||||
|
createMeasureDocumentMapping(
|
||||||
|
input: CreateMeasureDocumentMappingInput!
|
||||||
|
): CreateMeasureDocumentMappingPayload!
|
||||||
|
deleteMeasureDocumentMapping(
|
||||||
|
input: DeleteMeasureDocumentMappingInput!
|
||||||
|
): DeleteMeasureDocumentMappingPayload!
|
||||||
createRiskObligationMapping(
|
createRiskObligationMapping(
|
||||||
input: CreateRiskObligationMappingInput!
|
input: CreateRiskObligationMappingInput!
|
||||||
): CreateRiskObligationMappingPayload!
|
): CreateRiskObligationMappingPayload!
|
||||||
@@ -4571,6 +4586,16 @@ input DeleteRiskDocumentMappingInput {
|
|||||||
documentId: ID!
|
documentId: ID!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input CreateMeasureDocumentMappingInput {
|
||||||
|
measureId: ID!
|
||||||
|
documentId: ID!
|
||||||
|
}
|
||||||
|
|
||||||
|
input DeleteMeasureDocumentMappingInput {
|
||||||
|
measureId: ID!
|
||||||
|
documentId: ID!
|
||||||
|
}
|
||||||
|
|
||||||
input CreateRiskObligationMappingInput {
|
input CreateRiskObligationMappingInput {
|
||||||
riskId: ID!
|
riskId: ID!
|
||||||
obligationId: ID!
|
obligationId: ID!
|
||||||
@@ -5331,6 +5356,16 @@ type DeleteRiskDocumentMappingPayload {
|
|||||||
deletedDocumentId: ID!
|
deletedDocumentId: ID!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CreateMeasureDocumentMappingPayload {
|
||||||
|
measureEdge: MeasureEdge!
|
||||||
|
documentEdge: DocumentEdge!
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeleteMeasureDocumentMappingPayload {
|
||||||
|
deletedMeasureId: ID!
|
||||||
|
deletedDocumentId: ID!
|
||||||
|
}
|
||||||
|
|
||||||
type CreateRiskObligationMappingPayload {
|
type CreateRiskObligationMappingPayload {
|
||||||
riskEdge: RiskEdge!
|
riskEdge: RiskEdge!
|
||||||
obligationEdge: ObligationEdge!
|
obligationEdge: ObligationEdge!
|
||||||
|
|||||||
@@ -1601,6 +1601,13 @@ func (r *documentConnectionResolver) TotalCount(ctx context.Context, obj *types.
|
|||||||
return 0, gqlutils.Internal(ctx)
|
return 0, gqlutils.Internal(ctx)
|
||||||
}
|
}
|
||||||
return count, nil
|
return count, nil
|
||||||
|
case *measureResolver:
|
||||||
|
count, err := prb.Documents.CountForMeasureID(ctx, obj.ParentID, obj.Filters)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot count documents", log.Error(err))
|
||||||
|
return 0, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
r.logger.ErrorCtx(ctx, "unsupported resolver")
|
r.logger.ErrorCtx(ctx, "unsupported resolver")
|
||||||
@@ -2811,6 +2818,43 @@ func (r *measureResolver) Controls(ctx context.Context, obj *types.Measure, firs
|
|||||||
return types.NewControlConnection(page, r, obj.ID, controlFilter), nil
|
return types.NewControlConnection(page, r, obj.ID, controlFilter), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Documents is the resolver for the documents field.
|
||||||
|
func (r *measureResolver) Documents(ctx context.Context, obj *types.Measure, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.DocumentOrderBy, filter *types.DocumentFilter) (*types.DocumentConnection, error) {
|
||||||
|
if err := r.authorize(ctx, obj.ID, probo.ActionDocumentList); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
prb := r.ProboService(ctx, obj.ID.TenantID())
|
||||||
|
|
||||||
|
pageOrderBy := page.OrderBy[coredata.DocumentOrderField]{
|
||||||
|
Field: coredata.DocumentOrderFieldCreatedAt,
|
||||||
|
Direction: page.OrderDirectionDesc,
|
||||||
|
}
|
||||||
|
if orderBy != nil {
|
||||||
|
pageOrderBy = page.OrderBy[coredata.DocumentOrderField]{
|
||||||
|
Field: orderBy.Field,
|
||||||
|
Direction: orderBy.Direction,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
||||||
|
|
||||||
|
var documentFilter = coredata.NewDocumentFilter(nil)
|
||||||
|
if filter != nil {
|
||||||
|
documentFilter = coredata.NewDocumentFilter(filter.Query).
|
||||||
|
WithDocumentTypes(filter.DocumentTypes).
|
||||||
|
WithClassifications(filter.Classifications)
|
||||||
|
}
|
||||||
|
|
||||||
|
pg, err := prb.Documents.ListForMeasureID(ctx, obj.ID, cursor, documentFilter)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot list documents", log.Error(err))
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return types.NewDocumentConnection(pg, r, obj.ID, documentFilter), nil
|
||||||
|
}
|
||||||
|
|
||||||
// Permission is the resolver for the permission field.
|
// Permission is the resolver for the permission field.
|
||||||
func (r *measureResolver) Permission(ctx context.Context, obj *types.Measure, action string) (bool, error) {
|
func (r *measureResolver) Permission(ctx context.Context, obj *types.Measure, action string) (bool, error) {
|
||||||
return r.Resolver.Permission(ctx, obj, action)
|
return r.Resolver.Permission(ctx, obj, action)
|
||||||
@@ -4826,6 +4870,50 @@ func (r *mutationResolver) DeleteRiskDocumentMapping(ctx context.Context, input
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateMeasureDocumentMapping is the resolver for the createMeasureDocumentMapping field.
|
||||||
|
func (r *mutationResolver) CreateMeasureDocumentMapping(ctx context.Context, input types.CreateMeasureDocumentMappingInput) (*types.CreateMeasureDocumentMappingPayload, error) {
|
||||||
|
if err := r.authorize(ctx, input.MeasureID, probo.ActionMeasureDocumentMappingCreate); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
prb := r.ProboService(ctx, input.MeasureID.TenantID())
|
||||||
|
|
||||||
|
measure, document, err := prb.Measures.CreateDocumentMapping(ctx, input.MeasureID, input.DocumentID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
|
||||||
|
return nil, gqlutils.Conflict(ctx, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot create measure document mapping", log.Error(err))
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.CreateMeasureDocumentMappingPayload{
|
||||||
|
MeasureEdge: types.NewMeasureEdge(measure, coredata.MeasureOrderFieldCreatedAt),
|
||||||
|
DocumentEdge: types.NewDocumentEdge(document, coredata.DocumentOrderFieldTitle),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMeasureDocumentMapping is the resolver for the deleteMeasureDocumentMapping field.
|
||||||
|
func (r *mutationResolver) DeleteMeasureDocumentMapping(ctx context.Context, input types.DeleteMeasureDocumentMappingInput) (*types.DeleteMeasureDocumentMappingPayload, error) {
|
||||||
|
if err := r.authorize(ctx, input.MeasureID, probo.ActionMeasureDocumentMappingDelete); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
prb := r.ProboService(ctx, input.MeasureID.TenantID())
|
||||||
|
|
||||||
|
measure, document, err := prb.Measures.DeleteDocumentMapping(ctx, input.MeasureID, input.DocumentID)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot delete measure document mapping", log.Error(err))
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.DeleteMeasureDocumentMappingPayload{
|
||||||
|
DeletedMeasureID: measure.ID,
|
||||||
|
DeletedDocumentID: document.ID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// CreateRiskObligationMapping is the resolver for the createRiskObligationMapping field.
|
// CreateRiskObligationMapping is the resolver for the createRiskObligationMapping field.
|
||||||
func (r *mutationResolver) CreateRiskObligationMapping(ctx context.Context, input types.CreateRiskObligationMappingInput) (*types.CreateRiskObligationMappingPayload, error) {
|
func (r *mutationResolver) CreateRiskObligationMapping(ctx context.Context, input types.CreateRiskObligationMappingInput) (*types.CreateRiskObligationMappingPayload, error) {
|
||||||
if err := r.authorize(ctx, input.RiskID, probo.ActionRiskObligationMappingCreate); err != nil {
|
if err := r.authorize(ctx, input.RiskID, probo.ActionRiskObligationMappingCreate); err != nil {
|
||||||
|
|||||||
@@ -2597,6 +2597,11 @@ func (r *Resolver) LinkMeasureTool(ctx context.Context, req *mcp.CallToolRequest
|
|||||||
if _, _, err := svc.Risks.CreateMeasureMapping(ctx, input.ResourceID, input.MeasureID); err != nil {
|
if _, _, err := svc.Risks.CreateMeasureMapping(ctx, input.ResourceID, input.MeasureID); err != nil {
|
||||||
return nil, types.LinkMeasureOutput{}, fmt.Errorf("failed to link measure to risk: %w", err)
|
return nil, types.LinkMeasureOutput{}, fmt.Errorf("failed to link measure to risk: %w", err)
|
||||||
}
|
}
|
||||||
|
case coredata.DocumentEntityType:
|
||||||
|
r.MustAuthorize(ctx, input.MeasureID, probo.ActionMeasureDocumentMappingCreate)
|
||||||
|
if _, _, err := svc.Measures.CreateDocumentMapping(ctx, input.MeasureID, input.ResourceID); err != nil {
|
||||||
|
return nil, types.LinkMeasureOutput{}, fmt.Errorf("failed to link measure to document: %w", err)
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return nil, types.LinkMeasureOutput{}, fmt.Errorf("unsupported resource type for measure linking: entity type %d", input.ResourceID.EntityType())
|
return nil, types.LinkMeasureOutput{}, fmt.Errorf("unsupported resource type for measure linking: entity type %d", input.ResourceID.EntityType())
|
||||||
}
|
}
|
||||||
@@ -2618,6 +2623,11 @@ func (r *Resolver) UnlinkMeasureTool(ctx context.Context, req *mcp.CallToolReque
|
|||||||
if _, _, err := svc.Risks.DeleteMeasureMapping(ctx, input.ResourceID, input.MeasureID); err != nil {
|
if _, _, err := svc.Risks.DeleteMeasureMapping(ctx, input.ResourceID, input.MeasureID); err != nil {
|
||||||
return nil, types.UnlinkMeasureOutput{}, fmt.Errorf("failed to unlink measure from risk: %w", err)
|
return nil, types.UnlinkMeasureOutput{}, fmt.Errorf("failed to unlink measure from risk: %w", err)
|
||||||
}
|
}
|
||||||
|
case coredata.DocumentEntityType:
|
||||||
|
r.MustAuthorize(ctx, input.MeasureID, probo.ActionMeasureDocumentMappingDelete)
|
||||||
|
if _, _, err := svc.Measures.DeleteDocumentMapping(ctx, input.MeasureID, input.ResourceID); err != nil {
|
||||||
|
return nil, types.UnlinkMeasureOutput{}, fmt.Errorf("failed to unlink measure from document: %w", err)
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return nil, types.UnlinkMeasureOutput{}, fmt.Errorf("unsupported resource type for measure unlinking: entity type %d", input.ResourceID.EntityType())
|
return nil, types.UnlinkMeasureOutput{}, fmt.Errorf("unsupported resource type for measure unlinking: entity type %d", input.ResourceID.EntityType())
|
||||||
}
|
}
|
||||||
@@ -3873,3 +3883,29 @@ func (r *Resolver) PublishMinorDocumentVersionTool(ctx context.Context, req *mcp
|
|||||||
DocumentVersion: types.NewDocumentVersion(documentVersion),
|
DocumentVersion: types.NewDocumentVersion(documentVersion),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Resolver) ListMeasureDocumentsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListMeasureDocumentsInput) (*mcp.CallToolResult, types.ListMeasureDocumentsOutput, error) {
|
||||||
|
r.MustAuthorize(ctx, input.MeasureID, probo.ActionMeasureGet)
|
||||||
|
|
||||||
|
prb := r.ProboService(ctx, input.MeasureID)
|
||||||
|
|
||||||
|
pageOrderBy := page.OrderBy[coredata.DocumentOrderField]{
|
||||||
|
Field: coredata.DocumentOrderFieldCreatedAt,
|
||||||
|
Direction: page.OrderDirectionDesc,
|
||||||
|
}
|
||||||
|
if input.OrderBy != nil {
|
||||||
|
pageOrderBy = page.OrderBy[coredata.DocumentOrderField]{
|
||||||
|
Field: input.OrderBy.Field,
|
||||||
|
Direction: input.OrderBy.Direction,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
|
||||||
|
|
||||||
|
docPage, err := prb.Documents.ListForMeasureID(ctx, input.MeasureID, cursor, coredata.NewDocumentFilter(nil))
|
||||||
|
if err != nil {
|
||||||
|
return nil, types.ListMeasureDocumentsOutput{}, fmt.Errorf("failed to list measure documents: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, types.NewListMeasureDocumentsOutput(docPage), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1515,6 +1515,37 @@ components:
|
|||||||
items:
|
items:
|
||||||
$ref: "#/components/schemas/Control"
|
$ref: "#/components/schemas/Control"
|
||||||
|
|
||||||
|
ListMeasureDocumentsInput:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- measure_id
|
||||||
|
properties:
|
||||||
|
measure_id:
|
||||||
|
$ref: "#/components/schemas/GID"
|
||||||
|
description: Measure ID
|
||||||
|
cursor:
|
||||||
|
$ref: "#/components/schemas/CursorKey"
|
||||||
|
description: Page cursor
|
||||||
|
size:
|
||||||
|
type: integer
|
||||||
|
description: Page size
|
||||||
|
order_by:
|
||||||
|
$ref: "#/components/schemas/DocumentOrderBy"
|
||||||
|
description: Document order by
|
||||||
|
|
||||||
|
ListMeasureDocumentsOutput:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- documents
|
||||||
|
properties:
|
||||||
|
next_cursor:
|
||||||
|
$ref: "#/components/schemas/CursorKey"
|
||||||
|
description: Next cursor
|
||||||
|
documents:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/Document"
|
||||||
|
|
||||||
ListMeasureTasksInput:
|
ListMeasureTasksInput:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
@@ -1639,7 +1670,7 @@ components:
|
|||||||
description: Measure ID
|
description: Measure ID
|
||||||
resource_id:
|
resource_id:
|
||||||
$ref: "#/components/schemas/GID"
|
$ref: "#/components/schemas/GID"
|
||||||
description: ID of the resource to link (control or risk)
|
description: ID of the resource to link (control, risk, or document)
|
||||||
|
|
||||||
LinkMeasureOutput:
|
LinkMeasureOutput:
|
||||||
type: object
|
type: object
|
||||||
@@ -1655,7 +1686,7 @@ components:
|
|||||||
description: Measure ID
|
description: Measure ID
|
||||||
resource_id:
|
resource_id:
|
||||||
$ref: "#/components/schemas/GID"
|
$ref: "#/components/schemas/GID"
|
||||||
description: ID of the resource to unlink (control or risk)
|
description: ID of the resource to unlink (control, risk, or document)
|
||||||
|
|
||||||
UnlinkMeasureOutput:
|
UnlinkMeasureOutput:
|
||||||
type: object
|
type: object
|
||||||
@@ -7669,7 +7700,7 @@ tools:
|
|||||||
outputSchema:
|
outputSchema:
|
||||||
$ref: "#/components/schemas/ListMeasureEvidencesOutput"
|
$ref: "#/components/schemas/ListMeasureEvidencesOutput"
|
||||||
- name: linkMeasure
|
- name: linkMeasure
|
||||||
description: Link a measure to a resource (control or risk). The resource type is determined from the resource_id GID.
|
description: Link a measure to a resource (control, risk, or document). The resource type is determined from the resource_id GID.
|
||||||
hints:
|
hints:
|
||||||
readonly: false
|
readonly: false
|
||||||
inputSchema:
|
inputSchema:
|
||||||
@@ -7677,13 +7708,22 @@ tools:
|
|||||||
outputSchema:
|
outputSchema:
|
||||||
$ref: "#/components/schemas/LinkMeasureOutput"
|
$ref: "#/components/schemas/LinkMeasureOutput"
|
||||||
- name: unlinkMeasure
|
- name: unlinkMeasure
|
||||||
description: Unlink a measure from a resource (control or risk). The resource type is determined from the resource_id GID.
|
description: Unlink a measure from a resource (control, risk, or document). The resource type is determined from the resource_id GID.
|
||||||
hints:
|
hints:
|
||||||
readonly: false
|
readonly: false
|
||||||
inputSchema:
|
inputSchema:
|
||||||
$ref: "#/components/schemas/UnlinkMeasureInput"
|
$ref: "#/components/schemas/UnlinkMeasureInput"
|
||||||
outputSchema:
|
outputSchema:
|
||||||
$ref: "#/components/schemas/UnlinkMeasureOutput"
|
$ref: "#/components/schemas/UnlinkMeasureOutput"
|
||||||
|
- name: listMeasureDocuments
|
||||||
|
description: List documents linked to a measure
|
||||||
|
hints:
|
||||||
|
readonly: true
|
||||||
|
idempotent: true
|
||||||
|
inputSchema:
|
||||||
|
$ref: "#/components/schemas/ListMeasureDocumentsInput"
|
||||||
|
outputSchema:
|
||||||
|
$ref: "#/components/schemas/ListMeasureDocumentsOutput"
|
||||||
- name: listFrameworks
|
- name: listFrameworks
|
||||||
description: List all frameworks for the organization
|
description: List all frameworks for the organization
|
||||||
hints:
|
hints:
|
||||||
|
|||||||
@@ -52,6 +52,24 @@ func NewListControlDocumentsOutput(documentPage *page.Page[*coredata.Document, c
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewListMeasureDocumentsOutput(documentPage *page.Page[*coredata.Document, coredata.DocumentOrderField]) ListMeasureDocumentsOutput {
|
||||||
|
documents := make([]*Document, 0, len(documentPage.Data))
|
||||||
|
for _, d := range documentPage.Data {
|
||||||
|
documents = append(documents, NewDocument(d))
|
||||||
|
}
|
||||||
|
|
||||||
|
var nextCursor *page.CursorKey
|
||||||
|
if len(documentPage.Data) > 0 {
|
||||||
|
cursorKey := documentPage.Data[len(documentPage.Data)-1].CursorKey(documentPage.Cursor.OrderBy.Field)
|
||||||
|
nextCursor = &cursorKey
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListMeasureDocumentsOutput{
|
||||||
|
NextCursor: nextCursor,
|
||||||
|
Documents: documents,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func NewListDocumentsOutput(documentPage *page.Page[*coredata.Document, coredata.DocumentOrderField]) ListDocumentsOutput {
|
func NewListDocumentsOutput(documentPage *page.Page[*coredata.Document, coredata.DocumentOrderField]) ListDocumentsOutput {
|
||||||
documents := make([]*Document, 0, len(documentPage.Data))
|
documents := make([]*Document, 0, len(documentPage.Data))
|
||||||
for _, d := range documentPage.Data {
|
for _, d := range documentPage.Data {
|
||||||
|
|||||||
Reference in New Issue
Block a user