Add risk publish to document system

Replace the old snapshot-based system for risks with the publish
document system, mirroring the prior vendor / processing activity / DPIA
/ TIA migration. Includes the GraphQL mutation, MCP tool, CLI command,
n8n operation, frontend publish dialog, e2e tests, and a prosemirror
register template covering name, description, category, treatment,
owner, inherent and residual scoring, and notes.

The risk register lives as a generated DocumentTypeRegister document on
the organization, reused across publishes (the major version bumps on
every republish). Approvers can be passed in to create a draft pending
approval; otherwise the version is published immediately. The frontend
Risks page exposes a Publish button and a Document link button when the
document exists, and pre-fills the previous default approvers.

Risks was the last remaining snapshot type, so this commit also removes
the entire snapshot system: drop snapshotId from the Risk GraphQL type
and RiskFilter; remove RiskSnapshotter, Risks.Snapshot,
InsertRiskSnapshots, and the SnapshotID/SourceID fields on Risk; delete
Snapshot, ControlSnapshot, SnapshotsType, SnapshotOrderField,
Snapshottable, the SnapshotService, the Snapshot console resolvers and
GraphQL schema, the Snapshot MCP types and operations
(list/get/take/listControlSnapshots), the snapshot CLI (prb snapshot),
the snapshot frontend pages, routes, banner, LinkedSnapshotsCard,
SnapshotGraph, snapshot helpers, and the snapshot n8n resource and
control link/unlink snapshot operations. The snapshot_id columns remain
in the database but are now filtered out with snapshot_id IS NULL.

Add Get/Upsert/Clear GeneratedDocumentID methods on Risk backed by a new
risks_document_id column on generated_documents, matching the
ProcessingActivity/Finding/Vendor pattern. The migration command
migrate-risk-snapshots-to-documents uses raw SQL queries instead of the
Go snapshot types, since those are gone.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-04-29 18:11:19 +02:00
parent 01bc3ac696
commit 553901e4ad
93 changed files with 2384 additions and 5741 deletions

View File

