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:
Sacha Al Himdani
2026-04-03 09:38:38 +02:00
parent 8de7add203
commit ef2e99d86c
16 changed files with 903 additions and 50 deletions

View File

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

View File

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

View File

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

View File

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

View File

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