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

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

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

View File

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