diff --git a/apps/console/src/hooks/graph/MeasureGraph.ts b/apps/console/src/hooks/graph/MeasureGraph.ts index 5787753bd..28d6cccbf 100644 --- a/apps/console/src/hooks/graph/MeasureGraph.ts +++ b/apps/console/src/hooks/graph/MeasureGraph.ts @@ -19,8 +19,6 @@ import type { MeasureGraphDeleteMutation } from "#/__generated__/core/MeasureGra import { useMutationWithToasts } from "../useMutationWithToasts"; -/* eslint-disable relay/unused-fields, relay/must-colocate-fragment-spreads */ - export const MeasureConnectionKey = "MeasuresPage_measures"; 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` mutation MeasureGraphUpdateMutation($input: UpdateMeasureInput!) { updateMeasure(input: $input) { diff --git a/apps/console/src/pages/organizations/measures/MeasureDetailPage.tsx b/apps/console/src/pages/organizations/measures/MeasureDetailPage.tsx index e4f38d8cc..1a817f435 100644 --- a/apps/console/src/pages/organizations/measures/MeasureDetailPage.tsx +++ b/apps/console/src/pages/organizations/measures/MeasureDetailPage.tsx @@ -26,6 +26,7 @@ import { DropdownItem, IconCheckmark1, IconFrame2, + IconPageCheck, IconPageTextLine, IconPencil, IconTrashCan, @@ -50,17 +51,58 @@ import { } from "react-relay"; 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 { MeasureGraphNodeQuery } from "#/__generated__/core/MeasureGraphNodeQuery.graphql"; import { MeasureConnectionKey, - measureNodeQuery, useDeleteMeasureMutation, useUpdateMeasure, } from "#/hooks/graph/MeasureGraph"; import { useOrganizationId } from "#/hooks/useOrganizationId"; 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` query MeasureDetailPageTasksCountQuery($measureId: ID!) { @@ -84,7 +126,7 @@ function TasksCountBadge({ measureId }: { measureId: string }) { } type Props = { - queryRef: PreloadedQuery; + queryRef: PreloadedQuery; }; export default function MeasureDetailPage(props: Props) { @@ -106,6 +148,7 @@ export default function MeasureDetailPage(props: Props) { const evidencesCount = measure.evidencesInfos?.totalCount ?? 0; const controlsCount = measure.controlsInfos?.totalCount ?? 0; const risksCount = measure.risksInfos?.totalCount ?? 0; + const documentsCount = measure.documentsInfos?.totalCount ?? 0; const onDelete = () => { const connectionId = ConnectionHandler.getConnectionID( @@ -212,7 +255,7 @@ export default function MeasureDetailPage(props: Props) { - + {__("Evidences")} {evidencesCount} @@ -241,6 +284,13 @@ export default function MeasureDetailPage(props: Props) { {__("Risks")} {risksCount} + + + {__("Documents")} + {documentsCount} + diff --git a/apps/console/src/pages/organizations/measures/tabs/MeasureDocumentsTab.tsx b/apps/console/src/pages/organizations/measures/tabs/MeasureDocumentsTab.tsx new file mode 100644 index 000000000..b2ce07094 --- /dev/null +++ b/apps/console/src/pages/organizations/measures/tabs/MeasureDocumentsTab.tsx @@ -0,0 +1,116 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import { graphql, useFragment } from "react-relay"; +import { useOutletContext } 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( + 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 ( + + ); +} diff --git a/apps/console/src/routes/measureRoutes.ts b/apps/console/src/routes/measureRoutes.ts index 893b85586..6a6c51cc7 100644 --- a/apps/console/src/routes/measureRoutes.ts +++ b/apps/console/src/routes/measureRoutes.ts @@ -22,11 +22,11 @@ import { Fragment } from "react"; import { loadQuery } from "react-relay"; 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 { PageSkeleton } from "#/components/skeletons/PageSkeleton"; import { coreEnvironment } from "#/environments"; -import { measureNodeQuery } from "#/hooks/graph/MeasureGraph"; +import { measureNodeQuery } from "#/pages/organizations/measures/MeasureDetailPage"; export const measureRoutes = [ { @@ -41,7 +41,7 @@ export const measureRoutes = [ path: "measures/:measureId", Fallback: PageSkeleton, loader: loaderFromQueryLoader(({ measureId }) => - loadQuery(coreEnvironment, measureNodeQuery, { + loadQuery(coreEnvironment, measureNodeQuery, { measureId: measureId, }), ), @@ -89,6 +89,14 @@ export const measureRoutes = [ import("#/pages/organizations/measures/tabs/MeasureEvidencesTab"), ), }, + { + path: "documents", + Fallback: LinkCardSkeleton, + Component: lazy( + () => + import("#/pages/organizations/measures/tabs/MeasureDocumentsTab"), + ), + }, ], }, ] satisfies AppRoute[]; diff --git a/e2e/console/mapping_test.go b/e2e/console/mapping_test.go index b4d1369b4..fa7be2d23 100644 --- a/e2e/console/mapping_test.go +++ b/e2e/console/mapping_test.go @@ -945,3 +945,93 @@ func TestRiskObligationMapping_CreateDelete(t *testing.T) { 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) + }) +} diff --git a/pkg/coredata/document.go b/pkg/coredata/document.go index 142909521..c4c111577 100644 --- a/pkg/coredata/document.go +++ b/pkg/coredata/document.go @@ -837,6 +837,103 @@ WHERE rp.risk_id = @risk_id 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( ctx context.Context, conn pg.Tx, diff --git a/pkg/coredata/measure_document.go b/pkg/coredata/measure_document.go new file mode 100644 index 000000000..f0d47eac3 --- /dev/null +++ b/pkg/coredata/measure_document.go @@ -0,0 +1,144 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package coredata + +import ( + "context" + "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 +} diff --git a/pkg/coredata/migrations/20260403T120000Z.sql b/pkg/coredata/migrations/20260403T120000Z.sql new file mode 100644 index 000000000..de1d3fb3f --- /dev/null +++ b/pkg/coredata/migrations/20260403T120000Z.sql @@ -0,0 +1,22 @@ +-- Copyright (c) 2025-2026 Probo Inc . +-- +-- Permission to use, copy, modify, and/or distribute this software for any +-- purpose with or without fee is hereby granted, provided that the above +-- copyright notice and this permission notice appear in all copies. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +-- PERFORMANCE OF THIS SOFTWARE. + +CREATE TABLE measures_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) +); diff --git a/pkg/probo/actions.go b/pkg/probo/actions.go index e3c7b5385..03cdc9670 100644 --- a/pkg/probo/actions.go +++ b/pkg/probo/actions.go @@ -154,13 +154,15 @@ 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" + 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" // Task actions ActionTaskGet = "core:task:get" diff --git a/pkg/probo/document_service.go b/pkg/probo/document_service.go index c6734f1f4..eaeb48508 100644 --- a/pkg/probo/document_service.go +++ b/pkg/probo/document_service.go @@ -1133,6 +1133,11 @@ func (s *DocumentService) BulkArchive( 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) }, ) @@ -1532,6 +1537,58 @@ func (s *DocumentService) ListForRiskID( 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( ctx context.Context, req UpdateDocumentRequest, @@ -1618,6 +1675,11 @@ func (s *DocumentService) Archive( 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.ArchivedAt = &now document.UpdatedAt = now diff --git a/pkg/probo/measure_service.go b/pkg/probo/measure_service.go index de610c567..8bc9f493c 100644 --- a/pkg/probo/measure_service.go +++ b/pkg/probo/measure_service.go @@ -601,3 +601,80 @@ func (s MeasureService) Delete( 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 +} diff --git a/pkg/server/api/console/v1/schema.graphql b/pkg/server/api/console/v1/schema.graphql index 9c5fcdf0a..45960d734 100644 --- a/pkg/server/api/console/v1/schema.graphql +++ b/pkg/server/api/console/v1/schema.graphql @@ -2492,6 +2492,15 @@ type Measure implements Node { filter: ControlFilter ): ControlConnection! @goField(forceResolver: true) + documents( + first: Int + after: CursorKey + last: Int + before: CursorKey + orderBy: DocumentOrder + filter: DocumentFilter + ): DocumentConnection! @goField(forceResolver: true) + createdAt: Datetime! updatedAt: Datetime! @@ -3830,6 +3839,12 @@ type Mutation { deleteRiskDocumentMapping( input: DeleteRiskDocumentMappingInput! ): DeleteRiskDocumentMappingPayload! + createMeasureDocumentMapping( + input: CreateMeasureDocumentMappingInput! + ): CreateMeasureDocumentMappingPayload! + deleteMeasureDocumentMapping( + input: DeleteMeasureDocumentMappingInput! + ): DeleteMeasureDocumentMappingPayload! createRiskObligationMapping( input: CreateRiskObligationMappingInput! ): CreateRiskObligationMappingPayload! @@ -4571,6 +4586,16 @@ input DeleteRiskDocumentMappingInput { documentId: ID! } +input CreateMeasureDocumentMappingInput { + measureId: ID! + documentId: ID! +} + +input DeleteMeasureDocumentMappingInput { + measureId: ID! + documentId: ID! +} + input CreateRiskObligationMappingInput { riskId: ID! obligationId: ID! @@ -5331,6 +5356,16 @@ type DeleteRiskDocumentMappingPayload { deletedDocumentId: ID! } +type CreateMeasureDocumentMappingPayload { + measureEdge: MeasureEdge! + documentEdge: DocumentEdge! +} + +type DeleteMeasureDocumentMappingPayload { + deletedMeasureId: ID! + deletedDocumentId: ID! +} + type CreateRiskObligationMappingPayload { riskEdge: RiskEdge! obligationEdge: ObligationEdge! diff --git a/pkg/server/api/console/v1/v1_resolver.go b/pkg/server/api/console/v1/v1_resolver.go index ad1525ac1..fd9d25d06 100644 --- a/pkg/server/api/console/v1/v1_resolver.go +++ b/pkg/server/api/console/v1/v1_resolver.go @@ -1601,6 +1601,13 @@ func (r *documentConnectionResolver) TotalCount(ctx context.Context, obj *types. return 0, gqlutils.Internal(ctx) } 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") @@ -2811,6 +2818,43 @@ func (r *measureResolver) Controls(ctx context.Context, obj *types.Measure, firs 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. func (r *measureResolver) Permission(ctx context.Context, obj *types.Measure, action string) (bool, error) { return r.Resolver.Permission(ctx, obj, action) @@ -4826,6 +4870,50 @@ func (r *mutationResolver) DeleteRiskDocumentMapping(ctx context.Context, input }, 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. func (r *mutationResolver) CreateRiskObligationMapping(ctx context.Context, input types.CreateRiskObligationMappingInput) (*types.CreateRiskObligationMappingPayload, error) { if err := r.authorize(ctx, input.RiskID, probo.ActionRiskObligationMappingCreate); err != nil { diff --git a/pkg/server/api/mcp/v1/schema.resolvers.go b/pkg/server/api/mcp/v1/schema.resolvers.go index f82d405fb..ae6cde51e 100644 --- a/pkg/server/api/mcp/v1/schema.resolvers.go +++ b/pkg/server/api/mcp/v1/schema.resolvers.go @@ -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 { 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: 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 { 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: 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), }, 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 +} diff --git a/pkg/server/api/mcp/v1/specification.yaml b/pkg/server/api/mcp/v1/specification.yaml index 54c37a4af..595b12141 100644 --- a/pkg/server/api/mcp/v1/specification.yaml +++ b/pkg/server/api/mcp/v1/specification.yaml @@ -1515,6 +1515,37 @@ components: items: $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: type: object required: @@ -1639,7 +1670,7 @@ components: description: Measure ID resource_id: $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: type: object @@ -1655,7 +1686,7 @@ components: description: Measure ID resource_id: $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: type: object @@ -7669,7 +7700,7 @@ tools: outputSchema: $ref: "#/components/schemas/ListMeasureEvidencesOutput" - 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: readonly: false inputSchema: @@ -7677,13 +7708,22 @@ tools: outputSchema: $ref: "#/components/schemas/LinkMeasureOutput" - 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: readonly: false inputSchema: $ref: "#/components/schemas/UnlinkMeasureInput" outputSchema: $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 description: List all frameworks for the organization hints: diff --git a/pkg/server/api/mcp/v1/types/document.go b/pkg/server/api/mcp/v1/types/document.go index 5758e2126..6d485d995 100644 --- a/pkg/server/api/mcp/v1/types/document.go +++ b/pkg/server/api/mcp/v1/types/document.go @@ -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 { documents := make([]*Document, 0, len(documentPage.Data)) for _, d := range documentPage.Data {