Add finding and obligation publish to document system

Replace the old snapshot-based approach with the new publish document
system for findings and obligations. Includes GraphQL mutations, MCP
tools, CLI commands, e2e tests, frontend publish dialogs, and
snapshot-to-document migration tools.

Remove snapshot mode entirely from findings and obligations: drop
snapshotId from GraphQL schemas, filters, resolvers, MCP spec, frontend
routes, pages, and helpers. The snapshot_id column remains in the
database but is now filtered out with snapshot_id IS NULL.

Remove auditor's ability to publish SoA.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-04-23 10:53:57 +02:00
parent e473884b31
commit bdb16d4abe
57 changed files with 4241 additions and 609 deletions

View File

@@ -120,6 +120,7 @@ FROM
WHERE
%s
AND id = @finding_id
AND snapshot_id IS NULL
LIMIT 1;
`
@@ -158,6 +159,7 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND snapshot_id IS NULL
AND %s
`
@@ -212,6 +214,7 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND snapshot_id IS NULL
AND %s
AND %s
`
@@ -412,95 +415,6 @@ WHERE
return nil
}
func (fs Findings) Snapshot(ctx context.Context, conn pg.Tx, scope Scoper, organizationID, snapshotID gid.GID) error {
query := `
INSERT INTO findings (
id,
tenant_id,
snapshot_id,
source_id,
organization_id,
kind,
reference_id,
description,
source,
identified_on,
root_cause,
corrective_action,
owner_id,
due_date,
status,
priority,
risk_id,
effectiveness_check,
created_at,
updated_at
)
SELECT
generate_gid(decode_base64_unpadded(@tenant_id), @finding_entity_type),
@tenant_id,
@snapshot_id,
f.id,
f.organization_id,
f.kind,
f.reference_id,
f.description,
f.source,
f.identified_on,
f.root_cause,
f.corrective_action,
f.owner_id,
f.due_date,
f.status,
f.priority,
f.risk_id,
f.effectiveness_check,
f.created_at,
f.updated_at
FROM findings f
WHERE %s AND f.organization_id = @organization_id AND f.snapshot_id IS NULL
`
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"snapshot_id": snapshotID,
"organization_id": organizationID,
"finding_entity_type": FindingEntityType,
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, query, args)
if err != nil {
return fmt.Errorf("cannot insert finding snapshots: %w", err)
}
auditQuery := `
INSERT INTO findings_audits (finding_id, audit_id, reference_id, organization_id, tenant_id, created_at)
SELECT
snap.id,
fa.audit_id,
fa.reference_id,
fa.organization_id,
fa.tenant_id,
fa.created_at
FROM findings_audits fa
JOIN findings live ON fa.finding_id = live.id AND live.snapshot_id IS NULL
JOIN findings snap ON snap.source_id = live.id AND snap.snapshot_id = @snapshot_id
WHERE %s AND live.organization_id = @organization_id
`
auditQuery = fmt.Sprintf(auditQuery, scope.SQLFragment())
_, err = conn.Exec(ctx, auditQuery, args)
if err != nil {
return fmt.Errorf("cannot insert finding audit snapshots: %w", err)
}
return nil
}
func (fs *Findings) LoadByAuditID(
ctx context.Context,
conn pg.Querier,
@@ -538,6 +452,7 @@ WITH f AS (
findings_audits fa ON fi.id = fa.finding_id
WHERE
fa.audit_id = @audit_id
AND fi.snapshot_id IS NULL
)
SELECT
id,
@@ -605,6 +520,7 @@ WITH f AS (
findings_audits fa ON fi.id = fa.finding_id
WHERE
fa.audit_id = @audit_id
AND fi.snapshot_id IS NULL
)
SELECT
COUNT(id)
@@ -631,3 +547,167 @@ WHERE
return count, nil
}
func (fs *Findings) LoadAllByOrganizationID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
organizationID gid.GID,
) error {
q := `
SELECT
id,
organization_id,
snapshot_id,
source_id,
kind,
reference_id,
description,
source,
identified_on,
root_cause,
corrective_action,
owner_id,
due_date,
status,
priority,
risk_id,
effectiveness_check,
created_at,
updated_at
FROM
findings
WHERE
%s
AND organization_id = @organization_id
AND snapshot_id IS NULL
ORDER BY
reference_id ASC
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"organization_id": organizationID}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query findings: %w", err)
}
findings, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Finding])
if err != nil {
return fmt.Errorf("cannot collect findings: %w", err)
}
*fs = findings
return nil
}
func (f Finding) GetGeneratedDocumentID(
ctx context.Context,
conn pg.Querier,
organizationID gid.GID,
) (*gid.GID, error) {
var documentID *gid.GID
err := conn.QueryRow(
ctx,
`
SELECT
findings_document_id
FROM
generated_documents
WHERE
organization_id = @organization_id
`,
pgx.NamedArgs{"organization_id": organizationID},
).Scan(&documentID)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("cannot get finding list document ID: %w", err)
}
return documentID, nil
}
func (f Finding) UpsertGeneratedDocumentID(
ctx context.Context,
conn pg.Tx,
organizationID gid.GID,
tenantID gid.TenantID,
documentID gid.GID,
) error {
now := time.Now()
_, err := conn.Exec(
ctx,
`
INSERT INTO generated_documents (
organization_id,
tenant_id,
findings_document_id,
created_at,
updated_at
) VALUES (
@organization_id,
@tenant_id,
@findings_document_id,
@created_at,
@updated_at
)
ON CONFLICT (organization_id) DO UPDATE
SET
findings_document_id = @findings_document_id,
updated_at = @updated_at
`,
pgx.NamedArgs{
"organization_id": organizationID,
"tenant_id": tenantID,
"findings_document_id": documentID,
"created_at": now,
"updated_at": now,
},
)
if err != nil {
return fmt.Errorf("cannot upsert finding list document ID: %w", err)
}
return nil
}
func (f Finding) ClearGeneratedDocumentID(
ctx context.Context,
conn pg.Tx,
documentIDs []gid.GID,
) error {
ids := make([]string, len(documentIDs))
for i, id := range documentIDs {
ids[i] = id.String()
}
_, err := conn.Exec(
ctx,
`
UPDATE
generated_documents
SET
findings_document_id = NULL,
updated_at = @now
WHERE
findings_document_id = ANY(@ids)
`,
pgx.NamedArgs{
"ids": ids,
"now": time.Now(),
},
)
if err != nil {
return fmt.Errorf("cannot clear finding list document references: %w", err)
}
return nil
}