@@ -73,8 +73,6 @@ func ResourceTypeName(entityType uint16) string {
return "Obligation"
case VendorServiceEntityType:
return "VendorService"
case SnapshotEntityType:
return "Snapshot"
case ProcessingActivityEntityType:
return "ProcessingActivity"
case TrustCenterReferenceEntityType:

View File

@@ -970,77 +970,6 @@ WHERE %s
return nil
}
func (c *Controls) LoadBySnapshotID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
snapshotID gid.GID,
cursor *page.Cursor[ControlOrderField],
filter *ControlFilter,
) error {
q := `
WITH ctrl AS (
SELECT
c.id,
c.section_title,
c.framework_id,
c.organization_id,
c.tenant_id,
c.name,
c.description,
c.best_practice,
c.not_implemented_justification,
c.maturity_level,
c.created_at,
c.updated_at,
c.search_vector
FROM
controls c
INNER JOIN
controls_snapshots cs ON c.id = cs.control_id
WHERE
cs.snapshot_id = @snapshot_id
)
SELECT
id,
section_title,
framework_id,
organization_id,
name,
description,
best_practice,
not_implemented_justification,
maturity_level,
created_at,
updated_at
FROM
ctrl
WHERE %s
AND %s
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment())
args := pgx.NamedArgs{"snapshot_id": snapshotID}
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 controls: %w", err)
}
controls, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Control])
if err != nil {
return fmt.Errorf("cannot collect controls: %w", err)
}
*c = controls
return nil
}
func (c *Controls) CountByStatementOfApplicabilityID(
ctx context.Context,
conn pg.Querier,

View File

@@ -1,100 +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 (
"context"
"fmt"
"maps"
"time"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
)
type (
ControlSnapshot struct {
ControlID gid.GID `db:"control_id"`
SnapshotID gid.GID `db:"snapshot_id"`
OrganizationID gid.GID `db:"organization_id"`
CreatedAt time.Time `db:"created_at"`
}
ControlSnapshots []*ControlSnapshot
)
func (cs ControlSnapshot) Upsert(
ctx context.Context,
conn pg.Querier,
scope Scoper,
) error {
q := `
INSERT INTO
controls_snapshots (
control_id,
snapshot_id,
organization_id,
tenant_id,
created_at
)
VALUES (
@control_id,
@snapshot_id,
@organization_id,
@tenant_id,
@created_at
)
ON CONFLICT (control_id, snapshot_id) DO NOTHING;
`
args := pgx.StrictNamedArgs{
"control_id": cs.ControlID,
"snapshot_id": cs.SnapshotID,
"organization_id": cs.OrganizationID,
"tenant_id": scope.GetTenantID(),
"created_at": cs.CreatedAt,
}
_, err := conn.Exec(ctx, q, args)
return err
}
func (cs ControlSnapshot) Delete(
ctx context.Context,
conn pg.Tx,
scope Scoper,
controlID gid.GID,
snapshotID gid.GID,
) error {
q := `
DELETE
FROM
controls_snapshots
WHERE
%s
AND control_id = @control_id
AND snapshot_id = @snapshot_id;
`
args := pgx.StrictNamedArgs{
"control_id": controlID,
"snapshot_id": snapshotID,
}
maps.Copy(args, scope.SQLArguments())
q = fmt.Sprintf(q, scope.SQLFragment())
_, err := conn.Exec(ctx, q, args)
return err
}

View File

@@ -54,7 +54,7 @@ const (
_ uint16 = 28 // NonconformityEntityType - removed
ObligationEntityType uint16 = 29
VendorServiceEntityType uint16 = 30
SnapshotEntityType uint16 = 31
_ uint16 = 31 // SnapshotEntityType - removed
_ uint16 = 32 // ContinualImprovementEntityType - removed
ProcessingActivityEntityType uint16 = 33
ExportJobEntityType uint16 = 34
@@ -176,8 +176,6 @@ func NewEntityFromID(id gid.GID) (any, bool) {
return &Obligation{ID: id}, true
case VendorServiceEntityType:
return &VendorService{ID: id}, true
case SnapshotEntityType:
return &Snapshot{ID: id}, true
case ProcessingActivityEntityType:
return &ProcessingActivity{ID: id}, true
case ExportJobEntityType:

View File

@@ -0,0 +1,90 @@
-- 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 risks_document_id TEXT REFERENCES documents(id) ON DELETE SET NULL;
-- Backfill controls_documents from the legacy controls_snapshots links.
-- For every snapshot type whose register is now an org-level generated
-- document, link each control that was attached to a snapshot to the
-- corresponding generated document. Best effort: skips rows whose target
-- document hasn't been created yet (the matching `cmd/migrate-*` data
-- migration must have already run). ON CONFLICT keeps the migration
-- idempotent and tolerant of pre-existing mappings.
INSERT INTO controls_documents (control_id, document_id, organization_id, tenant_id, created_at)
SELECT DISTINCT
cs.control_id,
CASE s.type
WHEN 'RISKS' THEN gd.risks_document_id
WHEN 'VENDORS' THEN gd.vendors_document_id
WHEN 'ASSETS' THEN gd.asset_list_document_id
WHEN 'DATA' THEN gd.data_document_id
WHEN 'FINDINGS' THEN gd.findings_document_id
WHEN 'OBLIGATIONS' THEN gd.obligations_document_id
WHEN 'PROCESSING_ACTIVITIES' THEN gd.processing_activities_document_id
END AS document_id,
s.organization_id,
s.tenant_id,
NOW()
FROM controls_snapshots cs
INNER JOIN snapshots s ON s.id = cs.snapshot_id
LEFT JOIN generated_documents gd ON gd.organization_id = s.organization_id
WHERE s.type IN (
'RISKS',
'VENDORS',
'ASSETS',
'DATA',
'FINDINGS',
'OBLIGATIONS',
'PROCESSING_ACTIVITIES'
)
AND CASE s.type
WHEN 'RISKS' THEN gd.risks_document_id
WHEN 'VENDORS' THEN gd.vendors_document_id
WHEN 'ASSETS' THEN gd.asset_list_document_id
WHEN 'DATA' THEN gd.data_document_id
WHEN 'FINDINGS' THEN gd.findings_document_id
WHEN 'OBLIGATIONS' THEN gd.obligations_document_id
WHEN 'PROCESSING_ACTIVITIES' THEN gd.processing_activities_document_id
END IS NOT NULL
ON CONFLICT DO NOTHING;
-- For STATEMENTS_OF_APPLICABILITY snapshots, the published document lives on
-- the source SOA (the live row, snapshot_id IS NULL). Link controls that
-- were attached to a SOA snapshot to that source SOA's document.
INSERT INTO controls_documents (control_id, document_id, organization_id, tenant_id, created_at)
SELECT DISTINCT
cs.control_id,
live_soa.document_id,
s.organization_id,
s.tenant_id,
NOW()
FROM controls_snapshots cs
INNER JOIN snapshots s ON s.id = cs.snapshot_id
INNER JOIN statements_of_applicability snap_soa ON snap_soa.snapshot_id = s.id
INNER JOIN statements_of_applicability live_soa
ON live_soa.id = snap_soa.source_id
AND live_soa.snapshot_id IS NULL
WHERE s.type = 'STATEMENTS_OF_APPLICABILITY'
AND live_soa.document_id IS NOT NULL
ON CONFLICT DO NOTHING;
-- Drop the trailing " List" suffix from previously published register
-- documents so the version title matches the new naming convention used by
-- the publish flow. Restricted to REGISTER document types so unrelated
-- documents that happen to share a title aren't touched.
UPDATE document_versions SET title = 'Assets' WHERE title = 'Asset List' AND document_type = 'REGISTER';
UPDATE document_versions SET title = 'Data' WHERE title = 'Data List' AND document_type = 'REGISTER';
UPDATE document_versions SET title = 'Findings' WHERE title = 'Finding List' AND document_type = 'REGISTER';
UPDATE document_versions SET title = 'Obligations' WHERE title = 'Obligation List' AND document_type = 'REGISTER';

View File

@@ -27,6 +27,113 @@ import (
"go.probo.inc/probo/pkg/page"
)
func (r Risk) GetGeneratedDocumentID(
ctx context.Context,
conn pg.Querier,
organizationID gid.GID,
) (*gid.GID, error) {
var documentID *gid.GID
err := conn.QueryRow(
ctx,
`
SELECT
risks_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 risk list document ID: %w", err)
}
return documentID, nil
}
func (r Risk) 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,
risks_document_id,
created_at,
updated_at
) VALUES (
@organization_id,
@tenant_id,
@risks_document_id,
@created_at,
@updated_at
)
ON CONFLICT (organization_id) DO UPDATE
SET
risks_document_id = @risks_document_id,
updated_at = @updated_at
`,
pgx.NamedArgs{
"organization_id": organizationID,
"tenant_id": tenantID,
"risks_document_id": documentID,
"created_at": now,
"updated_at": now,
},
)
if err != nil {
return fmt.Errorf("cannot upsert risk list document ID: %w", err)
}
return nil
}
func (r Risk) 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
risks_document_id = NULL,
updated_at = @now
WHERE
risks_document_id = ANY(@ids)
`,
pgx.NamedArgs{
"ids": ids,
"now": time.Now(),
},
)
if err != nil {
return fmt.Errorf("cannot clear risk list document references: %w", err)
}
return nil
}
type (
Risk struct {
ID gid.GID `db:"id"`
@@ -43,8 +150,6 @@ type (
ResidualLikelihood int `db:"residual_likelihood"`
ResidualImpact int `db:"residual_impact"`
ResidualRiskScore int `db:"residual_risk_score"`
SnapshotID *gid.GID `db:"snapshot_id"`
SourceID *gid.GID `db:"source_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
@@ -53,10 +158,6 @@ type (
}
Risks []*Risk
RiskSnapshotter interface {
InsertRiskSnapshots(ctx context.Context, conn pg.Tx, scope Scoper, organizationID, snapshotID gid.GID) error
}
)
func (r *Risk) CursorKey(orderBy RiskOrderField) page.CursorKey {
@@ -106,14 +207,14 @@ WITH rsks AS (
SELECT
r.id,
r.tenant_id,
r.search_vector,
r.snapshot_id
r.search_vector
FROM
risks r
INNER JOIN
risks_measures rm ON r.id = rm.risk_id
WHERE
rm.measure_id = @measure_id
AND r.snapshot_id IS NULL
)
SELECT
COUNT(id)
@@ -165,8 +266,6 @@ WITH rsks AS (
r.residual_likelihood,
r.residual_impact,
r.residual_risk_score,
r.snapshot_id,
r.source_id,
r.search_vector,
r.created_at,
r.updated_at
@@ -178,6 +277,7 @@ WITH rsks AS (
iam_membership_profiles p ON r.owner_profile_id = p.id
WHERE
rm.measure_id = @measure_id
AND r.snapshot_id IS NULL
)
SELECT
id,
@@ -195,8 +295,6 @@ SELECT
residual_likelihood,
residual_impact,
residual_risk_score,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -240,6 +338,7 @@ SELECT
FROM risks
WHERE %s
AND organization_id = @organization_id
AND snapshot_id IS NULL
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
@@ -285,8 +384,6 @@ WITH rsks AS (
r.residual_impact,
r.residual_risk_score,
r.category,
r.snapshot_id,
r.source_id,
r.search_vector,
r.created_at,
r.updated_at
@@ -296,6 +393,7 @@ WITH rsks AS (
iam_membership_profiles p ON r.owner_profile_id = p.id
WHERE
r.organization_id = @organization_id
AND r.snapshot_id IS NULL
)
SELECT
id,
@@ -313,8 +411,6 @@ SELECT
residual_impact,
residual_risk_score,
category,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -345,6 +441,58 @@ WHERE %s
return nil
}
func (r *Risks) LoadAllByOrganizationID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
organizationID gid.GID,
) error {
q := `
SELECT
r.id,
r.organization_id,
r.name,
r.description,
r.category,
r.owner_profile_id,
NULL as owner_full_name,
r.treatment,
r.note,
r.inherent_likelihood,
r.inherent_impact,
r.inherent_risk_score,
r.residual_likelihood,
r.residual_impact,
r.residual_risk_score,
r.created_at,
r.updated_at
FROM
risks r
WHERE %s
AND r.organization_id = @organization_id
AND r.snapshot_id IS NULL
ORDER BY r.name ASC, r.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 risks: %w", err)
}
risks, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Risk])
if err != nil {
return fmt.Errorf("cannot collect risks: %w", err)
}
*r = risks
return nil
}
func (r *Risk) LoadByID(
ctx context.Context,
conn pg.Querier,
@@ -368,8 +516,6 @@ SELECT
residual_likelihood,
residual_impact,
residual_risk_score,
snapshot_id,
source_id,
created_at,
updated_at
FROM risks
@@ -424,8 +570,6 @@ SELECT
residual_likelihood,
residual_impact,
residual_risk_score,
snapshot_id,
source_id,
created_at,
updated_at
FROM risks
@@ -567,14 +711,14 @@ WITH rsks AS (
SELECT
r.id,
r.tenant_id,
r.search_vector,
r.snapshot_id
r.search_vector
FROM
risks r
INNER JOIN
risks_documents rd ON r.id = rd.risk_id
WHERE
rd.document_id = @document_id
AND r.snapshot_id IS NULL
)
SELECT
COUNT(id)
@@ -598,78 +742,3 @@ WHERE %s
return count, nil
}
func (r Risks) Snapshot(ctx context.Context, conn pg.Tx, scope Scoper, organizationID, snapshotID gid.GID) error {
if err := r.InsertRiskSnapshots(ctx, conn, scope, organizationID, snapshotID); err != nil {
return fmt.Errorf("cannot create risk snapshots: %w", err)
}
return nil
}
func (r Risks) InsertRiskSnapshots(
ctx context.Context,
conn pg.Tx,
scope Scoper,
organizationID gid.GID,
snapshotID gid.GID,
) error {
query := `
INSERT INTO risks (
tenant_id,
id,
snapshot_id,
source_id,
organization_id,
name,
description,
category,
treatment,
note,
owner_profile_id,
inherent_likelihood,
inherent_impact,
residual_likelihood,
residual_impact,
created_at,
updated_at
)
SELECT
@tenant_id,
generate_gid(decode_base64_unpadded(@tenant_id), @risk_entity_type),
@snapshot_id,
r.id,
r.organization_id,
r.name,
r.description,
r.category,
r.treatment,
r.note,
r.owner_profile_id,
r.inherent_likelihood,
r.inherent_impact,
r.residual_likelihood,
r.residual_impact,
r.created_at,
r.updated_at
FROM risks r
WHERE %s AND organization_id = @organization_id AND snapshot_id IS NULL
`
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"snapshot_id": snapshotID,
"organization_id": organizationID,
"risk_entity_type": RiskEntityType,
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, query, args)
if err != nil {
return fmt.Errorf("cannot insert risk snapshots: %w", err)
}
return nil
}

View File

@@ -16,40 +16,24 @@ package coredata
import (
"github.com/jackc/pgx/v5"
"go.probo.inc/probo/pkg/gid"
)
type (
RiskFilter struct {
query *string
snapshotID **gid.GID
query *string
}
)
func NewRiskFilter(query *string, snapshotID **gid.GID) *RiskFilter {
func NewRiskFilter(query *string) *RiskFilter {
return &RiskFilter{
query: query,
snapshotID: snapshotID,
query: query,
}
}
func (f *RiskFilter) SQLArguments() pgx.StrictNamedArgs {
args := pgx.StrictNamedArgs{
return pgx.StrictNamedArgs{
"query": f.query,
}
if f.snapshotID == nil {
args["has_snapshot_filter"] = false
args["filter_snapshot_id"] = nil
} else if *f.snapshotID == nil {
args["has_snapshot_filter"] = true
args["filter_snapshot_id"] = nil
} else {
args["has_snapshot_filter"] = true
args["filter_snapshot_id"] = **f.snapshotID
}
return args
}
func (f *RiskFilter) SQLFragment() string {
@@ -63,14 +47,5 @@ func (f *RiskFilter) SQLFragment() string {
)
ELSE TRUE
END
AND
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
)`
}

View File

@@ -1,316 +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 (
"context"
"errors"
"fmt"
"maps"
"time"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
Snapshot struct {
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
Name string `db:"name"`
Description *string `db:"description"`
Type SnapshotsType `db:"type"`
CreatedAt time.Time `db:"created_at"`
}
Snapshots []*Snapshot
)
func (s *Snapshot) CursorKey(field SnapshotOrderField) page.CursorKey {
switch field {
case SnapshotOrderFieldCreatedAt:
return page.NewCursorKey(s.ID, s.CreatedAt)
case SnapshotOrderFieldName:
return page.NewCursorKey(s.ID, s.Name)
case SnapshotOrderFieldType:
return page.NewCursorKey(s.ID, s.Type)
}
panic(fmt.Sprintf("unsupported order by: %s", field))
}
func (s *Snapshot) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) {
q := `SELECT organization_id FROM snapshots WHERE id = $1 LIMIT 1;`
var organizationID gid.GID
if err := conn.QueryRow(ctx, q, s.ID).Scan(&organizationID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrResourceNotFound
}
return nil, fmt.Errorf("cannot query snapshot authorization attributes: %w", err)
}
return map[string]string{"organization_id": organizationID.String()}, nil
}
func (s *Snapshot) LoadByID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
snapshotID gid.GID,
) error {
q := `
SELECT
id,
organization_id,
name,
description,
type,
created_at
FROM
snapshots
WHERE
%s
AND id = @snapshot_id
LIMIT 1;
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"snapshot_id": snapshotID}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query snapshots: %w", err)
}
snapshot, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Snapshot])
if err != nil {
return fmt.Errorf("cannot collect snapshot: %w", err)
}
*s = snapshot
return nil
}
func (s *Snapshots) CountByOrganizationID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
organizationID gid.GID,
filter *SnapshotFilter,
) (int, error) {
q := `
SELECT
COUNT(id)
FROM
snapshots
WHERE
%s
AND organization_id = @organization_id
AND type = 'RISKS'
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
args := pgx.StrictNamedArgs{"organization_id": organizationID}
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 (s *Snapshots) LoadByOrganizationID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
organizationID gid.GID,
cursor *page.Cursor[SnapshotOrderField],
) error {
q := `
SELECT
id,
organization_id,
name,
description,
type,
created_at
FROM
snapshots
WHERE
%s
AND organization_id = @organization_id
AND type = 'RISKS'
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
args := pgx.StrictNamedArgs{"organization_id": organizationID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query snapshots: %w", err)
}
snapshots, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Snapshot])
if err != nil {
return fmt.Errorf("cannot collect snapshots: %w", err)
}
*s = snapshots
return nil
}
func (s *Snapshot) Insert(
ctx context.Context,
conn pg.Tx,
scope Scoper,
) error {
q := `
INSERT INTO snapshots (
id,
tenant_id,
organization_id,
name,
description,
type,
created_at
) VALUES (
@id,
@tenant_id,
@organization_id,
@name,
@description,
@type,
@created_at
)
`
args := pgx.StrictNamedArgs{
"id": s.ID,
"tenant_id": scope.GetTenantID(),
"organization_id": s.OrganizationID,
"name": s.Name,
"description": s.Description,
"type": s.Type,
"created_at": s.CreatedAt,
}
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot insert snapshot: %w", err)
}
return nil
}
func (s *Snapshot) Delete(
ctx context.Context,
conn pg.Tx,
scope Scoper,
) error {
q := `
DELETE FROM snapshots
WHERE
%s
AND organization_id = @organization_id
AND id = @id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"id": s.ID, "organization_id": s.OrganizationID}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot delete snapshot: %w", err)
}
return nil
}
func (s *Snapshots) LoadByControlID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
controlID gid.GID,
cursor *page.Cursor[SnapshotOrderField],
) error {
q := `
WITH snapshots_by_control AS (
SELECT
s.id,
s.tenant_id,
s.organization_id,
s.name,
s.description,
s.type,
s.created_at
FROM
snapshots s
INNER JOIN
controls_snapshots cs ON s.id = cs.snapshot_id
WHERE
cs.control_id = @control_id
)
SELECT
id,
organization_id,
name,
description,
type,
created_at
FROM
snapshots_by_control
WHERE %s
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
args := pgx.StrictNamedArgs{"control_id": controlID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query snapshots: %w", err)
}
snapshots, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Snapshot])
if err != nil {
return fmt.Errorf("cannot collect snapshots: %w", err)
}
*s = snapshots
return nil
}

View File

@@ -1,65 +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 (
"time"
"github.com/jackc/pgx/v5"
)
type (
SnapshotFilter struct {
snapshotType *SnapshotsType
beforeDate *time.Time
}
)
func NewSnapshotFilter(snapshotType *SnapshotsType) *SnapshotFilter {
return &SnapshotFilter{
snapshotType: snapshotType,
}
}
func (f *SnapshotFilter) WithBeforeDate(beforeDate *time.Time) *SnapshotFilter {
f.beforeDate = beforeDate
return f
}
func (f *SnapshotFilter) SQLArguments() pgx.NamedArgs {
args := pgx.NamedArgs{
"filter_snapshot_type": f.snapshotType,
"filter_before_date": f.beforeDate,
}
return args
}
func (f *SnapshotFilter) SQLFragment() string {
return `
(
CASE
WHEN @filter_snapshot_type::snapshots_type IS NOT NULL THEN
type = @filter_snapshot_type::snapshots_type
ELSE TRUE
END
AND
CASE
WHEN @filter_before_date::timestamptz IS NOT NULL THEN
created_at <= @filter_before_date::timestamptz
ELSE TRUE
END
)`
}

View File

@@ -1,51 +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 (
"fmt"
)
type SnapshotOrderField string
const (
SnapshotOrderFieldCreatedAt SnapshotOrderField = "CREATED_AT"
SnapshotOrderFieldName SnapshotOrderField = "NAME"
SnapshotOrderFieldType SnapshotOrderField = "TYPE"
)
func (p SnapshotOrderField) Column() string {
return string(p)
}
func (p SnapshotOrderField) String() string {
return string(p)
}
func (p SnapshotOrderField) MarshalText() ([]byte, error) {
return []byte(p.String()), nil
}
func (p *SnapshotOrderField) UnmarshalText(text []byte) error {
val := string(text)
switch val {
case string(SnapshotOrderFieldCreatedAt),
string(SnapshotOrderFieldName),
string(SnapshotOrderFieldType):
*p = SnapshotOrderField(val)
return nil
}
return fmt.Errorf("invalid SnapshotOrderField value: %q", val)
}

View File

@@ -1,80 +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 (
"database/sql/driver"
"fmt"
)
type (
SnapshotsType string
)
const (
SnapshotsTypeRisks SnapshotsType = "RISKS"
SnapshotsTypeAssets SnapshotsType = "ASSETS"
SnapshotsTypeData SnapshotsType = "DATA"
SnapshotsTypeFindings SnapshotsType = "FINDINGS"
SnapshotsTypeObligations SnapshotsType = "OBLIGATIONS"
SnapshotsTypeProcessingActivities SnapshotsType = "PROCESSING_ACTIVITIES"
SnapshotsTypeStatementsOfApplicability SnapshotsType = "STATEMENTS_OF_APPLICABILITY"
)
func SnapshotsTypes() []SnapshotsType {
return []SnapshotsType{
SnapshotsTypeRisks,
}
}
func (st SnapshotsType) String() string {
return string(st)
}
func (st *SnapshotsType) Scan(value any) error {
var s string
switch v := value.(type) {
case string:
s = v
case []byte:
s = string(v)
default:
return fmt.Errorf("unsupported type for SnapshotsType: %T", value)
}
switch s {
case SnapshotsTypeRisks.String():
*st = SnapshotsTypeRisks
case SnapshotsTypeAssets.String():
*st = SnapshotsTypeAssets
case SnapshotsTypeData.String():
*st = SnapshotsTypeData
case SnapshotsTypeFindings.String(), "NONCONFORMITIES", "CONTINUAL_IMPROVEMENTS":
*st = SnapshotsTypeFindings
case SnapshotsTypeObligations.String():
*st = SnapshotsTypeObligations
case SnapshotsTypeProcessingActivities.String():
*st = SnapshotsTypeProcessingActivities
case SnapshotsTypeStatementsOfApplicability.String():
*st = SnapshotsTypeStatementsOfApplicability
default:
return fmt.Errorf("invalid SnapshotsType value: %q", s)
}
return nil
}
func (st SnapshotsType) Value() (driver.Value, error) {
return st.String(), nil
}

View File

@@ -1,36 +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 (
"context"
"fmt"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
)
type Snapshottable interface {
Snapshot(ctx context.Context, conn pg.Tx, scope Scoper, organizationID, snapshotID gid.GID) error
}
func GetSnapshottable(snapshotType SnapshotsType) (Snapshottable, error) {
switch snapshotType {
case SnapshotsTypeRisks:
return Risks{}, nil
default:
return nil, fmt.Errorf("unsupported snapshot type: %s", snapshotType)
}
}