View File

@@ -21,34 +21,29 @@ import (
type (
FindingFilter struct {
snapshotID **gid.GID
kind *FindingKind
status *FindingStatus
priority *FindingPriority
ownerID *gid.GID
kind *FindingKind
status *FindingStatus
priority *FindingPriority
ownerID *gid.GID
}
)
func NewFindingFilter(
snapshotID **gid.GID,
kind *FindingKind,
status *FindingStatus,
priority *FindingPriority,
ownerID *gid.GID,
) *FindingFilter {
return &FindingFilter{
snapshotID: snapshotID,
kind: kind,
status: status,
priority: priority,
ownerID: ownerID,
kind: kind,
status: status,
priority: priority,
ownerID: ownerID,
}
}
func (f *FindingFilter) SQLArguments() pgx.StrictNamedArgs {
args := pgx.StrictNamedArgs{
"has_snapshot_filter": false,
"filter_snapshot_id": nil,
"has_kind_filter": false,
"filter_kind": nil,
"has_status_filter": false,
@@ -59,13 +54,6 @@ func (f *FindingFilter) SQLArguments() pgx.StrictNamedArgs {
"filter_owner_id": nil,
}
if f.snapshotID != nil {
args["has_snapshot_filter"] = true
if *f.snapshotID != nil {
args["filter_snapshot_id"] = **f.snapshotID
}
}
if f.kind != nil {
args["has_kind_filter"] = true
args["filter_kind"] = string(*f.kind)
@@ -92,15 +80,6 @@ func (f *FindingFilter) SQLArguments() pgx.StrictNamedArgs {
func (f *FindingFilter) SQLFragment() string {
return `
(
CASE
WHEN @has_snapshot_filter::boolean = false THEN TRUE
WHEN @has_snapshot_filter::boolean = true AND @filter_snapshot_id::text IS NOT NULL THEN
snapshot_id = @filter_snapshot_id::text
WHEN @has_snapshot_filter::boolean = true AND @filter_snapshot_id::text IS NULL THEN
snapshot_id IS NULL
ELSE TRUE
END
AND
CASE
WHEN @has_kind_filter::boolean = false THEN TRUE
WHEN @has_kind_filter::boolean = true THEN

View File

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

View File

@@ -108,6 +108,7 @@ FROM
WHERE
%s
AND id = @obligation_id
AND snapshot_id IS NULL
LIMIT 1;
`
@@ -136,7 +137,6 @@ func (os *Obligations) CountByOrganizationID(
conn pg.Querier,
scope Scoper,
organizationID gid.GID,
filter *ObligationFilter,
) (int, error) {
q := `
SELECT
@@ -146,14 +146,13 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND %s
AND snapshot_id IS NULL
`
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"organization_id": organizationID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments())
row := conn.QueryRow(ctx, q, args)
@@ -171,7 +170,6 @@ func (os *Obligations) CountByRiskID(
conn pg.Querier,
scope Scoper,
riskID gid.GID,
filter *ObligationFilter,
) (int, error) {
q := `
WITH obls AS (
@@ -186,20 +184,19 @@ WITH obls AS (
risks_obligations ro ON o.id = ro.obligation_id
WHERE
ro.risk_id = @risk_id
AND o.snapshot_id IS NULL
)
SELECT
COUNT(id)
FROM
obls
WHERE %s
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"risk_id": riskID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments())
row := conn.QueryRow(ctx, q, args)
@@ -218,7 +215,6 @@ func (os *Obligations) LoadByOrganizationID(
scope Scoper,
organizationID gid.GID,
cursor *page.Cursor[ObligationOrderField],
filter *ObligationFilter,
) error {
q := `
SELECT
@@ -243,15 +239,14 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND %s
AND snapshot_id IS NULL
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment())
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
args := pgx.StrictNamedArgs{"organization_id": organizationID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
@@ -275,7 +270,6 @@ func (os *Obligations) LoadByRiskID(
scope Scoper,
riskID gid.GID,
cursor *page.Cursor[ObligationOrderField],
filter *ObligationFilter,
) error {
q := `
WITH obls AS (
@@ -304,6 +298,7 @@ WITH obls AS (
risks_obligations ro ON o.id = ro.obligation_id
WHERE
ro.risk_id = @risk_id
AND o.snapshot_id IS NULL
)
SELECT
id,
@@ -326,14 +321,12 @@ FROM
obls
WHERE %s
AND %s
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment())
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
args := pgx.StrictNamedArgs{"risk_id": riskID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
@@ -356,7 +349,6 @@ func (os *Obligations) CountByControlID(
conn pg.Querier,
scope Scoper,
controlID gid.GID,
filter *ObligationFilter,
) (int, error) {
q := `
WITH obls AS (
@@ -370,20 +362,19 @@ WITH obls AS (
controls_obligations co ON o.id = co.obligation_id
WHERE
co.control_id = @control_id
AND o.snapshot_id IS NULL
)
SELECT
COUNT(id)
FROM
obls
WHERE %s
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"control_id": controlID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments())
row := conn.QueryRow(ctx, q, args)
@@ -402,7 +393,6 @@ func (os *Obligations) LoadByControlID(
scope Scoper,
controlID gid.GID,
cursor *page.Cursor[ObligationOrderField],
filter *ObligationFilter,
) error {
q := `
WITH obls AS (
@@ -430,6 +420,7 @@ WITH obls AS (
controls_obligations co ON o.id = co.obligation_id
WHERE
co.control_id = @control_id
AND o.snapshot_id IS NULL
)
SELECT
id,
@@ -452,13 +443,11 @@ FROM
obls
WHERE %s
AND %s
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment())
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
args := pgx.NamedArgs{"control_id": controlID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
@@ -625,13 +614,15 @@ WHERE
return nil
}
func (os Obligations) Snapshot(ctx context.Context, conn pg.Tx, scope Scoper, organizationID, snapshotID gid.GID) error {
query := `
INSERT INTO obligations (
func (os *Obligations) LoadAllByOrganizationID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
organizationID gid.GID,
) error {
q := `
SELECT
id,
tenant_id,
snapshot_id,
source_id,
organization_id,
area,
source,
@@ -643,44 +634,142 @@ INSERT INTO obligations (
due_date,
status,
type,
snapshot_id,
source_id,
created_at,
updated_at
)
SELECT
generate_gid(decode_base64_unpadded(@tenant_id), @obligation_entity_type),
@tenant_id,
@snapshot_id,
o.id,
o.organization_id,
o.area,
o.source,
o.requirement,
o.actions_to_be_implemented,
o.regulator,
o.owner_profile_id,
o.last_review_date,
o.due_date,
o.status,
o.type,
o.created_at,
o.updated_at
FROM obligations o
WHERE %s AND o.organization_id = @organization_id AND o.snapshot_id IS NULL
`
FROM
obligations
WHERE
%s
AND organization_id = @organization_id
AND snapshot_id IS NULL
ORDER BY
created_at ASC
`
query = fmt.Sprintf(query, scope.SQLFragment())
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"snapshot_id": snapshotID,
"organization_id": organizationID,
"obligation_entity_type": ObligationEntityType,
}
args := pgx.StrictNamedArgs{"organization_id": organizationID}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, query, args)
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot insert obligation snapshots: %w", err)
return fmt.Errorf("cannot query obligations: %w", err)
}
obligations, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Obligation])
if err != nil {
return fmt.Errorf("cannot collect obligations: %w", err)
}
*os = obligations
return nil
}
func (o Obligation) GetGeneratedDocumentID(
ctx context.Context,
conn pg.Querier,
organizationID gid.GID,
) (*gid.GID, error) {
var documentID *gid.GID
err := conn.QueryRow(
ctx,
`
SELECT
obligations_document_id
FROM
generated_documents
WHERE
organization_id = @organization_id
`,
pgx.NamedArgs{"organization_id": organizationID},
).Scan(&documentID)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("cannot get obligation list document ID: %w", err)
}
return documentID, nil
}
func (o Obligation) UpsertGeneratedDocumentID(
ctx context.Context,
conn pg.Tx,
organizationID gid.GID,
tenantID gid.TenantID,
documentID gid.GID,
) error {
now := time.Now()
_, err := conn.Exec(
ctx,
`
INSERT INTO generated_documents (
organization_id,
tenant_id,
obligations_document_id,
created_at,
updated_at
) VALUES (
@organization_id,
@tenant_id,
@obligations_document_id,
@created_at,
@updated_at
)
ON CONFLICT (organization_id) DO UPDATE
SET
obligations_document_id = @obligations_document_id,
updated_at = @updated_at
`,
pgx.NamedArgs{
"organization_id": organizationID,
"tenant_id": tenantID,
"obligations_document_id": documentID,
"created_at": now,
"updated_at": now,
},
)
if err != nil {
return fmt.Errorf("cannot upsert obligation list document ID: %w", err)
}
return nil
}
func (o Obligation) ClearGeneratedDocumentID(
ctx context.Context,
conn pg.Tx,
documentIDs []gid.GID,
) error {
ids := make([]string, len(documentIDs))
for i, id := range documentIDs {
ids[i] = id.String()
}
_, err := conn.Exec(
ctx,
`
UPDATE
generated_documents
SET
obligations_document_id = NULL,
updated_at = @now
WHERE
obligations_document_id = ANY(@ids)
`,
pgx.NamedArgs{
"ids": ids,
"now": time.Now(),
},
)
if err != nil {
return fmt.Errorf("cannot clear obligation list document references: %w", err)
}
return nil

View File

@@ -1,54 +0,0 @@
// 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 (
"github.com/jackc/pgx/v5"
"go.probo.inc/probo/pkg/gid"
)
type (
ObligationFilter struct {
snapshotID **gid.GID
}
)
func NewObligationFilter(snapshotID **gid.GID) *ObligationFilter {
return &ObligationFilter{
snapshotID: snapshotID,
}
}
func (f *ObligationFilter) SQLArguments() pgx.NamedArgs {
args := pgx.NamedArgs{}
if f.snapshotID != nil && *f.snapshotID != nil {
args["filter_snapshot_id"] = **f.snapshotID
}
return args
}
func (f *ObligationFilter) SQLFragment() string {
if f.snapshotID == nil {
return "TRUE"
}
if *f.snapshotID == nil {
return "snapshot_id IS NULL"
} else {
return "snapshot_id = @filter_snapshot_id"
}
}

View File

@@ -38,8 +38,6 @@ func SnapshotsTypes() []SnapshotsType {
return []SnapshotsType{
SnapshotsTypeRisks,
SnapshotsTypeVendors,
SnapshotsTypeFindings,
SnapshotsTypeObligations,
SnapshotsTypeProcessingActivities,
}
}

View File

@@ -30,10 +30,6 @@ func GetSnapshottable(snapshotType SnapshotsType) (Snapshottable, error) {
switch snapshotType {
case SnapshotsTypeRisks:
return Risks{}, nil
case SnapshotsTypeFindings:
return Findings{}, nil
case SnapshotsTypeObligations:
return Obligations{}, nil
case SnapshotsTypeProcessingActivities:
return ProcessingActivities{}, nil
case SnapshotsTypeVendors: