Remove deprecated snapshot system

The register/document model has fully replaced the snapshot system.
Delete every snapshot-scoped row and strip the application code that
referenced them: SnapshotID/SourceID struct fields, snapshot_id IS NULL
filters, snapshot columns from SELECT/INSERT statements and named args,
and the eight migrate-*-snapshots-to-documents one-shot tools.

The remaining snapshot_id / source_id columns, the snapshots and
controls_snapshots tables, the snapshots_type enum, and the
snapshot-scoped indexes are now unused; they are dropped in a follow-up
schema migration so this change can roll back cleanly without leaving
orphaned data.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-05-06 17:24:58 +02:00
parent 79fa9a6e46
commit 3ff66757ad
28 changed files with 34 additions and 4597 deletions

View File

@@ -1,439 +0,0 @@
// 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.
// Command migrate-asset-snapshots-to-documents creates documents and document
// versions from existing asset snapshots. For each organization that has asset
// snapshots, it generates an asset list document using the same ProseMirror
// builder as the publish flow.
package main
import (
"context"
"flag"
"fmt"
"net"
"net/url"
"os"
"strings"
"time"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/docgen"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/probo"
)
func main() {
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}
func run() error {
var (
pgDSN string
dryRun bool
)
flag.StringVar(
&pgDSN,
"pg-dsn",
os.Getenv("DATABASE_URL"),
"PostgreSQL connection URL (default: DATABASE_URL env)",
)
flag.BoolVar(&dryRun, "dry-run", false, "show what would be done without writing")
flag.Parse()
if pgDSN == "" {
return fmt.Errorf("set -pg-dsn or DATABASE_URL")
}
ctx := context.Background()
pgClient, err := newPgClientFromDSN(pgDSN)
if err != nil {
return fmt.Errorf("cannot create pg client: %w", err)
}
return pgClient.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
return migrate(ctx, tx, dryRun)
})
}
type orgWithAssetSnapshots struct {
organizationID gid.GID
tenantID gid.TenantID
organizationName string
}
type assetSnapshot struct {
snapshotID string
publishedAt time.Time
}
func migrate(ctx context.Context, tx pg.Tx, dryRun bool) error {
orgs, err := loadOrgsWithAssetSnapshots(ctx, tx)
if err != nil {
return err
}
if len(orgs) == 0 {
fmt.Println("no organizations with asset snapshots to migrate")
return nil
}
var stats struct {
documents, versions int
}
for _, org := range orgs {
snapshots, err := loadAssetSnapshots(ctx, tx, org.organizationID)
if err != nil {
return err
}
if dryRun {
fmt.Printf("would migrate org %s (%s) — %d asset snapshot(s)\n",
org.organizationID, org.organizationName, len(snapshots))
continue
}
documentID := gid.New(org.tenantID, coredata.DocumentEntityType)
now := time.Now()
_, err = tx.Exec(
ctx,
`
INSERT INTO documents (
id, tenant_id, organization_id, write_mode,
current_published_major, current_published_minor,
trust_center_visibility, status, created_at, updated_at
) VALUES (
@id, @tenant_id, @organization_id,
'GENERATED'::document_write_mode,
@current_published_major, 0,
'NONE'::trust_center_visibility,
'ACTIVE'::document_status,
@created_at, @updated_at
)`,
pgx.NamedArgs{
"id": documentID,
"tenant_id": org.tenantID,
"organization_id": org.organizationID,
"current_published_major": len(snapshots),
"created_at": now,
"updated_at": now,
},
)
if err != nil {
return fmt.Errorf("cannot insert document for org %s: %w", org.organizationID, err)
}
stats.documents++
_, err = tx.Exec(
ctx,
`INSERT INTO generated_documents (organization_id, tenant_id, asset_list_document_id, created_at, updated_at)
VALUES (@organization_id, @tenant_id, @asset_list_document_id, @created_at, @updated_at)
ON CONFLICT (organization_id) DO UPDATE SET asset_list_document_id = @asset_list_document_id, updated_at = @updated_at`,
pgx.NamedArgs{
"organization_id": org.organizationID,
"tenant_id": org.tenantID,
"asset_list_document_id": documentID,
"created_at": now,
"updated_at": now,
},
)
if err != nil {
return fmt.Errorf("cannot link document to org %s: %w", org.organizationID, err)
}
for major, snap := range snapshots {
content, err := buildSnapshotContent(ctx, tx, snap.snapshotID, org.organizationName, snap.publishedAt)
if err != nil {
return fmt.Errorf("cannot build content for snapshot %s of org %s: %w",
snap.snapshotID, org.organizationID, err)
}
versionID := gid.New(org.tenantID, coredata.DocumentVersionEntityType)
_, err = tx.Exec(
ctx,
`
INSERT INTO document_versions (
id, tenant_id, organization_id, document_id,
title, major, minor, classification, document_type,
content, changelog, status, orientation,
published_at, created_at, updated_at
) VALUES (
@id, @tenant_id, @organization_id, @document_id,
@title, @major, 0,
'CONFIDENTIAL'::document_classification,
'REGISTER'::document_type,
@content, '',
'PUBLISHED'::document_version_status,
'PORTRAIT'::document_version_orientation,
@published_at, @published_at, @published_at
)`,
pgx.NamedArgs{
"id": versionID,
"tenant_id": org.tenantID,
"organization_id": org.organizationID,
"document_id": documentID,
"title": "Assets",
"major": major + 1,
"content": content,
"published_at": snap.publishedAt,
},
)
if err != nil {
return fmt.Errorf("cannot insert version for snapshot %s: %w", snap.snapshotID, err)
}
stats.versions++
}
fmt.Printf("migrated org %s (%s) — %d version(s)\n",
org.organizationID, org.organizationName, len(snapshots))
}
if dryRun {
fmt.Printf("\n%d organization(s) would be migrated\n", len(orgs))
return nil
}
fmt.Printf("\ncreated %d document(s), %d version(s)\n", stats.documents, stats.versions)
return nil
}
func loadOrgsWithAssetSnapshots(ctx context.Context, tx pg.Tx) ([]orgWithAssetSnapshots, error) {
rows, err := tx.Query(
ctx,
`
SELECT DISTINCT
o.id,
o.tenant_id,
o.name,
o.created_at
FROM organizations o
WHERE NOT EXISTS (
SELECT 1 FROM generated_documents gd
WHERE gd.organization_id = o.id AND gd.asset_list_document_id IS NOT NULL
)
AND EXISTS (
SELECT 1 FROM assets a
WHERE a.organization_id = o.id AND a.snapshot_id IS NOT NULL
)
ORDER BY o.created_at;
`,
)
if err != nil {
return nil, fmt.Errorf("cannot query organizations with asset snapshots: %w", err)
}
defer rows.Close()
var result []orgWithAssetSnapshots
for rows.Next() {
var o orgWithAssetSnapshots
var createdAt time.Time
if err := rows.Scan(&o.organizationID, &o.tenantID, &o.organizationName, &createdAt); err != nil {
return nil, fmt.Errorf("cannot scan organization: %w", err)
}
result = append(result, o)
}
return result, rows.Err()
}
func loadAssetSnapshots(ctx context.Context, tx pg.Tx, organizationID gid.GID) ([]assetSnapshot, error) {
rows, err := tx.Query(
ctx,
`
SELECT DISTINCT
s.id,
s.created_at
FROM snapshots s
WHERE s.organization_id = @organization_id
AND s.type = 'ASSETS'
ORDER BY s.created_at ASC;
`,
pgx.NamedArgs{"organization_id": organizationID},
)
if err != nil {
return nil, fmt.Errorf("cannot query asset snapshots for org %s: %w", organizationID, err)
}
defer rows.Close()
var result []assetSnapshot
for rows.Next() {
var s assetSnapshot
if err := rows.Scan(&s.snapshotID, &s.publishedAt); err != nil {
return nil, fmt.Errorf("cannot scan snapshot: %w", err)
}
result = append(result, s)
}
return result, rows.Err()
}
func buildSnapshotContent(
ctx context.Context,
tx pg.Tx,
snapshotID string,
orgName string,
publishedAt time.Time,
) (string, error) {
rows, err := tx.Query(
ctx,
`
SELECT
a.id,
a.name,
a.asset_type,
a.amount,
a.data_types_stored,
COALESCE(p.full_name, '-')
FROM assets a
LEFT JOIN iam_membership_profiles p ON p.id = a.owner_profile_id
WHERE a.snapshot_id = @snapshot_id
ORDER BY a.name ASC;
`,
pgx.NamedArgs{"snapshot_id": snapshotID},
)
if err != nil {
return "", fmt.Errorf("cannot load snapshot assets: %w", err)
}
defer rows.Close()
type assetInfo struct {
id string
name string
assetType string
amount int
dataTypesStored string
ownerName string
}
var assets []assetInfo
for rows.Next() {
var a assetInfo
if err := rows.Scan(&a.id, &a.name, &a.assetType, &a.amount, &a.dataTypesStored, &a.ownerName); err != nil {
return "", fmt.Errorf("cannot scan asset: %w", err)
}
assets = append(assets, a)
}
if err := rows.Err(); err != nil {
return "", err
}
thirdPartyRows, err := tx.Query(
ctx,
`
SELECT
av.asset_id,
v.name
FROM asset_third_parties av
JOIN third_parties v ON v.id = av.third_party_id
WHERE av.snapshot_id = @snapshot_id
ORDER BY v.name ASC;
`,
pgx.NamedArgs{"snapshot_id": snapshotID},
)
if err != nil {
return "", fmt.Errorf("cannot load snapshot asset thirdParties: %w", err)
}
defer thirdPartyRows.Close()
thirdPartiesByAsset := make(map[string][]string)
for thirdPartyRows.Next() {
var assetID, thirdPartyName string
if err := thirdPartyRows.Scan(&assetID, &thirdPartyName); err != nil {
return "", fmt.Errorf("cannot scan thirdParty: %w", err)
}
thirdPartiesByAsset[assetID] = append(thirdPartiesByAsset[assetID], thirdPartyName)
}
if err := thirdPartyRows.Err(); err != nil {
return "", err
}
assetRows := make([]docgen.AssetListRow, len(assets))
for i, a := range assets {
thirdParties := "-"
if v, ok := thirdPartiesByAsset[a.id]; ok && len(v) > 0 {
thirdParties = strings.Join(v, ", ")
}
assetRows[i] = docgen.AssetListRow{
Name: a.name,
AssetType: formatAssetTypeString(a.assetType),
Amount: a.amount,
DataTypesStored: a.dataTypesStored,
Owner: a.ownerName,
ThirdParties: thirdParties,
}
}
docData := docgen.AssetListData{
Title: "Assets",
OrganizationName: orgName,
CreatedAt: publishedAt,
TotalAssets: len(assetRows),
Rows: assetRows,
}
return probo.BuildAssetListDocument(docData)
}
func formatAssetTypeString(t string) string {
switch t {
case "PHYSICAL":
return "Physical"
case "VIRTUAL":
return "Virtual"
default:
return t
}
}
func newPgClientFromDSN(dsn string) (*pg.Client, error) {
u, err := url.Parse(dsn)
if err != nil {
return nil, fmt.Errorf("cannot parse DSN")
}
var opts []pg.Option
if u.Host != "" {
host := u.Host
if u.Port() == "" {
host = net.JoinHostPort(u.Hostname(), "5432")
}
opts = append(opts, pg.WithAddr(host))
}
if u.User != nil {
opts = append(opts, pg.WithUser(u.User.Username()))
if password, ok := u.User.Password(); ok {
opts = append(opts, pg.WithPassword(password))
}
}
if len(u.Path) > 1 {
opts = append(opts, pg.WithDatabase(u.Path[1:]))
}
return pg.NewClient(opts...)
}

View File

@@ -1,438 +0,0 @@
// 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.
// Command migrate-data-snapshots-to-documents creates documents and document
// versions from existing data snapshots. For each organization that has data
// snapshots, it generates a data list document using the same ProseMirror
// builder as the publish flow.
package main
import (
"context"
"flag"
"fmt"
"net"
"net/url"
"os"
"strings"
"time"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/docgen"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/probo"
)
func main() {
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}
func run() error {
var (
pgDSN string
dryRun bool
)
flag.StringVar(
&pgDSN,
"pg-dsn",
os.Getenv("DATABASE_URL"),
"PostgreSQL connection URL (default: DATABASE_URL env)",
)
flag.BoolVar(&dryRun, "dry-run", false, "show what would be done without writing")
flag.Parse()
if pgDSN == "" {
return fmt.Errorf("set -pg-dsn or DATABASE_URL")
}
ctx := context.Background()
pgClient, err := newPgClientFromDSN(pgDSN)
if err != nil {
return fmt.Errorf("cannot create pg client: %w", err)
}
return pgClient.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
return migrate(ctx, tx, dryRun)
})
}
type orgWithDataSnapshots struct {
organizationID gid.GID
tenantID gid.TenantID
organizationName string
}
type dataSnapshot struct {
snapshotID string
publishedAt time.Time
}
func migrate(ctx context.Context, tx pg.Tx, dryRun bool) error {
orgs, err := loadOrgsWithDataSnapshots(ctx, tx)
if err != nil {
return err
}
if len(orgs) == 0 {
fmt.Println("no organizations with data snapshots to migrate")
return nil
}
var stats struct {
documents, versions int
}
for _, org := range orgs {
snapshots, err := loadDataSnapshots(ctx, tx, org.organizationID)
if err != nil {
return err
}
if dryRun {
fmt.Printf("would migrate org %s (%s) — %d data snapshot(s)\n",
org.organizationID, org.organizationName, len(snapshots))
continue
}
documentID := gid.New(org.tenantID, coredata.DocumentEntityType)
now := time.Now()
_, err = tx.Exec(
ctx,
`
INSERT INTO documents (
id, tenant_id, organization_id, write_mode,
current_published_major, current_published_minor,
trust_center_visibility, status, created_at, updated_at
) VALUES (
@id, @tenant_id, @organization_id,
'GENERATED'::document_write_mode,
@current_published_major, 0,
'NONE'::trust_center_visibility,
'ACTIVE'::document_status,
@created_at, @updated_at
)`,
pgx.NamedArgs{
"id": documentID,
"tenant_id": org.tenantID,
"organization_id": org.organizationID,
"current_published_major": len(snapshots),
"created_at": now,
"updated_at": now,
},
)
if err != nil {
return fmt.Errorf("cannot insert document for org %s: %w", org.organizationID, err)
}
stats.documents++
_, err = tx.Exec(
ctx,
`INSERT INTO generated_documents (organization_id, tenant_id, data_document_id, created_at, updated_at)
VALUES (@organization_id, @tenant_id, @data_document_id, @created_at, @updated_at)
ON CONFLICT (organization_id) DO UPDATE SET data_document_id = @data_document_id, updated_at = @updated_at`,
pgx.NamedArgs{
"organization_id": org.organizationID,
"tenant_id": org.tenantID,
"data_document_id": documentID,
"created_at": now,
"updated_at": now,
},
)
if err != nil {
return fmt.Errorf("cannot link document to org %s: %w", org.organizationID, err)
}
for major, snap := range snapshots {
content, err := buildSnapshotContent(ctx, tx, snap.snapshotID, org.organizationName, snap.publishedAt)
if err != nil {
return fmt.Errorf("cannot build content for snapshot %s of org %s: %w",
snap.snapshotID, org.organizationID, err)
}
versionID := gid.New(org.tenantID, coredata.DocumentVersionEntityType)
_, err = tx.Exec(
ctx,
`
INSERT INTO document_versions (
id, tenant_id, organization_id, document_id,
title, major, minor, classification, document_type,
content, changelog, status, orientation,
published_at, created_at, updated_at
) VALUES (
@id, @tenant_id, @organization_id, @document_id,
@title, @major, 0,
'CONFIDENTIAL'::document_classification,
'REGISTER'::document_type,
@content, '',
'PUBLISHED'::document_version_status,
'PORTRAIT'::document_version_orientation,
@published_at, @published_at, @published_at
)`,
pgx.NamedArgs{
"id": versionID,
"tenant_id": org.tenantID,
"organization_id": org.organizationID,
"document_id": documentID,
"title": "Data",
"major": major + 1,
"content": content,
"published_at": snap.publishedAt,
},
)
if err != nil {
return fmt.Errorf("cannot insert version for snapshot %s: %w", snap.snapshotID, err)
}
stats.versions++
}
fmt.Printf("migrated org %s (%s) — %d version(s)\n",
org.organizationID, org.organizationName, len(snapshots))
}
if dryRun {
fmt.Printf("\n%d organization(s) would be migrated\n", len(orgs))
return nil
}
fmt.Printf("\ncreated %d document(s), %d version(s)\n", stats.documents, stats.versions)
return nil
}
func loadOrgsWithDataSnapshots(ctx context.Context, tx pg.Tx) ([]orgWithDataSnapshots, error) {
rows, err := tx.Query(
ctx,
`
SELECT DISTINCT
o.id,
o.tenant_id,
o.name,
o.created_at
FROM organizations o
WHERE NOT EXISTS (
SELECT 1 FROM generated_documents gd
WHERE gd.organization_id = o.id AND gd.data_document_id IS NOT NULL
)
AND EXISTS (
SELECT 1 FROM data d
WHERE d.organization_id = o.id AND d.snapshot_id IS NOT NULL
)
ORDER BY o.created_at;
`,
)
if err != nil {
return nil, fmt.Errorf("cannot query organizations with data snapshots: %w", err)
}
defer rows.Close()
var result []orgWithDataSnapshots
for rows.Next() {
var o orgWithDataSnapshots
var createdAt time.Time
if err := rows.Scan(&o.organizationID, &o.tenantID, &o.organizationName, &createdAt); err != nil {
return nil, fmt.Errorf("cannot scan organization: %w", err)
}
result = append(result, o)
}
return result, rows.Err()
}
func loadDataSnapshots(ctx context.Context, tx pg.Tx, organizationID gid.GID) ([]dataSnapshot, error) {
rows, err := tx.Query(
ctx,
`
SELECT DISTINCT
s.id,
s.created_at
FROM snapshots s
WHERE s.organization_id = @organization_id
AND s.type = 'DATA'
ORDER BY s.created_at ASC;
`,
pgx.NamedArgs{"organization_id": organizationID},
)
if err != nil {
return nil, fmt.Errorf("cannot query data snapshots for org %s: %w", organizationID, err)
}
defer rows.Close()
var result []dataSnapshot
for rows.Next() {
var s dataSnapshot
if err := rows.Scan(&s.snapshotID, &s.publishedAt); err != nil {
return nil, fmt.Errorf("cannot scan snapshot: %w", err)
}
result = append(result, s)
}
return result, rows.Err()
}
func buildSnapshotContent(
ctx context.Context,
tx pg.Tx,
snapshotID string,
orgName string,
publishedAt time.Time,
) (string, error) {
rows, err := tx.Query(
ctx,
`
SELECT
d.id,
d.name,
d.data_classification,
COALESCE(p.full_name, '-')
FROM data d
LEFT JOIN iam_membership_profiles p ON p.id = d.owner_profile_id
WHERE d.snapshot_id = @snapshot_id
ORDER BY d.name ASC;
`,
pgx.NamedArgs{"snapshot_id": snapshotID},
)
if err != nil {
return "", fmt.Errorf("cannot load snapshot data: %w", err)
}
defer rows.Close()
type datumInfo struct {
id string
name string
classification string
ownerName string
}
var data []datumInfo
for rows.Next() {
var d datumInfo
if err := rows.Scan(&d.id, &d.name, &d.classification, &d.ownerName); err != nil {
return "", fmt.Errorf("cannot scan datum: %w", err)
}
data = append(data, d)
}
if err := rows.Err(); err != nil {
return "", err
}
// Load thirdParties for each datum in this snapshot.
thirdPartyRows, err := tx.Query(
ctx,
`
SELECT
dv.datum_id,
v.name
FROM data_third_parties dv
JOIN third_parties v ON v.id = dv.third_party_id
WHERE dv.snapshot_id = @snapshot_id
ORDER BY v.name ASC;
`,
pgx.NamedArgs{"snapshot_id": snapshotID},
)
if err != nil {
return "", fmt.Errorf("cannot load snapshot data thirdParties: %w", err)
}
defer thirdPartyRows.Close()
thirdPartiesByDatum := make(map[string][]string)
for thirdPartyRows.Next() {
var datumID, thirdPartyName string
if err := thirdPartyRows.Scan(&datumID, &thirdPartyName); err != nil {
return "", fmt.Errorf("cannot scan thirdParty: %w", err)
}
thirdPartiesByDatum[datumID] = append(thirdPartiesByDatum[datumID], thirdPartyName)
}
if err := thirdPartyRows.Err(); err != nil {
return "", err
}
dataRows := make([]docgen.DataListRow, len(data))
for i, d := range data {
thirdParties := "-"
if v, ok := thirdPartiesByDatum[d.id]; ok && len(v) > 0 {
thirdParties = strings.Join(v, ", ")
}
dataRows[i] = docgen.DataListRow{
Name: d.name,
Classification: formatClassificationString(d.classification),
Owner: d.ownerName,
ThirdParties: thirdParties,
}
}
docData := docgen.DataListData{
Title: "Data",
OrganizationName: orgName,
CreatedAt: publishedAt,
TotalData: len(dataRows),
Rows: dataRows,
}
return probo.BuildDataListDocument(docData)
}
func formatClassificationString(c string) string {
switch c {
case "PUBLIC":
return "Public"
case "INTERNAL":
return "Internal"
case "CONFIDENTIAL":
return "Confidential"
case "SECRET":
return "Secret"
default:
return c
}
}
func newPgClientFromDSN(dsn string) (*pg.Client, error) {
u, err := url.Parse(dsn)
if err != nil {
return nil, fmt.Errorf("cannot parse DSN")
}
var opts []pg.Option
if u.Host != "" {
host := u.Host
if u.Port() == "" {
host = net.JoinHostPort(u.Hostname(), "5432")
}
opts = append(opts, pg.WithAddr(host))
}
if u.User != nil {
opts = append(opts, pg.WithUser(u.User.Username()))
if password, ok := u.User.Password(); ok {
opts = append(opts, pg.WithPassword(password))
}
}
if len(u.Path) > 1 {
opts = append(opts, pg.WithDatabase(u.Path[1:]))
}
return pg.NewClient(opts...)
}

View File

@@ -1,492 +0,0 @@
// 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.
// Command migrate-finding-snapshots-to-documents creates documents and document
// versions from existing finding snapshots. For each organization that has finding
// snapshots, it generates a finding register document using the same ProseMirror
// builder as the publish flow.
package main
import (
"context"
"flag"
"fmt"
"net"
"net/url"
"os"
"time"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/docgen"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/probo"
)
func main() {
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}
func run() error {
var (
pgDSN string
dryRun bool
)
flag.StringVar(
&pgDSN,
"pg-dsn",
os.Getenv("DATABASE_URL"),
"PostgreSQL connection URL (default: DATABASE_URL env)",
)
flag.BoolVar(&dryRun, "dry-run", false, "show what would be done without writing")
flag.Parse()
if pgDSN == "" {
return fmt.Errorf("set -pg-dsn or DATABASE_URL")
}
ctx := context.Background()
pgClient, err := newPgClientFromDSN(pgDSN)
if err != nil {
return fmt.Errorf("cannot create pg client: %w", err)
}
return pgClient.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
return migrate(ctx, tx, dryRun)
})
}
type orgWithFindingSnapshots struct {
organizationID gid.GID
tenantID gid.TenantID
organizationName string
}
type findingSnapshot struct {
snapshotID string
publishedAt time.Time
}
func migrate(ctx context.Context, tx pg.Tx, dryRun bool) error {
orgs, err := loadOrgsWithFindingSnapshots(ctx, tx)
if err != nil {
return err
}
if len(orgs) == 0 {
fmt.Println("no organizations with finding snapshots to migrate")
return nil
}
var stats struct {
documents, versions int
}
for _, org := range orgs {
snapshots, err := loadFindingSnapshots(ctx, tx, org.organizationID)
if err != nil {
return err
}
if dryRun {
fmt.Printf("would migrate org %s (%s) — %d finding snapshot(s)\n",
org.organizationID, org.organizationName, len(snapshots))
continue
}
documentID := gid.New(org.tenantID, coredata.DocumentEntityType)
now := time.Now()
_, err = tx.Exec(
ctx,
`
INSERT INTO documents (
id, tenant_id, organization_id, write_mode,
current_published_major, current_published_minor,
trust_center_visibility, status, created_at, updated_at
) VALUES (
@id, @tenant_id, @organization_id,
'GENERATED'::document_write_mode,
@current_published_major, 0,
'NONE'::trust_center_visibility,
'ACTIVE'::document_status,
@created_at, @updated_at
)`,
pgx.NamedArgs{
"id": documentID,
"tenant_id": org.tenantID,
"organization_id": org.organizationID,
"current_published_major": len(snapshots),
"created_at": now,
"updated_at": now,
},
)
if err != nil {
return fmt.Errorf("cannot insert document for org %s: %w", org.organizationID, err)
}
stats.documents++
_, err = tx.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": org.organizationID,
"tenant_id": org.tenantID,
"findings_document_id": documentID,
"created_at": now,
"updated_at": now,
},
)
if err != nil {
return fmt.Errorf("cannot link document to org %s: %w", org.organizationID, err)
}
for major, snap := range snapshots {
content, err := buildSnapshotContent(ctx, tx, snap.snapshotID, org.organizationName, snap.publishedAt)
if err != nil {
return fmt.Errorf("cannot build content for snapshot %s of org %s: %w",
snap.snapshotID, org.organizationID, err)
}
versionID := gid.New(org.tenantID, coredata.DocumentVersionEntityType)
_, err = tx.Exec(
ctx,
`
INSERT INTO document_versions (
id, tenant_id, organization_id, document_id,
title, major, minor, classification, document_type,
content, changelog, status, orientation,
published_at, created_at, updated_at
) VALUES (
@id, @tenant_id, @organization_id, @document_id,
@title, @major, 0,
'CONFIDENTIAL'::document_classification,
'REGISTER'::document_type,
@content, '',
'PUBLISHED'::document_version_status,
'LANDSCAPE'::document_version_orientation,
@published_at, @published_at, @published_at
)`,
pgx.NamedArgs{
"id": versionID,
"tenant_id": org.tenantID,
"organization_id": org.organizationID,
"document_id": documentID,
"title": "Findings",
"major": major + 1,
"content": content,
"published_at": snap.publishedAt,
},
)
if err != nil {
return fmt.Errorf("cannot insert version for snapshot %s: %w", snap.snapshotID, err)
}
stats.versions++
}
fmt.Printf("migrated org %s (%s) — %d version(s)\n",
org.organizationID, org.organizationName, len(snapshots))
}
if dryRun {
fmt.Printf("\n%d organization(s) would be migrated\n", len(orgs))
return nil
}
fmt.Printf("\ncreated %d document(s), %d version(s)\n", stats.documents, stats.versions)
return nil
}
func loadOrgsWithFindingSnapshots(ctx context.Context, tx pg.Tx) ([]orgWithFindingSnapshots, error) {
rows, err := tx.Query(
ctx,
`
SELECT DISTINCT
o.id,
o.tenant_id,
o.name,
o.created_at
FROM organizations o
WHERE NOT EXISTS (
SELECT 1 FROM generated_documents gd
WHERE gd.organization_id = o.id AND gd.findings_document_id IS NOT NULL
)
AND EXISTS (
SELECT 1 FROM findings f
WHERE f.organization_id = o.id AND f.snapshot_id IS NOT NULL
)
ORDER BY o.created_at;
`,
)
if err != nil {
return nil, fmt.Errorf("cannot query organizations with finding snapshots: %w", err)
}
defer rows.Close()
var result []orgWithFindingSnapshots
for rows.Next() {
var o orgWithFindingSnapshots
var createdAt time.Time
if err := rows.Scan(&o.organizationID, &o.tenantID, &o.organizationName, &createdAt); err != nil {
return nil, fmt.Errorf("cannot scan organization: %w", err)
}
result = append(result, o)
}
return result, rows.Err()
}
func loadFindingSnapshots(ctx context.Context, tx pg.Tx, organizationID gid.GID) ([]findingSnapshot, error) {
rows, err := tx.Query(
ctx,
`
SELECT DISTINCT
s.id,
s.created_at
FROM snapshots s
WHERE s.organization_id = @organization_id
AND s.type = 'FINDINGS'
ORDER BY s.created_at ASC;
`,
pgx.NamedArgs{"organization_id": organizationID},
)
if err != nil {
return nil, fmt.Errorf("cannot query finding snapshots for org %s: %w", organizationID, err)
}
defer rows.Close()
var result []findingSnapshot
for rows.Next() {
var s findingSnapshot
if err := rows.Scan(&s.snapshotID, &s.publishedAt); err != nil {
return nil, fmt.Errorf("cannot scan snapshot: %w", err)
}
result = append(result, s)
}
return result, rows.Err()
}
func buildSnapshotContent(
ctx context.Context,
tx pg.Tx,
snapshotID string,
orgName string,
publishedAt time.Time,
) (string, error) {
rows, err := tx.Query(
ctx,
`
SELECT
f.reference_id,
f.kind,
f.description,
f.source,
f.identified_on,
f.root_cause,
f.corrective_action,
f.effectiveness_check,
f.status,
f.priority,
f.due_date,
COALESCE(p.full_name, '-')
FROM findings f
LEFT JOIN iam_membership_profiles p ON p.id = f.owner_id
WHERE f.snapshot_id = @snapshot_id
ORDER BY f.reference_id ASC;
`,
pgx.NamedArgs{"snapshot_id": snapshotID},
)
if err != nil {
return "", fmt.Errorf("cannot load snapshot findings: %w", err)
}
defer rows.Close()
type findingInfo struct {
referenceID string
kind string
description *string
source *string
identifiedOn *time.Time
rootCause *string
correctiveAction *string
effectivenessCheck *string
status string
priority string
dueDate *time.Time
ownerName string
}
var findings []findingInfo
for rows.Next() {
var f findingInfo
if err := rows.Scan(&f.referenceID, &f.kind, &f.description, &f.source, &f.identifiedOn, &f.rootCause, &f.correctiveAction, &f.effectivenessCheck, &f.status, &f.priority, &f.dueDate, &f.ownerName); err != nil {
return "", fmt.Errorf("cannot scan finding: %w", err)
}
findings = append(findings, f)
}
if err := rows.Err(); err != nil {
return "", err
}
findingRows := make([]docgen.FindingListRow, len(findings))
for i, f := range findings {
description := "-"
if f.description != nil && *f.description != "" {
description = *f.description
}
source := "-"
if f.source != nil && *f.source != "" {
source = *f.source
}
identifiedOn := "-"
if f.identifiedOn != nil {
identifiedOn = f.identifiedOn.Format("2006-01-02")
}
rootCause := "-"
if f.rootCause != nil && *f.rootCause != "" {
rootCause = *f.rootCause
}
correctiveAction := "-"
if f.correctiveAction != nil && *f.correctiveAction != "" {
correctiveAction = *f.correctiveAction
}
effectivenessCheck := "-"
if f.effectivenessCheck != nil && *f.effectivenessCheck != "" {
effectivenessCheck = *f.effectivenessCheck
}
dueDate := "-"
if f.dueDate != nil {
dueDate = f.dueDate.Format("2006-01-02")
}
findingRows[i] = docgen.FindingListRow{
ReferenceID: f.referenceID,
Kind: formatFindingKindString(f.kind),
Description: description,
Source: source,
IdentifiedOn: identifiedOn,
RootCause: rootCause,
CorrectiveAction: correctiveAction,
EffectivenessCheck: effectivenessCheck,
Status: formatFindingStatusString(f.status),
Priority: formatFindingPriorityString(f.priority),
Owner: f.ownerName,
DueDate: dueDate,
}
}
docData := docgen.FindingListData{
Title: "Findings",
OrganizationName: orgName,
CreatedAt: publishedAt,
TotalFindings: len(findingRows),
Rows: findingRows,
}
return probo.BuildFindingListDocument(docData)
}
func formatFindingKindString(k string) string {
switch k {
case "MINOR_NONCONFORMITY":
return "Minor Nonconformity"
case "MAJOR_NONCONFORMITY":
return "Major Nonconformity"
case "OBSERVATION":
return "Observation"
case "EXCEPTION":
return "Exception"
default:
return k
}
}
func formatFindingStatusString(s string) string {
switch s {
case "OPEN":
return "Open"
case "IN_PROGRESS":
return "In Progress"
case "CLOSED":
return "Closed"
case "RISK_ACCEPTED":
return "Risk Accepted"
case "MITIGATED":
return "Mitigated"
case "FALSE_POSITIVE":
return "False Positive"
default:
return s
}
}
func formatFindingPriorityString(p string) string {
switch p {
case "LOW":
return "Low"
case "MEDIUM":
return "Medium"
case "HIGH":
return "High"
default:
return p
}
}
func newPgClientFromDSN(dsn string) (*pg.Client, error) {
u, err := url.Parse(dsn)
if err != nil {
return nil, fmt.Errorf("cannot parse DSN: %w", err)
}
var opts []pg.Option
if u.Host != "" {
host := u.Host
if u.Port() == "" {
host = net.JoinHostPort(u.Hostname(), "5432")
}
opts = append(opts, pg.WithAddr(host))
}
if u.User != nil {
opts = append(opts, pg.WithUser(u.User.Username()))
if password, ok := u.User.Password(); ok {
opts = append(opts, pg.WithPassword(password))
}
}
if len(u.Path) > 1 {
opts = append(opts, pg.WithDatabase(u.Path[1:]))
}
return pg.NewClient(opts...)
}

View File

@@ -1,455 +0,0 @@
// 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.
// Command migrate-obligation-snapshots-to-documents creates documents and document
// versions from existing obligation snapshots. For each organization that has
// obligation snapshots, it generates an obligation register document using the same
// ProseMirror builder as the publish flow.
package main
import (
"context"
"flag"
"fmt"
"net"
"net/url"
"os"
"time"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/docgen"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/probo"
)
func main() {
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}
func run() error {
var (
pgDSN string
dryRun bool
)
flag.StringVar(
&pgDSN,
"pg-dsn",
os.Getenv("DATABASE_URL"),
"PostgreSQL connection URL (default: DATABASE_URL env)",
)
flag.BoolVar(&dryRun, "dry-run", false, "show what would be done without writing")
flag.Parse()
if pgDSN == "" {
return fmt.Errorf("set -pg-dsn or DATABASE_URL")
}
ctx := context.Background()
pgClient, err := newPgClientFromDSN(pgDSN)
if err != nil {
return fmt.Errorf("cannot create pg client: %w", err)
}
return pgClient.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
return migrate(ctx, tx, dryRun)
})
}
type orgWithObligationSnapshots struct {
organizationID gid.GID
tenantID gid.TenantID
organizationName string
}
type obligationSnapshot struct {
snapshotID string
publishedAt time.Time
}
func migrate(ctx context.Context, tx pg.Tx, dryRun bool) error {
orgs, err := loadOrgsWithObligationSnapshots(ctx, tx)
if err != nil {
return err
}
if len(orgs) == 0 {
fmt.Println("no organizations with obligation snapshots to migrate")
return nil
}
var stats struct {
documents, versions int
}
for _, org := range orgs {
snapshots, err := loadObligationSnapshots(ctx, tx, org.organizationID)
if err != nil {
return err
}
if dryRun {
fmt.Printf("would migrate org %s (%s) — %d obligation snapshot(s)\n",
org.organizationID, org.organizationName, len(snapshots))
continue
}
documentID := gid.New(org.tenantID, coredata.DocumentEntityType)
now := time.Now()
_, err = tx.Exec(
ctx,
`
INSERT INTO documents (
id, tenant_id, organization_id, write_mode,
current_published_major, current_published_minor,
trust_center_visibility, status, created_at, updated_at
) VALUES (
@id, @tenant_id, @organization_id,
'GENERATED'::document_write_mode,
@current_published_major, 0,
'NONE'::trust_center_visibility,
'ACTIVE'::document_status,
@created_at, @updated_at
)`,
pgx.NamedArgs{
"id": documentID,
"tenant_id": org.tenantID,
"organization_id": org.organizationID,
"current_published_major": len(snapshots),
"created_at": now,
"updated_at": now,
},
)
if err != nil {
return fmt.Errorf("cannot insert document for org %s: %w", org.organizationID, err)
}
stats.documents++
_, err = tx.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": org.organizationID,
"tenant_id": org.tenantID,
"obligations_document_id": documentID,
"created_at": now,
"updated_at": now,
},
)
if err != nil {
return fmt.Errorf("cannot link document to org %s: %w", org.organizationID, err)
}
for major, snap := range snapshots {
content, err := buildSnapshotContent(ctx, tx, snap.snapshotID, org.organizationName, snap.publishedAt)
if err != nil {
return fmt.Errorf("cannot build content for snapshot %s of org %s: %w",
snap.snapshotID, org.organizationID, err)
}
versionID := gid.New(org.tenantID, coredata.DocumentVersionEntityType)
_, err = tx.Exec(
ctx,
`
INSERT INTO document_versions (
id, tenant_id, organization_id, document_id,
title, major, minor, classification, document_type,
content, changelog, status, orientation,
published_at, created_at, updated_at
) VALUES (
@id, @tenant_id, @organization_id, @document_id,
@title, @major, 0,
'CONFIDENTIAL'::document_classification,
'REGISTER'::document_type,
@content, '',
'PUBLISHED'::document_version_status,
'LANDSCAPE'::document_version_orientation,
@published_at, @published_at, @published_at
)`,
pgx.NamedArgs{
"id": versionID,
"tenant_id": org.tenantID,
"organization_id": org.organizationID,
"document_id": documentID,
"title": "Obligations",
"major": major + 1,
"content": content,
"published_at": snap.publishedAt,
},
)
if err != nil {
return fmt.Errorf("cannot insert version for snapshot %s: %w", snap.snapshotID, err)
}
stats.versions++
}
fmt.Printf("migrated org %s (%s) — %d version(s)\n",
org.organizationID, org.organizationName, len(snapshots))
}
if dryRun {
fmt.Printf("\n%d organization(s) would be migrated\n", len(orgs))
return nil
}
fmt.Printf("\ncreated %d document(s), %d version(s)\n", stats.documents, stats.versions)
return nil
}
func loadOrgsWithObligationSnapshots(ctx context.Context, tx pg.Tx) ([]orgWithObligationSnapshots, error) {
rows, err := tx.Query(
ctx,
`
SELECT DISTINCT
o.id,
o.tenant_id,
o.name,
o.created_at
FROM organizations o
WHERE NOT EXISTS (
SELECT 1 FROM generated_documents gd
WHERE gd.organization_id = o.id AND gd.obligations_document_id IS NOT NULL
)
AND EXISTS (
SELECT 1 FROM obligations ob
WHERE ob.organization_id = o.id AND ob.snapshot_id IS NOT NULL
)
ORDER BY o.created_at;
`,
)
if err != nil {
return nil, fmt.Errorf("cannot query organizations with obligation snapshots: %w", err)
}
defer rows.Close()
var result []orgWithObligationSnapshots
for rows.Next() {
var o orgWithObligationSnapshots
var createdAt time.Time
if err := rows.Scan(&o.organizationID, &o.tenantID, &o.organizationName, &createdAt); err != nil {
return nil, fmt.Errorf("cannot scan organization: %w", err)
}
result = append(result, o)
}
return result, rows.Err()
}
func loadObligationSnapshots(ctx context.Context, tx pg.Tx, organizationID gid.GID) ([]obligationSnapshot, error) {
rows, err := tx.Query(
ctx,
`
SELECT DISTINCT
s.id,
s.created_at
FROM snapshots s
WHERE s.organization_id = @organization_id
AND s.type = 'OBLIGATIONS'
ORDER BY s.created_at ASC;
`,
pgx.NamedArgs{"organization_id": organizationID},
)
if err != nil {
return nil, fmt.Errorf("cannot query obligation snapshots for org %s: %w", organizationID, err)
}
defer rows.Close()
var result []obligationSnapshot
for rows.Next() {
var s obligationSnapshot
if err := rows.Scan(&s.snapshotID, &s.publishedAt); err != nil {
return nil, fmt.Errorf("cannot scan snapshot: %w", err)
}
result = append(result, s)
}
return result, rows.Err()
}
func buildSnapshotContent(
ctx context.Context,
tx pg.Tx,
snapshotID string,
orgName string,
publishedAt time.Time,
) (string, error) {
rows, err := tx.Query(
ctx,
`
SELECT
ob.area,
ob.source,
ob.requirement,
ob.actions_to_be_implemented,
ob.status,
ob.type,
ob.regulator,
ob.due_date,
COALESCE(p.full_name, '-')
FROM obligations ob
LEFT JOIN iam_membership_profiles p ON p.id = ob.owner_profile_id
WHERE ob.snapshot_id = @snapshot_id
ORDER BY ob.created_at ASC;
`,
pgx.NamedArgs{"snapshot_id": snapshotID},
)
if err != nil {
return "", fmt.Errorf("cannot load snapshot obligations: %w", err)
}
defer rows.Close()
type obligationInfo struct {
area *string
source *string
requirement *string
actionsToBeImplemented *string
status string
oblType string
regulator *string
dueDate *time.Time
ownerName string
}
var obligations []obligationInfo
for rows.Next() {
var o obligationInfo
if err := rows.Scan(&o.area, &o.source, &o.requirement, &o.actionsToBeImplemented, &o.status, &o.oblType, &o.regulator, &o.dueDate, &o.ownerName); err != nil {
return "", fmt.Errorf("cannot scan obligation: %w", err)
}
obligations = append(obligations, o)
}
if err := rows.Err(); err != nil {
return "", err
}
obligationRows := make([]docgen.ObligationListRow, len(obligations))
for i, o := range obligations {
area := "-"
if o.area != nil && *o.area != "" {
area = *o.area
}
source := "-"
if o.source != nil && *o.source != "" {
source = *o.source
}
requirement := "-"
if o.requirement != nil && *o.requirement != "" {
requirement = *o.requirement
}
actionsToBeImplemented := "-"
if o.actionsToBeImplemented != nil && *o.actionsToBeImplemented != "" {
actionsToBeImplemented = *o.actionsToBeImplemented
}
regulator := "-"
if o.regulator != nil && *o.regulator != "" {
regulator = *o.regulator
}
dueDate := "-"
if o.dueDate != nil {
dueDate = o.dueDate.Format("2006-01-02")
}
obligationRows[i] = docgen.ObligationListRow{
Area: area,
Source: source,
Requirement: requirement,
ActionsToBeImplemented: actionsToBeImplemented,
Status: formatObligationStatusString(o.status),
Type: formatObligationTypeString(o.oblType),
Regulator: regulator,
Owner: o.ownerName,
DueDate: dueDate,
}
}
docData := docgen.ObligationListData{
Title: "Obligations",
OrganizationName: orgName,
CreatedAt: publishedAt,
TotalObligations: len(obligationRows),
Rows: obligationRows,
}
return probo.BuildObligationListDocument(docData)
}
func formatObligationStatusString(s string) string {
switch s {
case "NON_COMPLIANT":
return "Non Compliant"
case "PARTIALLY_COMPLIANT":
return "Partially Compliant"
case "COMPLIANT":
return "Compliant"
default:
return s
}
}
func formatObligationTypeString(t string) string {
switch t {
case "LEGAL":
return "Legal"
case "CONTRACTUAL":
return "Contractual"
default:
return t
}
}
func newPgClientFromDSN(dsn string) (*pg.Client, error) {
u, err := url.Parse(dsn)
if err != nil {
return nil, fmt.Errorf("cannot parse DSN: %w", err)
}
var opts []pg.Option
if u.Host != "" {
host := u.Host
if u.Port() == "" {
host = net.JoinHostPort(u.Hostname(), "5432")
}
opts = append(opts, pg.WithAddr(host))
}
if u.User != nil {
opts = append(opts, pg.WithUser(u.User.Username()))
if password, ok := u.User.Password(); ok {
opts = append(opts, pg.WithPassword(password))
}
}
if len(u.Path) > 1 {
opts = append(opts, pg.WithDatabase(u.Path[1:]))
}
return pg.NewClient(opts...)
}

View File

@@ -1,763 +0,0 @@
// 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.
// Command migrate-processing-activity-snapshots-to-documents creates documents
// and document versions from existing processing activity snapshots. For each
// organization that has PROCESSING_ACTIVITIES snapshots, it produces three
// register documents — Processing Activities, Data Protection Impact
// Assessments, and Transfer Impact Assessments — using the same ProseMirror
// builders as the publish flow, with one version per snapshot ordered by date.
package main
import (
"context"
"flag"
"fmt"
"net"
"net/url"
"os"
"strings"
"time"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/docgen"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/probo"
)
func main() {
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}
func run() error {
var (
pgDSN string
dryRun bool
)
flag.StringVar(
&pgDSN,
"pg-dsn",
os.Getenv("DATABASE_URL"),
"PostgreSQL connection URL (default: DATABASE_URL env)",
)
flag.BoolVar(&dryRun, "dry-run", false, "show what would be done without writing")
flag.Parse()
if pgDSN == "" {
return fmt.Errorf("set -pg-dsn or DATABASE_URL")
}
ctx := context.Background()
pgClient, err := newPgClientFromDSN(pgDSN)
if err != nil {
return fmt.Errorf("cannot create pg client: %w", err)
}
return pgClient.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
return migrate(ctx, tx, dryRun)
})
}
type orgWithSnapshots struct {
organizationID gid.GID
tenantID gid.TenantID
organizationName string
}
type processingActivitySnapshot struct {
snapshotID string
publishedAt time.Time
}
type kind struct {
name string
title string
column string
buildFn func(ctx context.Context, tx pg.Tx, snapshotID string, orgName string, publishedAt time.Time) (string, int, error)
}
func migrate(ctx context.Context, tx pg.Tx, dryRun bool) error {
orgs, err := loadOrgsWithSnapshots(ctx, tx)
if err != nil {
return err
}
if len(orgs) == 0 {
fmt.Println("no organizations with processing activity snapshots to migrate")
return nil
}
kinds := []kind{
{name: "processing-activity", title: "Processing Activities", column: "processing_activities_document_id", buildFn: buildProcessingActivityContent},
{name: "dpia", title: "Data Protection Impact Assessments", column: "data_protection_impact_assessments_document_id", buildFn: buildDPIAContent},
{name: "tia", title: "Transfer Impact Assessments", column: "transfer_impact_assessments_document_id", buildFn: buildTIAContent},
}
var stats struct {
documents, versions int
}
for _, org := range orgs {
snapshots, err := loadSnapshots(ctx, tx, org.organizationID)
if err != nil {
return err
}
if dryRun {
fmt.Printf("would migrate org %s (%s) — %d snapshot(s) × %d kind(s)\n",
org.organizationID, org.organizationName, len(snapshots), len(kinds))
continue
}
for _, k := range kinds {
documentID := gid.New(org.tenantID, coredata.DocumentEntityType)
now := time.Now()
versionsInserted := 0
for major, snap := range snapshots {
content, count, err := k.buildFn(ctx, tx, snap.snapshotID, org.organizationName, snap.publishedAt)
if err != nil {
return fmt.Errorf("cannot build %s content for snapshot %s of org %s: %w",
k.name, snap.snapshotID, org.organizationID, err)
}
if count == 0 {
continue
}
versionID := gid.New(org.tenantID, coredata.DocumentVersionEntityType)
_, err = tx.Exec(
ctx,
`
INSERT INTO document_versions (
id, tenant_id, organization_id, document_id,
title, major, minor, classification, document_type,
content, changelog, status, orientation,
published_at, created_at, updated_at
) VALUES (
@id, @tenant_id, @organization_id, @document_id,
@title, @major, 0,
'CONFIDENTIAL'::document_classification,
'REGISTER'::document_type,
@content, '',
'PUBLISHED'::document_version_status,
'PORTRAIT'::document_version_orientation,
@published_at, @published_at, @published_at
)`,
pgx.NamedArgs{
"id": versionID,
"tenant_id": org.tenantID,
"organization_id": org.organizationID,
"document_id": documentID,
"title": k.title,
"major": major + 1,
"content": content,
"published_at": snap.publishedAt,
},
)
if err != nil {
return fmt.Errorf("cannot insert %s version for snapshot %s: %w", k.name, snap.snapshotID, err)
}
versionsInserted++
}
if versionsInserted == 0 {
continue
}
_, err := tx.Exec(
ctx,
`
INSERT INTO documents (
id, tenant_id, organization_id, write_mode,
current_published_major, current_published_minor,
trust_center_visibility, status, created_at, updated_at
) VALUES (
@id, @tenant_id, @organization_id,
'GENERATED'::document_write_mode,
@current_published_major, 0,
'NONE'::trust_center_visibility,
'ACTIVE'::document_status,
@created_at, @updated_at
)`,
pgx.NamedArgs{
"id": documentID,
"tenant_id": org.tenantID,
"organization_id": org.organizationID,
"current_published_major": versionsInserted,
"created_at": now,
"updated_at": now,
},
)
if err != nil {
return fmt.Errorf("cannot insert %s document for org %s: %w", k.name, org.organizationID, err)
}
stats.documents++
stats.versions += versionsInserted
_, err = tx.Exec(
ctx,
fmt.Sprintf(`
INSERT INTO generated_documents (organization_id, tenant_id, %s, created_at, updated_at)
VALUES (@organization_id, @tenant_id, @document_id, @created_at, @updated_at)
ON CONFLICT (organization_id) DO UPDATE SET %s = @document_id, updated_at = @updated_at`, k.column, k.column),
pgx.NamedArgs{
"organization_id": org.organizationID,
"tenant_id": org.tenantID,
"document_id": documentID,
"created_at": now,
"updated_at": now,
},
)
if err != nil {
return fmt.Errorf("cannot link %s document to org %s: %w", k.name, org.organizationID, err)
}
}
fmt.Printf("migrated org %s (%s) — %d snapshot(s)\n",
org.organizationID, org.organizationName, len(snapshots))
}
if dryRun {
fmt.Printf("\n%d organization(s) would be migrated\n", len(orgs))
return nil
}
fmt.Printf("\ncreated %d document(s), %d version(s)\n", stats.documents, stats.versions)
return nil
}
func loadOrgsWithSnapshots(ctx context.Context, tx pg.Tx) ([]orgWithSnapshots, error) {
rows, err := tx.Query(
ctx,
`
SELECT DISTINCT
o.id,
o.tenant_id,
o.name,
o.created_at
FROM organizations o
WHERE NOT EXISTS (
SELECT 1 FROM generated_documents gd
WHERE gd.organization_id = o.id
AND gd.processing_activities_document_id IS NOT NULL
)
AND EXISTS (
SELECT 1 FROM snapshots s
WHERE s.organization_id = o.id AND s.type = 'PROCESSING_ACTIVITIES'
)
ORDER BY o.created_at;
`,
)
if err != nil {
return nil, fmt.Errorf("cannot query organizations with snapshots: %w", err)
}
defer rows.Close()
var result []orgWithSnapshots
for rows.Next() {
var o orgWithSnapshots
var createdAt time.Time
if err := rows.Scan(&o.organizationID, &o.tenantID, &o.organizationName, &createdAt); err != nil {
return nil, fmt.Errorf("cannot scan organization: %w", err)
}
result = append(result, o)
}
return result, rows.Err()
}
func loadSnapshots(ctx context.Context, tx pg.Tx, organizationID gid.GID) ([]processingActivitySnapshot, error) {
rows, err := tx.Query(
ctx,
`
SELECT
s.id,
s.created_at
FROM snapshots s
WHERE s.organization_id = @organization_id
AND s.type = 'PROCESSING_ACTIVITIES'
ORDER BY s.created_at ASC;
`,
pgx.NamedArgs{"organization_id": organizationID},
)
if err != nil {
return nil, fmt.Errorf("cannot query snapshots for org %s: %w", organizationID, err)
}
defer rows.Close()
var result []processingActivitySnapshot
for rows.Next() {
var s processingActivitySnapshot
if err := rows.Scan(&s.snapshotID, &s.publishedAt); err != nil {
return nil, fmt.Errorf("cannot scan snapshot: %w", err)
}
result = append(result, s)
}
return result, rows.Err()
}
func buildProcessingActivityContent(
ctx context.Context,
tx pg.Tx,
snapshotID string,
orgName string,
publishedAt time.Time,
) (string, int, error) {
rows, err := tx.Query(
ctx,
`
SELECT
pa.id,
pa.name,
pa.purpose,
pa.data_subject_category,
pa.personal_data_category,
pa.special_or_criminal_data,
pa.consent_evidence_link,
pa.lawful_basis,
pa.recipients,
pa.location,
pa.international_transfers,
pa.transfer_safeguards,
pa.retention_period,
pa.security_measures,
pa.data_protection_impact_assessment_needed,
pa.transfer_impact_assessment_needed,
pa.last_review_date,
pa.next_review_date,
pa.role,
COALESCE(p.full_name, '')
FROM processing_activities pa
LEFT JOIN iam_membership_profiles p ON p.id = pa.dpo_profile_id
WHERE pa.snapshot_id = @snapshot_id
ORDER BY pa.name ASC;
`,
pgx.NamedArgs{"snapshot_id": snapshotID},
)
if err != nil {
return "", 0, fmt.Errorf("cannot load snapshot processing activities: %w", err)
}
defer rows.Close()
type paRow struct {
id gid.GID
name string
purpose *string
dataSubjectCategory *string
personalDataCategory *string
specialOrCriminalData string
consentEvidenceLink *string
lawfulBasis string
recipients *string
location *string
internationalTransfers bool
transferSafeguards *string
retentionPeriod *string
securityMeasures *string
dataProtectionImpactAssessmentNeeded string
transferImpactAssessmentNeeded string
lastReviewDate *time.Time
nextReviewDate *time.Time
role string
dpoName string
}
var pas []paRow
for rows.Next() {
var p paRow
if err := rows.Scan(
&p.id, &p.name, &p.purpose, &p.dataSubjectCategory, &p.personalDataCategory,
&p.specialOrCriminalData, &p.consentEvidenceLink, &p.lawfulBasis,
&p.recipients, &p.location, &p.internationalTransfers, &p.transferSafeguards,
&p.retentionPeriod, &p.securityMeasures,
&p.dataProtectionImpactAssessmentNeeded, &p.transferImpactAssessmentNeeded,
&p.lastReviewDate, &p.nextReviewDate, &p.role, &p.dpoName,
); err != nil {
return "", 0, fmt.Errorf("cannot scan PA: %w", err)
}
pas = append(pas, p)
}
if err := rows.Err(); err != nil {
return "", 0, err
}
if len(pas) == 0 {
return "", 0, nil
}
thirdPartyMap, err := loadThirdPartiesForSnapshot(ctx, tx, snapshotID)
if err != nil {
return "", 0, err
}
listRows := make([]docgen.ProcessingActivityListRow, len(pas))
for i, p := range pas {
dpo := "Not assigned"
if p.dpoName != "" {
dpo = p.dpoName
}
thirdParties := "None"
if v, ok := thirdPartyMap[p.id]; ok && len(v) > 0 {
thirdParties = strings.Join(v, ", ")
}
listRows[i] = docgen.ProcessingActivityListRow{
Name: p.name,
Purpose: derefOrNotSpecified(p.purpose),
Role: formatRoleString(p.role),
DataSubjectCategory: derefOrNotSpecified(p.dataSubjectCategory),
PersonalDataCategory: derefOrNotSpecified(p.personalDataCategory),
SpecialOrCriminalData: formatSpecialOrCriminalDataString(p.specialOrCriminalData),
LawfulBasis: formatLawfulBasisString(p.lawfulBasis),
ConsentEvidenceLink: derefOrNotSpecified(p.consentEvidenceLink),
Recipients: derefOrNotSpecified(p.recipients),
Location: derefOrNotSpecified(p.location),
InternationalTransfers: yesNoLabel(p.internationalTransfers),
TransferSafeguards: formatTransferSafeguardString(p.transferSafeguards),
RetentionPeriod: derefOrNotSpecified(p.retentionPeriod),
SecurityMeasures: derefOrNotSpecified(p.securityMeasures),
DataProtectionImpactAssessmentNeeded: formatYesNoString(p.dataProtectionImpactAssessmentNeeded),
TransferImpactAssessmentNeeded: formatYesNoString(p.transferImpactAssessmentNeeded),
LastReviewDate: formatDateOrNotSpecified(p.lastReviewDate),
NextReviewDate: formatDateOrNotSpecified(p.nextReviewDate),
DataProtectionOfficer: dpo,
ThirdParties: thirdParties,
}
}
content, err := probo.BuildProcessingActivityListDocument(docgen.ProcessingActivityListData{
Title: "Processing Activities",
OrganizationName: orgName,
CreatedAt: publishedAt,
TotalProcessingActivities: len(listRows),
Rows: listRows,
})
if err != nil {
return "", 0, err
}
return content, len(listRows), nil
}
func loadThirdPartiesForSnapshot(ctx context.Context, tx pg.Tx, snapshotID string) (map[gid.GID][]string, error) {
rows, err := tx.Query(
ctx,
`
SELECT pav.processing_activity_id, v.name
FROM processing_activity_third_parties pav
INNER JOIN third_parties v ON v.id = pav.third_party_id
WHERE pav.snapshot_id = @snapshot_id
ORDER BY pav.processing_activity_id, v.name;
`,
pgx.NamedArgs{"snapshot_id": snapshotID},
)
if err != nil {
return nil, fmt.Errorf("cannot load snapshot thirdParties: %w", err)
}
defer rows.Close()
result := make(map[gid.GID][]string)
for rows.Next() {
var paID gid.GID
var name string
if err := rows.Scan(&paID, &name); err != nil {
return nil, fmt.Errorf("cannot scan thirdParty row: %w", err)
}
result[paID] = append(result[paID], name)
}
return result, rows.Err()
}
func buildDPIAContent(
ctx context.Context,
tx pg.Tx,
snapshotID string,
orgName string,
publishedAt time.Time,
) (string, int, error) {
rows, err := tx.Query(
ctx,
`
SELECT
pa.name,
dpia.description,
dpia.necessity_and_proportionality,
dpia.potential_risk,
dpia.mitigations,
dpia.residual_risk
FROM processing_activity_data_protection_impact_assessments dpia
INNER JOIN processing_activities pa ON pa.id = dpia.processing_activity_id
WHERE dpia.snapshot_id = @snapshot_id
ORDER BY pa.name ASC;
`,
pgx.NamedArgs{"snapshot_id": snapshotID},
)
if err != nil {
return "", 0, fmt.Errorf("cannot load snapshot DPIAs: %w", err)
}
defer rows.Close()
var listRows []docgen.DataProtectionImpactAssessmentListRow
for rows.Next() {
var name string
var description, necessity, potentialRisk, mitigations *string
var residualRisk *string
if err := rows.Scan(&name, &description, &necessity, &potentialRisk, &mitigations, &residualRisk); err != nil {
return "", 0, fmt.Errorf("cannot scan DPIA: %w", err)
}
listRows = append(listRows, docgen.DataProtectionImpactAssessmentListRow{
ProcessingActivityName: name,
Description: derefOrNotSpecified(description),
NecessityAndProportionality: derefOrNotSpecified(necessity),
PotentialRisk: derefOrNotSpecified(potentialRisk),
Mitigations: derefOrNotSpecified(mitigations),
ResidualRisk: formatResidualRiskString(residualRisk),
})
}
if err := rows.Err(); err != nil {
return "", 0, err
}
if len(listRows) == 0 {
return "", 0, nil
}
content, err := probo.BuildDataProtectionImpactAssessmentListDocument(docgen.DataProtectionImpactAssessmentListData{
Title: "Data Protection Impact Assessments",
OrganizationName: orgName,
CreatedAt: publishedAt,
TotalDataProtectionImpactAssessments: len(listRows),
Rows: listRows,
})
if err != nil {
return "", 0, err
}
return content, len(listRows), nil
}
func buildTIAContent(
ctx context.Context,
tx pg.Tx,
snapshotID string,
orgName string,
publishedAt time.Time,
) (string, int, error) {
rows, err := tx.Query(
ctx,
`
SELECT
pa.name,
tia.data_subjects,
tia.legal_mechanism,
tia.transfer,
tia.local_law_risk,
tia.supplementary_measures
FROM processing_activity_transfer_impact_assessments tia
INNER JOIN processing_activities pa ON pa.id = tia.processing_activity_id
WHERE tia.snapshot_id = @snapshot_id
ORDER BY pa.name ASC;
`,
pgx.NamedArgs{"snapshot_id": snapshotID},
)
if err != nil {
return "", 0, fmt.Errorf("cannot load snapshot TIAs: %w", err)
}
defer rows.Close()
var listRows []docgen.TransferImpactAssessmentListRow
for rows.Next() {
var name string
var dataSubjects, legalMechanism, transfer, localLawRisk, supplementary *string
if err := rows.Scan(&name, &dataSubjects, &legalMechanism, &transfer, &localLawRisk, &supplementary); err != nil {
return "", 0, fmt.Errorf("cannot scan TIA: %w", err)
}
listRows = append(listRows, docgen.TransferImpactAssessmentListRow{
ProcessingActivityName: name,
DataSubjects: derefOrNotSpecified(dataSubjects),
LegalMechanism: derefOrNotSpecified(legalMechanism),
Transfer: derefOrNotSpecified(transfer),
LocalLawRisk: derefOrNotSpecified(localLawRisk),
SupplementaryMeasures: derefOrNotSpecified(supplementary),
})
}
if err := rows.Err(); err != nil {
return "", 0, err
}
if len(listRows) == 0 {
return "", 0, nil
}
content, err := probo.BuildTransferImpactAssessmentListDocument(docgen.TransferImpactAssessmentListData{
Title: "Transfer Impact Assessments",
OrganizationName: orgName,
CreatedAt: publishedAt,
TotalTransferImpactAssessments: len(listRows),
Rows: listRows,
})
if err != nil {
return "", 0, err
}
return content, len(listRows), nil
}
func derefOrNotSpecified(s *string) string {
if s == nil || *s == "" {
return "Not specified"
}
return *s
}
func formatDateOrNotSpecified(t *time.Time) string {
if t == nil {
return "Not specified"
}
return t.Format("January 2, 2006")
}
func yesNoLabel(b bool) string {
if b {
return "Yes"
}
return "No"
}
func formatYesNoString(s string) string {
switch s {
case "NEEDED":
return "Yes"
case "NOT_NEEDED":
return "No"
default:
return s
}
}
func formatRoleString(role string) string {
switch role {
case "CONTROLLER":
return "Controller"
case "PROCESSOR":
return "Processor"
default:
return role
}
}
func formatLawfulBasisString(b string) string {
switch b {
case "CONSENT":
return "Consent"
case "CONTRACTUAL_NECESSITY":
return "Contractual Necessity"
case "LEGAL_OBLIGATION":
return "Legal Obligation"
case "LEGITIMATE_INTEREST":
return "Legitimate Interest"
case "PUBLIC_TASK":
return "Public Task"
case "VITAL_INTERESTS":
return "Vital Interests"
default:
return b
}
}
func formatSpecialOrCriminalDataString(s string) string {
switch s {
case "YES":
return "Yes"
case "NO":
return "No"
case "POSSIBLE":
return "Possible"
default:
return s
}
}
func formatTransferSafeguardString(s *string) string {
if s == nil {
return "Not specified"
}
switch *s {
case "STANDARD_CONTRACTUAL_CLAUSES":
return "Standard Contractual Clauses"
case "BINDING_CORPORATE_RULES":
return "Binding Corporate Rules"
case "ADEQUACY_DECISION":
return "Adequacy Decision"
case "DEROGATIONS":
return "Derogations"
case "CODES_OF_CONDUCT":
return "Codes of Conduct"
case "CERTIFICATION_MECHANISMS":
return "Certification Mechanisms"
default:
return *s
}
}
func formatResidualRiskString(s *string) string {
if s == nil {
return "Not specified"
}
switch *s {
case "LOW":
return "Low"
case "MEDIUM":
return "Medium"
case "HIGH":
return "High"
default:
return *s
}
}
func newPgClientFromDSN(dsn string) (*pg.Client, error) {
u, err := url.Parse(dsn)
if err != nil {
return nil, fmt.Errorf("cannot parse DSN: %w", err)
}
var opts []pg.Option
if u.Host != "" {
host := u.Host
if u.Port() == "" {
host = net.JoinHostPort(u.Hostname(), "5432")
}
opts = append(opts, pg.WithAddr(host))
}
if u.User != nil {
opts = append(opts, pg.WithUser(u.User.Username()))
if password, ok := u.User.Password(); ok {
opts = append(opts, pg.WithPassword(password))
}
}
if len(u.Path) > 1 {
opts = append(opts, pg.WithDatabase(u.Path[1:]))
}
return pg.NewClient(opts...)
}

View File

@@ -1,530 +0,0 @@
// 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.
// Command migrate-risk-snapshots-to-documents creates documents and document
// versions from existing risk snapshots. For each organization that has risk
// snapshots, it generates a risk list document using the same ProseMirror
// builder as the publish flow.
package main
import (
"context"
"flag"
"fmt"
"net"
"net/url"
"os"
"time"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/docgen"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/probo"
)
func main() {
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}
func run() error {
var (
pgDSN string
dryRun bool
)
flag.StringVar(
&pgDSN,
"pg-dsn",
os.Getenv("DATABASE_URL"),
"PostgreSQL connection URL (default: DATABASE_URL env)",
)
flag.BoolVar(&dryRun, "dry-run", false, "show what would be done without writing")
flag.Parse()
if pgDSN == "" {
return fmt.Errorf("set -pg-dsn or DATABASE_URL")
}
ctx := context.Background()
pgClient, err := newPgClientFromDSN(pgDSN)
if err != nil {
return fmt.Errorf("cannot create pg client: %w", err)
}
return migrate(ctx, pgClient, dryRun)
}
type orgWithRiskSnapshots struct {
organizationID gid.GID
tenantID gid.TenantID
organizationName string
}
type riskSnapshot struct {
snapshotID string
publishedAt time.Time
}
func migrate(ctx context.Context, pgClient *pg.Client, dryRun bool) error {
var orgs []orgWithRiskSnapshots
err := pgClient.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
var err error
orgs, err = loadOrgsWithRiskSnapshots(ctx, conn)
return err
})
if err != nil {
return err
}
if len(orgs) == 0 {
fmt.Println("no organizations with risk snapshots to migrate")
return nil
}
var stats struct {
documents, versions, failed int
}
for _, org := range orgs {
if dryRun {
var count int
err := pgClient.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
snapshots, err := loadRiskSnapshots(ctx, conn, org.organizationID)
count = len(snapshots)
return err
})
if err != nil {
return err
}
fmt.Printf("would migrate org %s (%s) — %d risk snapshot(s)\n",
org.organizationID, org.organizationName, count)
continue
}
err := pgClient.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
return migrateOrg(ctx, tx, org)
})
if err != nil {
fmt.Fprintf(os.Stderr, "FAIL org %s (%s): %v\n",
org.organizationID, org.organizationName, err)
stats.failed++
continue
}
stats.documents++
}
if dryRun {
fmt.Printf("\n%d organization(s) would be migrated\n", len(orgs))
return nil
}
fmt.Printf("\nmigrated %d organization(s), %d failed\n",
stats.documents, stats.failed)
return nil
}
func migrateOrg(ctx context.Context, tx pg.Tx, org orgWithRiskSnapshots) error {
snapshots, err := loadRiskSnapshots(ctx, tx, org.organizationID)
if err != nil {
return err
}
if len(snapshots) == 0 {
return nil
}
documentID := gid.New(org.tenantID, coredata.DocumentEntityType)
now := time.Now()
_, err = tx.Exec(
ctx,
`
INSERT INTO documents (
id, tenant_id, organization_id, write_mode,
current_published_major, current_published_minor,
trust_center_visibility, status, created_at, updated_at
) VALUES (
@id, @tenant_id, @organization_id,
'GENERATED'::document_write_mode,
@current_published_major, 0,
'NONE'::trust_center_visibility,
'ACTIVE'::document_status,
@created_at, @updated_at
)`,
pgx.NamedArgs{
"id": documentID,
"tenant_id": org.tenantID,
"organization_id": org.organizationID,
"current_published_major": len(snapshots),
"created_at": now,
"updated_at": now,
},
)
if err != nil {
return fmt.Errorf("cannot insert document: %w", err)
}
_, err = tx.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": org.organizationID,
"tenant_id": org.tenantID,
"risks_document_id": documentID,
"created_at": now,
"updated_at": now,
},
)
if err != nil {
return fmt.Errorf("cannot link document: %w", err)
}
for major, snap := range snapshots {
content, err := buildSnapshotContent(ctx, tx, snap.snapshotID, org.organizationName, snap.publishedAt)
if err != nil {
return fmt.Errorf("cannot build content for snapshot %s: %w", snap.snapshotID, err)
}
versionID := gid.New(org.tenantID, coredata.DocumentVersionEntityType)
_, err = tx.Exec(
ctx,
`
INSERT INTO document_versions (
id, tenant_id, organization_id, document_id,
title, major, minor, classification, document_type,
content, changelog, status, orientation,
pdf_attempt_count,
published_at, created_at, updated_at
) VALUES (
@id, @tenant_id, @organization_id, @document_id,
@title, @major, 0,
'CONFIDENTIAL'::document_classification,
'REGISTER'::document_type,
@content, '',
'PUBLISHED'::document_version_status,
'PORTRAIT'::document_version_orientation,
0,
@published_at, @published_at, @published_at
)`,
pgx.NamedArgs{
"id": versionID,
"tenant_id": org.tenantID,
"organization_id": org.organizationID,
"document_id": documentID,
"title": "Risks",
"major": major + 1,
"content": content,
"published_at": snap.publishedAt,
},
)
if err != nil {
return fmt.Errorf("cannot insert version for snapshot %s: %w", snap.snapshotID, err)
}
}
fmt.Printf("OK org %s (%s) — %d version(s)\n",
org.organizationID, org.organizationName, len(snapshots))
return nil
}
func loadOrgsWithRiskSnapshots(ctx context.Context, conn pg.Querier) ([]orgWithRiskSnapshots, error) {
rows, err := conn.Query(
ctx,
`
SELECT DISTINCT
o.id,
o.tenant_id,
o.name,
o.created_at
FROM organizations o
WHERE NOT EXISTS (
SELECT 1 FROM generated_documents gd
WHERE gd.organization_id = o.id AND gd.risks_document_id IS NOT NULL
)
AND EXISTS (
SELECT 1 FROM snapshots s
WHERE s.organization_id = o.id AND s.type = 'RISKS'
)
ORDER BY o.created_at;
`,
)
if err != nil {
return nil, fmt.Errorf("cannot query organizations with risk snapshots: %w", err)
}
defer rows.Close()
var result []orgWithRiskSnapshots
for rows.Next() {
var o orgWithRiskSnapshots
var createdAt time.Time
if err := rows.Scan(&o.organizationID, &o.tenantID, &o.organizationName, &createdAt); err != nil {
return nil, fmt.Errorf("cannot scan organization: %w", err)
}
result = append(result, o)
}
return result, rows.Err()
}
func loadRiskSnapshots(ctx context.Context, conn pg.Querier, organizationID gid.GID) ([]riskSnapshot, error) {
rows, err := conn.Query(
ctx,
`
SELECT DISTINCT
s.id,
s.created_at
FROM snapshots s
WHERE s.organization_id = @organization_id
AND s.type = 'RISKS'
ORDER BY s.created_at ASC;
`,
pgx.NamedArgs{"organization_id": organizationID},
)
if err != nil {
return nil, fmt.Errorf("cannot query risk snapshots for org %s: %w", organizationID, err)
}
defer rows.Close()
var result []riskSnapshot
for rows.Next() {
var s riskSnapshot
if err := rows.Scan(&s.snapshotID, &s.publishedAt); err != nil {
return nil, fmt.Errorf("cannot scan snapshot: %w", err)
}
result = append(result, s)
}
return result, rows.Err()
}
type riskInfo struct {
id string
name string
description *string
category string
treatment string
note string
ownerName string
inherentLikelihood int
inherentImpact int
inherentRiskScore int
residualLikelihood int
residualImpact int
residualRiskScore int
}
func buildSnapshotContent(
ctx context.Context,
tx pg.Tx,
snapshotID string,
orgName string,
publishedAt time.Time,
) (string, error) {
riskRows, err := tx.Query(
ctx,
`
SELECT
r.id,
r.name,
r.description,
r.category,
r.treatment::text,
r.note,
COALESCE(NULLIF(p.full_name, ''), 'Not assigned'),
r.inherent_likelihood,
r.inherent_impact,
r.inherent_risk_score,
r.residual_likelihood,
r.residual_impact,
r.residual_risk_score
FROM risks r
LEFT JOIN iam_membership_profiles p ON p.id = r.owner_profile_id
WHERE r.snapshot_id = @snapshot_id
ORDER BY r.name ASC;
`,
pgx.NamedArgs{"snapshot_id": snapshotID},
)
if err != nil {
return "", fmt.Errorf("cannot load snapshot risks: %w", err)
}
defer riskRows.Close()
var risks []riskInfo
for riskRows.Next() {
var r riskInfo
if err := riskRows.Scan(
&r.id, &r.name, &r.description, &r.category, &r.treatment, &r.note,
&r.ownerName,
&r.inherentLikelihood, &r.inherentImpact, &r.inherentRiskScore,
&r.residualLikelihood, &r.residualImpact, &r.residualRiskScore,
); err != nil {
return "", fmt.Errorf("cannot scan risk: %w", err)
}
risks = append(risks, r)
}
if err := riskRows.Err(); err != nil {
return "", err
}
rows := make([]docgen.RiskListRow, 0, len(risks))
for _, r := range risks {
row := docgen.RiskListRow{
Name: r.name,
Description: derefOrNotSpecified(r.description),
Category: stringOrNotSpecified(r.category),
Treatment: formatTreatment(r.treatment),
Owner: r.ownerName,
InherentLikelihood: r.inherentLikelihood,
InherentLikelihoodLabel: riskLikelihoodLabel(r.inherentLikelihood),
InherentImpact: r.inherentImpact,
InherentImpactLabel: riskImpactLabel(r.inherentImpact),
InherentRiskScore: r.inherentRiskScore,
InherentSeverity: riskSeverityLabel(r.inherentRiskScore),
ResidualLikelihood: r.residualLikelihood,
ResidualLikelihoodLabel: riskLikelihoodLabel(r.residualLikelihood),
ResidualImpact: r.residualImpact,
ResidualImpactLabel: riskImpactLabel(r.residualImpact),
ResidualRiskScore: r.residualRiskScore,
ResidualSeverity: riskSeverityLabel(r.residualRiskScore),
Note: stringOrNotSpecified(r.note),
}
rows = append(rows, row)
}
docData := docgen.RiskListData{
Title: "Risks",
OrganizationName: orgName,
CreatedAt: publishedAt,
TotalRisks: len(rows),
Rows: rows,
}
return probo.BuildRiskListDocument(docData)
}
func derefOrNotSpecified(s *string) string {
if s == nil || *s == "" {
return "Not specified"
}
return *s
}
func stringOrNotSpecified(s string) string {
if s == "" {
return "Not specified"
}
return s
}
func formatTreatment(t string) string {
switch t {
case "MITIGATED":
return "Mitigated"
case "ACCEPTED":
return "Accepted"
case "AVOIDED":
return "Avoided"
case "TRANSFERRED":
return "Transferred"
default:
return stringOrNotSpecified(t)
}
}
func riskLikelihoodLabel(v int) string {
switch v {
case 1:
return "Improbable"
case 2:
return "Remote"
case 3:
return "Occasional"
case 4:
return "Probable"
case 5:
return "Frequent"
default:
return "Unknown"
}
}
func riskImpactLabel(v int) string {
switch v {
case 1:
return "Negligible"
case 2:
return "Low"
case 3:
return "Moderate"
case 4:
return "Significant"
case 5:
return "Catastrophic"
default:
return "Unknown"
}
}
func riskSeverityLabel(score int) string {
switch {
case score >= 15:
return "Critical"
case score >= 5:
return "High"
default:
return "Low"
}
}
func newPgClientFromDSN(dsn string) (*pg.Client, error) {
u, err := url.Parse(dsn)
if err != nil {
return nil, fmt.Errorf("cannot parse DSN")
}
var opts []pg.Option
if u.Host != "" {
host := u.Host
if u.Port() == "" {
host = net.JoinHostPort(u.Hostname(), "5432")
}
opts = append(opts, pg.WithAddr(host))
}
if u.User != nil {
opts = append(opts, pg.WithUser(u.User.Username()))
if password, ok := u.User.Password(); ok {
opts = append(opts, pg.WithPassword(password))
}
}
if len(u.Path) > 1 {
opts = append(opts, pg.WithDatabase(u.Path[1:]))
}
return pg.NewClient(opts...)
}

View File

@@ -1,539 +0,0 @@
// 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.
// Command migrate-soa-snapshots-to-documents creates documents, document
// versions, approval quorums, and approval decisions from existing SOA
// snapshots. For each snapshot it generates the ProseMirror content using
// the same builder as the publish flow.
package main
import (
"context"
"flag"
"fmt"
"net"
"net/url"
"os"
"time"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/docgen"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/probo"
)
func main() {
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}
func run() error {
var (
pgDSN string
dryRun bool
)
flag.StringVar(
&pgDSN,
"pg-dsn",
os.Getenv("DATABASE_URL"),
"PostgreSQL connection URL (default: DATABASE_URL env)",
)
flag.BoolVar(&dryRun, "dry-run", false, "show what would be done without writing")
flag.Parse()
if pgDSN == "" {
return fmt.Errorf("set -pg-dsn or DATABASE_URL")
}
ctx := context.Background()
pgClient, err := newPgClientFromDSN(pgDSN)
if err != nil {
return fmt.Errorf("cannot create pg client: %w", err)
}
return pgClient.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
return migrate(ctx, tx, dryRun)
})
}
type originalSOA struct {
id string
tenantID gid.TenantID
organizationID gid.GID
name string
ownerProfileID *string
}
type snapshotSOA struct {
id string
snapshotID string
ownerProfileID *string
publishedAt time.Time
}
func migrate(ctx context.Context, tx pg.Tx, dryRun bool) error {
originals, err := loadOriginalSOAs(ctx, tx)
if err != nil {
return err
}
if len(originals) == 0 {
fmt.Println("no SOAs with snapshots to migrate")
return nil
}
var stats struct {
documents, versions, quorums, decisions, defaultApprovers int
}
for _, orig := range originals {
snapshots, err := loadSnapshots(ctx, tx, orig.id)
if err != nil {
return err
}
if dryRun {
fmt.Printf("would migrate SOA %s (%s) — %d snapshot(s)\n", orig.id, orig.name, len(snapshots))
continue
}
documentID := gid.New(orig.tenantID, coredata.DocumentEntityType)
now := time.Now()
_, err = tx.Exec(
ctx,
`
INSERT INTO documents (
id, tenant_id, organization_id, write_mode,
current_published_major, current_published_minor,
trust_center_visibility, status, created_at, updated_at
) VALUES (
@id, @tenant_id, @organization_id,
'GENERATED'::document_write_mode,
@current_published_major, 0,
'NONE'::trust_center_visibility,
'ACTIVE'::document_status,
@created_at, @updated_at
)`,
pgx.NamedArgs{
"id": documentID,
"tenant_id": orig.tenantID,
"organization_id": orig.organizationID,
"current_published_major": len(snapshots),
"created_at": now,
"updated_at": now,
},
)
if err != nil {
return fmt.Errorf("cannot insert document for SOA %s: %w", orig.id, err)
}
stats.documents++
_, err = tx.Exec(
ctx,
`UPDATE statements_of_applicability SET document_id = @document_id WHERE id = @id`,
pgx.NamedArgs{
"document_id": documentID,
"id": orig.id,
},
)
if err != nil {
return fmt.Errorf("cannot link document to SOA %s: %w", orig.id, err)
}
if orig.ownerProfileID != nil {
_, err = tx.Exec(
ctx,
`
INSERT INTO document_default_approvers (
document_id, approver_profile_id, tenant_id, organization_id, created_at, updated_at
) VALUES (
@document_id, @approver_profile_id, @tenant_id, @organization_id, @created_at, @created_at
)`,
pgx.NamedArgs{
"document_id": documentID,
"approver_profile_id": *orig.ownerProfileID,
"tenant_id": orig.tenantID,
"organization_id": orig.organizationID,
"created_at": now,
},
)
if err != nil {
return fmt.Errorf("cannot insert default approver for SOA %s: %w", orig.id, err)
}
stats.defaultApprovers++
}
for major, snap := range snapshots {
content, err := buildSnapshotContent(ctx, tx, snap.id, orig.name)
if err != nil {
return fmt.Errorf("cannot build content for snapshot %s of SOA %s: %w", snap.snapshotID, orig.id, err)
}
versionID := gid.New(orig.tenantID, coredata.DocumentVersionEntityType)
_, err = tx.Exec(
ctx,
`
INSERT INTO document_versions (
id, tenant_id, organization_id, document_id,
title, major, minor, classification, document_type,
content, changelog, status, orientation,
published_at, created_at, updated_at
) VALUES (
@id, @tenant_id, @organization_id, @document_id,
@title, @major, 0,
'CONFIDENTIAL'::document_classification,
'STATEMENT_OF_APPLICABILITY'::document_type,
@content, '',
'PUBLISHED'::document_version_status,
'LANDSCAPE'::document_version_orientation,
@published_at, @published_at, @published_at
)`,
pgx.NamedArgs{
"id": versionID,
"tenant_id": orig.tenantID,
"organization_id": orig.organizationID,
"document_id": documentID,
"title": orig.name,
"major": major + 1,
"content": content,
"published_at": snap.publishedAt,
},
)
if err != nil {
return fmt.Errorf("cannot insert version for snapshot %s: %w", snap.snapshotID, err)
}
stats.versions++
if snap.ownerProfileID != nil {
quorumID := gid.New(orig.tenantID, coredata.DocumentVersionApprovalQuorumEntityType)
_, err = tx.Exec(
ctx,
`
INSERT INTO document_version_approval_quorums (
id, tenant_id, organization_id, version_id, status, created_at, updated_at
) VALUES (
@id, @tenant_id, @organization_id, @version_id,
'APPROVED'::document_version_approval_quorum_status,
@created_at, @created_at
)`,
pgx.NamedArgs{
"id": quorumID,
"tenant_id": orig.tenantID,
"organization_id": orig.organizationID,
"version_id": versionID,
"created_at": snap.publishedAt,
},
)
if err != nil {
return fmt.Errorf("cannot insert quorum for snapshot %s: %w", snap.snapshotID, err)
}
stats.quorums++
decisionID := gid.New(orig.tenantID, coredata.DocumentVersionApprovalDecisionEntityType)
_, err = tx.Exec(
ctx,
`
INSERT INTO document_version_approval_decisions (
id, tenant_id, organization_id, quorum_id,
approver_id, state, decided_at, created_at, updated_at
) VALUES (
@id, @tenant_id, @organization_id, @quorum_id,
@approver_id,
'APPROVED'::document_version_approval_decision_state,
@decided_at, @decided_at, @decided_at
)`,
pgx.NamedArgs{
"id": decisionID,
"tenant_id": orig.tenantID,
"organization_id": orig.organizationID,
"quorum_id": quorumID,
"approver_id": *snap.ownerProfileID,
"decided_at": snap.publishedAt,
},
)
if err != nil {
return fmt.Errorf("cannot insert decision for snapshot %s: %w", snap.snapshotID, err)
}
stats.decisions++
}
}
fmt.Printf("migrated SOA %s (%s) — %d version(s)\n", orig.id, orig.name, len(snapshots))
}
if dryRun {
fmt.Printf("\n%d SOA(s) would be migrated\n", len(originals))
return nil
}
fmt.Printf("\ncreated %d document(s), %d version(s), %d quorum(s), %d decision(s), %d default approver(s)\n",
stats.documents, stats.versions, stats.quorums, stats.decisions, stats.defaultApprovers)
return nil
}
func loadOriginalSOAs(ctx context.Context, tx pg.Tx) ([]originalSOA, error) {
rows, err := tx.Query(
ctx,
`
SELECT
soa.id,
soa.tenant_id,
soa.organization_id,
soa.name,
soa.owner_profile_id
FROM statements_of_applicability soa
WHERE soa.snapshot_id IS NULL
AND soa.document_id IS NULL
AND EXISTS (
SELECT 1 FROM statements_of_applicability snap
WHERE snap.source_id = soa.id AND snap.snapshot_id IS NOT NULL
)
ORDER BY soa.created_at;
`,
)
if err != nil {
return nil, fmt.Errorf("cannot query original SOAs: %w", err)
}
defer rows.Close()
var result []originalSOA
for rows.Next() {
var o originalSOA
if err := rows.Scan(&o.id, &o.tenantID, &o.organizationID, &o.name, &o.ownerProfileID); err != nil {
return nil, fmt.Errorf("cannot scan original SOA: %w", err)
}
result = append(result, o)
}
return result, rows.Err()
}
func loadSnapshots(ctx context.Context, tx pg.Tx, originalSOAID string) ([]snapshotSOA, error) {
rows, err := tx.Query(
ctx,
`
SELECT
snap.id,
snap.snapshot_id,
snap.owner_profile_id,
snap_record.created_at
FROM statements_of_applicability snap
JOIN snapshots snap_record ON snap_record.id = snap.snapshot_id
WHERE snap.source_id = @source_id
AND snap.snapshot_id IS NOT NULL
ORDER BY snap_record.created_at ASC;
`,
pgx.NamedArgs{"source_id": originalSOAID},
)
if err != nil {
return nil, fmt.Errorf("cannot query snapshots for SOA %s: %w", originalSOAID, err)
}
defer rows.Close()
var result []snapshotSOA
for rows.Next() {
var s snapshotSOA
if err := rows.Scan(&s.id, &s.snapshotID, &s.ownerProfileID, &s.publishedAt); err != nil {
return nil, fmt.Errorf("cannot scan snapshot: %w", err)
}
result = append(result, s)
}
return result, rows.Err()
}
type snapshotControl struct {
frameworkName string
sectionTitle string
controlName string
applicability bool
justification *string
bestPractice bool
maturityLevel string
notImplementedJustification *string
hasLegal bool
hasContractual bool
hasRisk bool
}
func buildSnapshotContent(ctx context.Context, tx pg.Tx, snapshotSOAID string, soaName string) (string, error) {
rows, err := tx.Query(
ctx,
`
WITH control_risks_via_measures AS (
SELECT DISTINCT cm.control_id
FROM controls_measures cm
INNER JOIN risks_measures rm ON cm.measure_id = rm.measure_id
),
control_risks_via_documents AS (
SELECT DISTINCT cd.control_id
FROM controls_documents cd
INNER JOIN risks_documents rd ON cd.document_id = rd.document_id
),
control_risks AS (
SELECT control_id FROM control_risks_via_measures
UNION
SELECT control_id FROM control_risks_via_documents
)
SELECT
f.name AS framework_name,
c.section_title,
c.name AS control_name,
stmt.applicability,
stmt.justification,
c.best_practice,
c.maturity_level,
c.not_implemented_justification,
EXISTS (
SELECT 1 FROM controls_obligations co
JOIN obligations o ON o.id = co.obligation_id
WHERE co.control_id = c.id AND o.type = 'LEGAL'
) AS has_legal,
EXISTS (
SELECT 1 FROM controls_obligations co
JOIN obligations o ON o.id = co.obligation_id
WHERE co.control_id = c.id AND o.type = 'CONTRACTUAL'
) AS has_contractual,
EXISTS (
SELECT 1 FROM control_risks cr WHERE cr.control_id = c.id
) AS has_risk
FROM applicability_statements stmt
JOIN controls c ON c.id = stmt.control_id
JOIN frameworks f ON f.id = c.framework_id
WHERE stmt.statement_of_applicability_id = @soa_id
ORDER BY f.name, c.section_title;
`,
pgx.NamedArgs{"soa_id": snapshotSOAID},
)
if err != nil {
return "", fmt.Errorf("cannot load snapshot controls: %w", err)
}
defer rows.Close()
var soaRows []docgen.SOARow
for rows.Next() {
var sc snapshotControl
if err := rows.Scan(
&sc.frameworkName,
&sc.sectionTitle,
&sc.controlName,
&sc.applicability,
&sc.justification,
&sc.bestPractice,
&sc.maturityLevel,
&sc.notImplementedJustification,
&sc.hasLegal,
&sc.hasContractual,
&sc.hasRisk,
); err != nil {
return "", fmt.Errorf("cannot scan control: %w", err)
}
applicable := sc.applicability
justification := "-"
if !applicable && sc.justification != nil {
justification = *sc.justification
}
maturityLevel := "-"
if applicable {
maturityLevel = docgen.MaturityLabel(coredata.ControlMaturityLevel(sc.maturityLevel))
}
notImplJustification := "-"
if applicable && sc.maturityLevel == "NONE" && sc.notImplementedJustification != nil {
notImplJustification = *sc.notImplementedJustification
}
regulatory := "-"
contractual := "-"
bestPractice := "-"
riskAssessment := "-"
if applicable {
regulatory = docgen.BoolLabel(sc.hasLegal)
contractual = docgen.BoolLabel(sc.hasContractual)
bestPractice = docgen.BoolLabel(sc.bestPractice)
riskAssessment = docgen.BoolLabel(sc.hasRisk)
}
soaRows = append(soaRows, docgen.SOARow{
FrameworkName: sc.frameworkName,
ControlSection: sc.sectionTitle,
ControlName: sc.controlName,
Applicability: docgen.BoolLabel(applicable),
Justification: justification,
MaturityLevel: maturityLevel,
NotImplJustification: notImplJustification,
Regulatory: regulatory,
Contractual: contractual,
BestPractice: bestPractice,
RiskAssessment: riskAssessment,
})
}
if err := rows.Err(); err != nil {
return "", err
}
data := docgen.StatementOfApplicabilityData{
Title: soaName,
TotalControls: len(soaRows),
Rows: soaRows,
}
return probo.BuildStatementOfApplicabilityDocument(data)
}
func newPgClientFromDSN(dsn string) (*pg.Client, error) {
u, err := url.Parse(dsn)
if err != nil {
return nil, fmt.Errorf("cannot parse DSN")
}
var opts []pg.Option
if u.Host != "" {
host := u.Host
if u.Port() == "" {
host = net.JoinHostPort(u.Hostname(), "5432")
}
opts = append(opts, pg.WithAddr(host))
}
if u.User != nil {
opts = append(opts, pg.WithUser(u.User.Username()))
if password, ok := u.User.Password(); ok {
opts = append(opts, pg.WithPassword(password))
}
}
if len(u.Path) > 1 {
opts = append(opts, pg.WithDatabase(u.Path[1:]))
}
return pg.NewClient(opts...)
}

View File

@@ -1,754 +0,0 @@
// 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.
// Command migrate-thirdParty-snapshots-to-documents creates documents and document
// versions from existing thirdParty snapshots. For each organization that has thirdParty
// snapshots, it generates a thirdParty list document using the same ProseMirror
// builder as the publish flow.
package main
import (
"context"
"flag"
"fmt"
"net"
"net/url"
"os"
"strings"
"time"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/docgen"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/probo"
)
func main() {
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}
func run() error {
var (
pgDSN string
dryRun bool
)
flag.StringVar(
&pgDSN,
"pg-dsn",
os.Getenv("DATABASE_URL"),
"PostgreSQL connection URL (default: DATABASE_URL env)",
)
flag.BoolVar(&dryRun, "dry-run", false, "show what would be done without writing")
flag.Parse()
if pgDSN == "" {
return fmt.Errorf("set -pg-dsn or DATABASE_URL")
}
ctx := context.Background()
pgClient, err := newPgClientFromDSN(pgDSN)
if err != nil {
return fmt.Errorf("cannot create pg client: %w", err)
}
return migrate(ctx, pgClient, dryRun)
}
type orgWithThirdPartySnapshots struct {
organizationID gid.GID
tenantID gid.TenantID
organizationName string
}
type thirdPartySnapshot struct {
snapshotID string
publishedAt time.Time
}
func migrate(ctx context.Context, pgClient *pg.Client, dryRun bool) error {
var orgs []orgWithThirdPartySnapshots
err := pgClient.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
var err error
orgs, err = loadOrgsWithThirdPartySnapshots(ctx, conn)
return err
})
if err != nil {
return err
}
if len(orgs) == 0 {
fmt.Println("no organizations with thirdParty snapshots to migrate")
return nil
}
var stats struct {
documents, versions, failed int
}
for _, org := range orgs {
if dryRun {
var count int
err := pgClient.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
snapshots, err := loadThirdPartySnapshots(ctx, conn, org.organizationID)
count = len(snapshots)
return err
})
if err != nil {
return err
}
fmt.Printf("would migrate org %s (%s) — %d thirdParty snapshot(s)\n",
org.organizationID, org.organizationName, count)
continue
}
err := pgClient.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
return migrateOrg(ctx, tx, org)
})
if err != nil {
fmt.Fprintf(os.Stderr, "FAIL org %s (%s): %v\n",
org.organizationID, org.organizationName, err)
stats.failed++
continue
}
stats.documents++
}
if dryRun {
fmt.Printf("\n%d organization(s) would be migrated\n", len(orgs))
return nil
}
fmt.Printf("\nmigrated %d organization(s), %d failed\n",
stats.documents, stats.failed)
return nil
}
func migrateOrg(ctx context.Context, tx pg.Tx, org orgWithThirdPartySnapshots) error {
snapshots, err := loadThirdPartySnapshots(ctx, tx, org.organizationID)
if err != nil {
return err
}
if len(snapshots) == 0 {
return nil
}
documentID := gid.New(org.tenantID, coredata.DocumentEntityType)
now := time.Now()
_, err = tx.Exec(
ctx,
`
INSERT INTO documents (
id, tenant_id, organization_id, write_mode,
current_published_major, current_published_minor,
trust_center_visibility, status, created_at, updated_at
) VALUES (
@id, @tenant_id, @organization_id,
'GENERATED'::document_write_mode,
@current_published_major, 0,
'NONE'::trust_center_visibility,
'ACTIVE'::document_status,
@created_at, @updated_at
)`,
pgx.NamedArgs{
"id": documentID,
"tenant_id": org.tenantID,
"organization_id": org.organizationID,
"current_published_major": len(snapshots),
"created_at": now,
"updated_at": now,
},
)
if err != nil {
return fmt.Errorf("cannot insert document: %w", err)
}
_, err = tx.Exec(
ctx,
`INSERT INTO generated_documents (organization_id, tenant_id, third_parties_document_id, created_at, updated_at)
VALUES (@organization_id, @tenant_id, @third_parties_document_id, @created_at, @updated_at)
ON CONFLICT (organization_id) DO UPDATE SET third_parties_document_id = @third_parties_document_id, updated_at = @updated_at`,
pgx.NamedArgs{
"organization_id": org.organizationID,
"tenant_id": org.tenantID,
"third_parties_document_id": documentID,
"created_at": now,
"updated_at": now,
},
)
if err != nil {
return fmt.Errorf("cannot link document: %w", err)
}
for major, snap := range snapshots {
content, err := buildSnapshotContent(ctx, tx, snap.snapshotID, org.organizationName, snap.publishedAt)
if err != nil {
return fmt.Errorf("cannot build content for snapshot %s: %w", snap.snapshotID, err)
}
versionID := gid.New(org.tenantID, coredata.DocumentVersionEntityType)
_, err = tx.Exec(
ctx,
`
INSERT INTO document_versions (
id, tenant_id, organization_id, document_id,
title, major, minor, classification, document_type,
content, changelog, status, orientation,
pdf_attempt_count,
published_at, created_at, updated_at
) VALUES (
@id, @tenant_id, @organization_id, @document_id,
@title, @major, 0,
'CONFIDENTIAL'::document_classification,
'REGISTER'::document_type,
@content, '',
'PUBLISHED'::document_version_status,
'PORTRAIT'::document_version_orientation,
0,
@published_at, @published_at, @published_at
)`,
pgx.NamedArgs{
"id": versionID,
"tenant_id": org.tenantID,
"organization_id": org.organizationID,
"document_id": documentID,
"title": "ThirdParties",
"major": major + 1,
"content": content,
"published_at": snap.publishedAt,
},
)
if err != nil {
return fmt.Errorf("cannot insert version for snapshot %s: %w", snap.snapshotID, err)
}
}
fmt.Printf("OK org %s (%s) — %d version(s)\n",
org.organizationID, org.organizationName, len(snapshots))
return nil
}
func loadOrgsWithThirdPartySnapshots(ctx context.Context, conn pg.Querier) ([]orgWithThirdPartySnapshots, error) {
rows, err := conn.Query(
ctx,
`
SELECT DISTINCT
o.id,
o.tenant_id,
o.name,
o.created_at
FROM organizations o
WHERE NOT EXISTS (
SELECT 1 FROM generated_documents gd
WHERE gd.organization_id = o.id AND gd.third_parties_document_id IS NOT NULL
)
AND EXISTS (
SELECT 1 FROM snapshots s
WHERE s.organization_id = o.id AND s.type = 'VENDORS'
)
ORDER BY o.created_at;
`,
)
if err != nil {
return nil, fmt.Errorf("cannot query organizations with thirdParty snapshots: %w", err)
}
defer rows.Close()
var result []orgWithThirdPartySnapshots
for rows.Next() {
var o orgWithThirdPartySnapshots
var createdAt time.Time
if err := rows.Scan(&o.organizationID, &o.tenantID, &o.organizationName, &createdAt); err != nil {
return nil, fmt.Errorf("cannot scan organization: %w", err)
}
result = append(result, o)
}
return result, rows.Err()
}
func loadThirdPartySnapshots(ctx context.Context, conn pg.Querier, organizationID gid.GID) ([]thirdPartySnapshot, error) {
rows, err := conn.Query(
ctx,
`
SELECT DISTINCT
s.id,
s.created_at
FROM snapshots s
WHERE s.organization_id = @organization_id
AND s.type = 'VENDORS'
ORDER BY s.created_at ASC;
`,
pgx.NamedArgs{"organization_id": organizationID},
)
if err != nil {
return nil, fmt.Errorf("cannot query thirdParty snapshots for org %s: %w", organizationID, err)
}
defer rows.Close()
var result []thirdPartySnapshot
for rows.Next() {
var s thirdPartySnapshot
if err := rows.Scan(&s.snapshotID, &s.publishedAt); err != nil {
return nil, fmt.Errorf("cannot scan snapshot: %w", err)
}
result = append(result, s)
}
return result, rows.Err()
}
type thirdPartyInfo struct {
id string
name string
category string
legalName *string
description *string
headquarterAddress *string
websiteURL *string
privacyPolicyURL *string
serviceLevelAgreementURL *string
dataProcessingAgreementURL *string
businessAssociateAgreementURL *string
subprocessorsListURL *string
statusPageURL *string
termsOfServiceURL *string
securityPageURL *string
trustPageURL *string
certifications []string
countries []string
businessOwnerName string
securityOwnerName string
}
func buildSnapshotContent(
ctx context.Context,
tx pg.Tx,
snapshotID string,
orgName string,
publishedAt time.Time,
) (string, error) {
thirdPartyRows, err := tx.Query(
ctx,
`
SELECT
v.id,
v.name,
v.category,
v.legal_name,
v.description,
v.headquarter_address,
v.website_url,
v.privacy_policy_url,
v.service_level_agreement_url,
v.data_processing_agreement_url,
v.business_associate_agreement_url,
v.subprocessors_list_url,
v.status_page_url,
v.terms_of_service_url,
v.security_page_url,
v.trust_page_url,
v.certifications,
v.countries,
COALESCE(bo.full_name, 'Not assigned'),
COALESCE(so.full_name, 'Not assigned')
FROM third_parties v
LEFT JOIN iam_membership_profiles bo ON bo.id = v.business_owner_profile_id
LEFT JOIN iam_membership_profiles so ON so.id = v.security_owner_profile_id
WHERE v.snapshot_id = @snapshot_id
ORDER BY v.name ASC;
`,
pgx.NamedArgs{"snapshot_id": snapshotID},
)
if err != nil {
return "", fmt.Errorf("cannot load snapshot thirdParties: %w", err)
}
defer thirdPartyRows.Close()
var thirdParties []thirdPartyInfo
for thirdPartyRows.Next() {
var v thirdPartyInfo
if err := thirdPartyRows.Scan(
&v.id, &v.name, &v.category,
&v.legalName, &v.description, &v.headquarterAddress,
&v.websiteURL, &v.privacyPolicyURL, &v.serviceLevelAgreementURL,
&v.dataProcessingAgreementURL, &v.businessAssociateAgreementURL,
&v.subprocessorsListURL, &v.statusPageURL, &v.termsOfServiceURL,
&v.securityPageURL, &v.trustPageURL,
&v.certifications, &v.countries,
&v.businessOwnerName, &v.securityOwnerName,
); err != nil {
return "", fmt.Errorf("cannot scan thirdParty: %w", err)
}
thirdParties = append(thirdParties, v)
}
if err := thirdPartyRows.Err(); err != nil {
return "", err
}
thirdPartyIDs := make([]string, len(thirdParties))
for i, v := range thirdParties {
thirdPartyIDs[i] = v.id
}
servicesByThirdParty, err := loadSnapshotServices(ctx, tx, snapshotID, thirdPartyIDs)
if err != nil {
return "", err
}
contactsByThirdParty, err := loadSnapshotContacts(ctx, tx, snapshotID, thirdPartyIDs)
if err != nil {
return "", err
}
assessmentsByThirdParty, err := loadSnapshotRiskAssessments(ctx, tx, snapshotID, thirdPartyIDs)
if err != nil {
return "", err
}
reportsByThirdParty, err := loadSnapshotComplianceReports(ctx, tx, snapshotID, thirdPartyIDs)
if err != nil {
return "", err
}
baaByThirdParty, err := loadSnapshotBAAs(ctx, tx, snapshotID, thirdPartyIDs)
if err != nil {
return "", err
}
dpaByThirdParty, err := loadSnapshotDPAs(ctx, tx, snapshotID, thirdPartyIDs)
if err != nil {
return "", err
}
rows := make([]docgen.ThirdPartyListRow, 0, len(thirdParties))
for _, v := range thirdParties {
row := docgen.ThirdPartyListRow{
Name: v.name,
LegalName: deref(v.legalName),
Description: deref(v.description),
Category: formatCategory(v.category),
HeadquarterAddress: deref(v.headquarterAddress),
WebsiteURL: deref(v.websiteURL),
PrivacyPolicyURL: deref(v.privacyPolicyURL),
ServiceLevelAgreementURL: deref(v.serviceLevelAgreementURL),
DataProcessingAgreementURL: deref(v.dataProcessingAgreementURL),
BusinessAssociateAgreementURL: deref(v.businessAssociateAgreementURL),
SubprocessorsListURL: deref(v.subprocessorsListURL),
StatusPageURL: deref(v.statusPageURL),
TermsOfServiceURL: deref(v.termsOfServiceURL),
SecurityPageURL: deref(v.securityPageURL),
TrustPageURL: deref(v.trustPageURL),
Certifications: joinOrDefault(v.certifications),
Countries: joinOrDefault(v.countries),
BusinessOwner: v.businessOwnerName,
SecurityOwner: v.securityOwnerName,
Services: servicesByThirdParty[v.id],
Contacts: contactsByThirdParty[v.id],
RiskAssessments: assessmentsByThirdParty[v.id],
ComplianceReports: reportsByThirdParty[v.id],
BusinessAssociateAgreement: baaByThirdParty[v.id],
DataPrivacyAgreement: dpaByThirdParty[v.id],
}
rows = append(rows, row)
}
docData := docgen.ThirdPartyListData{
Title: "ThirdParties",
OrganizationName: orgName,
CreatedAt: publishedAt,
TotalThirdParties: len(rows),
Rows: rows,
}
return probo.BuildThirdPartyListDocument(docData)
}
func loadSnapshotServices(ctx context.Context, tx pg.Tx, snapshotID string, thirdPartyIDs []string) (map[string][]docgen.ThirdPartyListService, error) {
rows, err := tx.Query(ctx,
`SELECT vs.third_party_id, vs.name, COALESCE(vs.description, 'Not specified')
FROM third_party_services vs
WHERE vs.snapshot_id = @snapshot_id AND vs.third_party_id = ANY(@third_party_ids)
ORDER BY vs.third_party_id, vs.name ASC`,
pgx.NamedArgs{"snapshot_id": snapshotID, "third_party_ids": thirdPartyIDs})
if err != nil {
return nil, fmt.Errorf("cannot load snapshot services: %w", err)
}
defer rows.Close()
result := make(map[string][]docgen.ThirdPartyListService)
for rows.Next() {
var thirdPartyID, name, desc string
if err := rows.Scan(&thirdPartyID, &name, &desc); err != nil {
return nil, fmt.Errorf("cannot scan service: %w", err)
}
result[thirdPartyID] = append(result[thirdPartyID], docgen.ThirdPartyListService{Name: name, Description: desc})
}
return result, rows.Err()
}
func loadSnapshotContacts(ctx context.Context, tx pg.Tx, snapshotID string, thirdPartyIDs []string) (map[string][]docgen.ThirdPartyListContact, error) {
rows, err := tx.Query(ctx,
`SELECT vc.third_party_id,
COALESCE(vc.full_name, 'Not specified'),
COALESCE(vc.email, 'Not specified'),
COALESCE(vc.phone, 'Not specified'),
COALESCE(vc.role, 'Not specified')
FROM third_party_contacts vc
WHERE vc.snapshot_id = @snapshot_id AND vc.third_party_id = ANY(@third_party_ids)
ORDER BY vc.third_party_id, vc.full_name ASC`,
pgx.NamedArgs{"snapshot_id": snapshotID, "third_party_ids": thirdPartyIDs})
if err != nil {
return nil, fmt.Errorf("cannot load snapshot contacts: %w", err)
}
defer rows.Close()
result := make(map[string][]docgen.ThirdPartyListContact)
for rows.Next() {
var thirdPartyID, name, email, phone, role string
if err := rows.Scan(&thirdPartyID, &name, &email, &phone, &role); err != nil {
return nil, fmt.Errorf("cannot scan contact: %w", err)
}
result[thirdPartyID] = append(result[thirdPartyID], docgen.ThirdPartyListContact{
FullName: name, Email: email, Phone: phone, Role: role,
})
}
return result, rows.Err()
}
func loadSnapshotRiskAssessments(ctx context.Context, tx pg.Tx, snapshotID string, thirdPartyIDs []string) (map[string][]docgen.ThirdPartyListRiskAssessment, error) {
rows, err := tx.Query(ctx,
`SELECT vra.third_party_id, vra.created_at, vra.expires_at, vra.data_sensitivity, vra.business_impact, COALESCE(vra.notes, 'Not specified')
FROM third_party_risk_assessments vra
WHERE vra.snapshot_id = @snapshot_id AND vra.third_party_id = ANY(@third_party_ids)
ORDER BY vra.third_party_id, vra.created_at DESC`,
pgx.NamedArgs{"snapshot_id": snapshotID, "third_party_ids": thirdPartyIDs})
if err != nil {
return nil, fmt.Errorf("cannot load snapshot risk assessments: %w", err)
}
defer rows.Close()
result := make(map[string][]docgen.ThirdPartyListRiskAssessment)
for rows.Next() {
var thirdPartyID, sensitivity, impact, notes string
var assessedAt, expiresAt time.Time
if err := rows.Scan(&thirdPartyID, &assessedAt, &expiresAt, &sensitivity, &impact, &notes); err != nil {
return nil, fmt.Errorf("cannot scan risk assessment: %w", err)
}
result[thirdPartyID] = append(result[thirdPartyID], docgen.ThirdPartyListRiskAssessment{
AssessedAt: assessedAt.Format("2006-01-02"),
ExpiresAt: expiresAt.Format("2006-01-02"),
DataSensitivity: sensitivity,
BusinessImpact: impact,
Notes: notes,
})
}
return result, rows.Err()
}
func loadSnapshotComplianceReports(ctx context.Context, tx pg.Tx, snapshotID string, thirdPartyIDs []string) (map[string][]docgen.ThirdPartyListComplianceReport, error) {
rows, err := tx.Query(ctx,
`SELECT vcr.third_party_id, vcr.report_name, vcr.report_date, vcr.valid_until
FROM third_party_compliance_reports vcr
WHERE vcr.snapshot_id = @snapshot_id AND vcr.third_party_id = ANY(@third_party_ids)
ORDER BY vcr.third_party_id, vcr.report_date DESC`,
pgx.NamedArgs{"snapshot_id": snapshotID, "third_party_ids": thirdPartyIDs})
if err != nil {
return nil, fmt.Errorf("cannot load snapshot compliance reports: %w", err)
}
defer rows.Close()
result := make(map[string][]docgen.ThirdPartyListComplianceReport)
for rows.Next() {
var thirdPartyID, name string
var reportDate time.Time
var validUntil *time.Time
if err := rows.Scan(&thirdPartyID, &name, &reportDate, &validUntil); err != nil {
return nil, fmt.Errorf("cannot scan compliance report: %w", err)
}
vu := "Not specified"
if validUntil != nil {
vu = validUntil.Format("2006-01-02")
}
result[thirdPartyID] = append(result[thirdPartyID], docgen.ThirdPartyListComplianceReport{
ReportName: name, ReportDate: reportDate.Format("2006-01-02"), ValidUntil: vu,
})
}
return result, rows.Err()
}
func loadSnapshotBAAs(ctx context.Context, tx pg.Tx, snapshotID string, thirdPartyIDs []string) (map[string]*docgen.ThirdPartyListAgreement, error) {
rows, err := tx.Query(ctx,
`SELECT vbaa.third_party_id, vbaa.valid_from, vbaa.valid_until
FROM third_party_business_associate_agreements vbaa
WHERE vbaa.snapshot_id = @snapshot_id AND vbaa.third_party_id = ANY(@third_party_ids)`,
pgx.NamedArgs{"snapshot_id": snapshotID, "third_party_ids": thirdPartyIDs})
if err != nil {
return nil, fmt.Errorf("cannot load snapshot BAAs: %w", err)
}
defer rows.Close()
result := make(map[string]*docgen.ThirdPartyListAgreement)
for rows.Next() {
var thirdPartyID string
var validFrom, validUntil *time.Time
if err := rows.Scan(&thirdPartyID, &validFrom, &validUntil); err != nil {
return nil, fmt.Errorf("cannot scan BAA: %w", err)
}
result[thirdPartyID] = &docgen.ThirdPartyListAgreement{
ValidFrom: fmtTime(validFrom), ValidUntil: fmtTime(validUntil),
}
}
return result, rows.Err()
}
func loadSnapshotDPAs(ctx context.Context, tx pg.Tx, snapshotID string, thirdPartyIDs []string) (map[string]*docgen.ThirdPartyListAgreement, error) {
rows, err := tx.Query(ctx,
`SELECT vdpa.third_party_id, vdpa.valid_from, vdpa.valid_until
FROM third_party_data_privacy_agreements vdpa
WHERE vdpa.snapshot_id = @snapshot_id AND vdpa.third_party_id = ANY(@third_party_ids)`,
pgx.NamedArgs{"snapshot_id": snapshotID, "third_party_ids": thirdPartyIDs})
if err != nil {
return nil, fmt.Errorf("cannot load snapshot DPAs: %w", err)
}
defer rows.Close()
result := make(map[string]*docgen.ThirdPartyListAgreement)
for rows.Next() {
var thirdPartyID string
var validFrom, validUntil *time.Time
if err := rows.Scan(&thirdPartyID, &validFrom, &validUntil); err != nil {
return nil, fmt.Errorf("cannot scan DPA: %w", err)
}
result[thirdPartyID] = &docgen.ThirdPartyListAgreement{
ValidFrom: fmtTime(validFrom), ValidUntil: fmtTime(validUntil),
}
}
return result, rows.Err()
}
func deref(s *string) string {
if s == nil || *s == "" {
return "Not specified"
}
return *s
}
func joinOrDefault(items []string) string {
if len(items) == 0 {
return "Not specified"
}
return strings.Join(items, ", ")
}
func fmtTime(t *time.Time) string {
if t == nil {
return "Not specified"
}
return t.Format("2006-01-02")
}
func formatCategory(c string) string {
switch c {
case "ANALYTICS":
return "Analytics"
case "CLOUD_MONITORING":
return "Cloud Monitoring"
case "CLOUD_PROVIDER":
return "Cloud Provider"
case "COLLABORATION":
return "Collaboration"
case "CUSTOMER_SUPPORT":
return "Customer Support"
case "DATA_STORAGE_AND_PROCESSING":
return "Data Storage and Processing"
case "DOCUMENT_MANAGEMENT":
return "Document Management"
case "EMPLOYEE_MANAGEMENT":
return "Employee Management"
case "ENGINEERING":
return "Engineering"
case "FINANCE":
return "Finance"
case "IDENTITY_PROVIDER":
return "Identity Provider"
case "IT":
return "IT"
case "MARKETING":
return "Marketing"
case "OFFICE_OPERATIONS":
return "Office Operations"
case "OTHER":
return "Other"
case "PASSWORD_MANAGEMENT":
return "Password Management"
case "PRODUCT_AND_DESIGN":
return "Product and Design"
case "PROFESSIONAL_SERVICES":
return "Professional Services"
case "RECRUITING":
return "Recruiting"
case "SALES":
return "Sales"
case "SECURITY":
return "Security"
case "VERSION_CONTROL":
return "Version Control"
default:
return c
}
}
func newPgClientFromDSN(dsn string) (*pg.Client, error) {
u, err := url.Parse(dsn)
if err != nil {
return nil, fmt.Errorf("cannot parse DSN")
}
var opts []pg.Option
if u.Host != "" {
host := u.Host
if u.Port() == "" {
host = net.JoinHostPort(u.Hostname(), "5432")
}
opts = append(opts, pg.WithAddr(host))
}
if u.User != nil {
opts = append(opts, pg.WithUser(u.User.Username()))
if password, ok := u.User.Password(); ok {
opts = append(opts, pg.WithPassword(password))
}
}
if len(u.Path) > 1 {
opts = append(opts, pg.WithDatabase(u.Path[1:]))
}
return pg.NewClient(opts...)
}

View File

@@ -13,7 +13,6 @@ Every entity uses `gid.GID` for its ID, `db` tags for pgx mapping, and `CreatedA
type (
Asset struct {
ID gid.GID `db:"id"`
SnapshotID *gid.GID `db:"snapshot_id"`
Name string `db:"name"`
OrganizationID gid.GID `db:"organization_id"`
AssetType AssetType `db:"asset_type"`

View File

@@ -34,7 +34,6 @@ type (
StatementOfApplicabilityID gid.GID `db:"statement_of_applicability_id"`
ControlID gid.GID `db:"control_id"`
OrganizationID gid.GID `db:"organization_id"`
SnapshotID *gid.GID `db:"snapshot_id"`
Applicability bool `db:"applicability"`
Justification *string `db:"justification"`
CreatedAt time.Time `db:"created_at"`
@@ -85,7 +84,6 @@ WITH stmt AS (
a.statement_of_applicability_id,
a.control_id,
a.organization_id,
a.snapshot_id,
a.applicability,
a.justification,
a.created_at,
@@ -107,7 +105,6 @@ SELECT
statement_of_applicability_id,
control_id,
organization_id,
snapshot_id,
applicability,
justification,
created_at,
@@ -162,7 +159,6 @@ SELECT
soac.statement_of_applicability_id,
soac.control_id,
soac.organization_id,
soac.snapshot_id,
soac.applicability,
soac.justification,
soac.created_at,
@@ -218,7 +214,6 @@ INSERT INTO
control_id,
organization_id,
tenant_id,
snapshot_id,
applicability,
justification,
created_at,
@@ -230,7 +225,6 @@ VALUES (
@control_id,
@organization_id,
@tenant_id,
@snapshot_id,
@applicability,
@justification,
@created_at,
@@ -244,7 +238,6 @@ VALUES (
"control_id": sac.ControlID,
"organization_id": sac.OrganizationID,
"tenant_id": scope.GetTenantID(),
"snapshot_id": sac.SnapshotID,
"applicability": sac.Applicability,
"justification": sac.Justification,
"created_at": sac.CreatedAt,
@@ -410,7 +403,6 @@ WITH stmt AS (
a.statement_of_applicability_id,
a.control_id,
a.organization_id,
a.snapshot_id,
a.applicability,
a.justification,
a.created_at,
@@ -432,7 +424,6 @@ SELECT
statement_of_applicability_id,
control_id,
organization_id,
snapshot_id,
applicability,
justification,
created_at,
@@ -477,7 +468,6 @@ SELECT
a.statement_of_applicability_id,
a.control_id,
a.organization_id,
a.snapshot_id,
a.applicability,
a.justification,
a.created_at,
@@ -558,7 +548,6 @@ WITH soac_ctrl AS (
soac.statement_of_applicability_id,
soac.control_id,
soac.organization_id,
soac.snapshot_id,
soac.applicability,
soac.justification,
soac.created_at,
@@ -576,14 +565,12 @@ WITH soac_ctrl AS (
WHERE
soac.%[1]s
AND soac.control_id = @control_id
AND soa.snapshot_id IS NULL
)
SELECT
id,
statement_of_applicability_id,
control_id,
organization_id,
snapshot_id,
applicability,
justification,
created_at,

View File

@@ -91,7 +91,6 @@ FROM
WHERE
%s
AND id = @asset_id
AND snapshot_id IS NULL
LIMIT 1;
`
@@ -140,7 +139,6 @@ FROM
WHERE
%s
AND owner_profile_id = @owner_profile_id
AND snapshot_id IS NULL
LIMIT 1;
`
@@ -182,7 +180,6 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND snapshot_id IS NULL
`
q = fmt.Sprintf(q, scope.SQLFragment())
@@ -223,7 +220,6 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND snapshot_id IS NULL
AND %s
`
@@ -270,7 +266,6 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND snapshot_id IS NULL
ORDER BY
name ASC
`
@@ -364,7 +359,6 @@ SET
WHERE
%s
AND id = @id
AND snapshot_id IS NULL
RETURNING
id,
name,
@@ -415,7 +409,6 @@ DELETE FROM assets
WHERE
%s
AND id = @id
AND snapshot_id IS NULL
`
q = fmt.Sprintf(q, scope.SQLFragment())

View File

@@ -138,8 +138,6 @@ WHERE
type (
DataProtectionImpactAssessment struct {
ID gid.GID `db:"id"`
SnapshotID *gid.GID `db:"snapshot_id"`
SourceID *gid.GID `db:"source_id"`
OrganizationID gid.GID `db:"organization_id"`
ProcessingActivityID gid.GID `db:"processing_activity_id"`
Description *string `db:"description"`
@@ -192,7 +190,6 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND snapshot_id IS NULL
`
q = fmt.Sprintf(q, scope.SQLFragment())
@@ -221,8 +218,6 @@ func (dpias *DataProtectionImpactAssessments) LoadByOrganizationID(
q := `
SELECT
id,
snapshot_id,
source_id,
organization_id,
processing_activity_id,
description,
@@ -237,7 +232,6 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND snapshot_id IS NULL
AND %s
`
@@ -271,8 +265,6 @@ func (dpias *DataProtectionImpactAssessments) LoadAllByOrganizationID(
q := `
SELECT
id,
snapshot_id,
source_id,
organization_id,
processing_activity_id,
description,
@@ -287,7 +279,6 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND snapshot_id IS NULL
`
q = fmt.Sprintf(q, scope.SQLFragment())
@@ -319,8 +310,6 @@ func (dpia *DataProtectionImpactAssessment) LoadByID(
q := `
SELECT
id,
snapshot_id,
source_id,
organization_id,
processing_activity_id,
description,
@@ -371,8 +360,6 @@ func (dpia *DataProtectionImpactAssessment) LoadByProcessingActivityID(
q := `
SELECT
id,
snapshot_id,
source_id,
organization_id,
processing_activity_id,
description,

View File

@@ -89,7 +89,6 @@ FROM
WHERE
%s
AND id = @data_id
AND snapshot_id IS NULL
LIMIT 1;
`
@@ -132,7 +131,6 @@ FROM
WHERE
%s
AND owner_profile_id = @owner_profile_id
AND snapshot_id IS NULL
LIMIT 1;
`
@@ -170,7 +168,6 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND snapshot_id IS NULL
`
q = fmt.Sprintf(q, scope.SQLFragment())
@@ -210,7 +207,6 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND snapshot_id IS NULL
AND %s
`
@@ -255,7 +251,6 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND snapshot_id IS NULL
ORDER BY
name ASC
`
@@ -341,7 +336,6 @@ SET
WHERE
%s
AND id = @id
AND snapshot_id IS NULL
RETURNING
id,
name,
@@ -388,7 +382,6 @@ DELETE FROM data
WHERE
%s
AND id = @id
AND snapshot_id IS NULL
`
q = fmt.Sprintf(q, scope.SQLFragment())

View File

@@ -31,8 +31,6 @@ type (
Finding struct {
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
SnapshotID *gid.GID `db:"snapshot_id"`
SourceID *gid.GID `db:"source_id"`
Kind FindingKind `db:"kind"`
ReferenceID string `db:"reference_id"`
Description *string `db:"description"`
@@ -98,8 +96,6 @@ func (f *Finding) LoadByID(
SELECT
id,
organization_id,
snapshot_id,
source_id,
kind,
reference_id,
description,
@@ -120,7 +116,6 @@ FROM
WHERE
%s
AND id = @finding_id
AND snapshot_id IS NULL
LIMIT 1;
`
@@ -159,7 +154,6 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND snapshot_id IS NULL
AND %s
`
@@ -192,8 +186,6 @@ func (fs *Findings) LoadByOrganizationID(
SELECT
id,
organization_id,
snapshot_id,
source_id,
kind,
reference_id,
description,
@@ -214,7 +206,6 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND snapshot_id IS NULL
AND %s
AND %s
`
@@ -264,7 +255,7 @@ WITH next_ref AS (
0
) + 1 AS next_num
FROM findings
WHERE organization_id = @organization_id AND snapshot_id IS NULL
WHERE organization_id = @organization_id
)
INSERT INTO findings (
id,
@@ -360,7 +351,6 @@ SET
WHERE
%s
AND id = @id
AND snapshot_id IS NULL
`
q = fmt.Sprintf(q, scope.SQLFragment())
@@ -399,7 +389,7 @@ func (f *Finding) Delete(
DELETE FROM findings
WHERE
%s
AND id = @id AND snapshot_id IS NULL
AND id = @id
`
q = fmt.Sprintf(q, scope.SQLFragment())
@@ -429,8 +419,6 @@ WITH f AS (
fi.id,
fi.tenant_id,
fi.organization_id,
fi.snapshot_id,
fi.source_id,
fi.kind,
fi.reference_id,
fi.description,
@@ -452,13 +440,10 @@ 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,
organization_id,
snapshot_id,
source_id,
kind,
reference_id,
description,
@@ -520,7 +505,6 @@ 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)
@@ -558,8 +542,6 @@ func (fs *Findings) LoadAllByOrganizationID(
SELECT
id,
organization_id,
snapshot_id,
source_id,
kind,
reference_id,
description,
@@ -580,7 +562,6 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND snapshot_id IS NULL
ORDER BY
reference_id ASC
`

View File

@@ -0,0 +1,30 @@
-- 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.
-- The register/document model has fully replaced the snapshot system. Delete the
-- snapshot-scoped data; the schema (snapshot_id / source_id columns, snapshots
-- table, controls_snapshots, snapshots_type enum, snapshot-scoped indexes) is
-- dropped in a follow-up migration.
-- processing_activity_vendors stores snapshot_id without an FK to snapshots, so
-- the cascade delete below would not reach it. Clean it up explicitly first.
DELETE FROM processing_activity_vendors WHERE snapshot_id IS NOT NULL;
-- Every other table with a snapshot_id has a FOREIGN KEY (snapshot_id) REFERENCES
-- snapshots(id) ON DELETE CASCADE, so deleting all snapshots removes every
-- snapshot-scoped row across data, vendors, assets, risks, findings, obligations,
-- processing_activities, statements_of_applicability, applicability_statements,
-- the vendor_* sub-tables, the processing_activity DPIA/TIA tables, and the
-- controls_snapshots junction.
DELETE FROM snapshots;

View File

@@ -41,8 +41,6 @@ type (
DueDate *time.Time `db:"due_date"`
Status ObligationStatus `db:"status"`
Type ObligationType `db:"type"`
SnapshotID *gid.GID `db:"snapshot_id"`
SourceID *gid.GID `db:"source_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
@@ -89,8 +87,6 @@ func (o *Obligation) LoadByID(
SELECT
id,
organization_id,
snapshot_id,
source_id,
area,
source,
requirement,
@@ -108,7 +104,6 @@ FROM
WHERE
%s
AND id = @obligation_id
AND snapshot_id IS NULL
LIMIT 1;
`
@@ -146,7 +141,6 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND snapshot_id IS NULL
`
q = fmt.Sprintf(q, scope.SQLFragment())
@@ -176,7 +170,6 @@ WITH obls AS (
SELECT
o.id,
o.tenant_id,
o.snapshot_id,
o.search_vector
FROM
obligations o
@@ -184,7 +177,6 @@ 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)
@@ -230,8 +222,6 @@ SELECT
due_date,
status,
type,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -239,7 +229,6 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND snapshot_id IS NULL
AND %s
`
@@ -286,8 +275,6 @@ WITH obls AS (
o.due_date,
o.status,
o.type,
o.snapshot_id,
o.source_id,
o.created_at,
o.updated_at,
o.tenant_id,
@@ -298,7 +285,6 @@ 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,
@@ -313,8 +299,6 @@ SELECT
due_date,
status,
type,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -354,15 +338,13 @@ func (os *Obligations) CountByControlID(
WITH obls AS (
SELECT
o.id,
o.tenant_id,
o.snapshot_id
o.tenant_id
FROM
obligations o
INNER JOIN
controls_obligations co ON o.id = co.obligation_id
WHERE
co.control_id = @control_id
AND o.snapshot_id IS NULL
)
SELECT
COUNT(id)
@@ -409,8 +391,6 @@ WITH obls AS (
o.due_date,
o.status,
o.type,
o.snapshot_id,
o.source_id,
o.created_at,
o.updated_at,
o.tenant_id
@@ -420,7 +400,6 @@ 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,
@@ -435,8 +414,6 @@ SELECT
due_date,
status,
type,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -485,8 +462,6 @@ INSERT INTO obligations (
due_date,
status,
type,
snapshot_id,
source_id,
created_at,
updated_at
) VALUES (
@@ -503,8 +478,6 @@ INSERT INTO obligations (
@due_date,
@status,
@type,
@snapshot_id,
@source_id,
@created_at,
@updated_at
)
@@ -524,8 +497,6 @@ INSERT INTO obligations (
"due_date": o.DueDate,
"status": o.Status,
"type": o.Type,
"snapshot_id": o.SnapshotID,
"source_id": o.SourceID,
"created_at": o.CreatedAt,
"updated_at": o.UpdatedAt,
}
@@ -559,7 +530,6 @@ UPDATE obligations SET
WHERE
%s
AND id = @id
AND snapshot_id IS NULL
`
q = fmt.Sprintf(q, scope.SQLFragment())
@@ -598,7 +568,6 @@ DELETE FROM obligations
WHERE
%s
AND id = @id
AND snapshot_id IS NULL
`
q = fmt.Sprintf(q, scope.SQLFragment())
@@ -634,8 +603,6 @@ SELECT
due_date,
status,
type,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -643,7 +610,6 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND snapshot_id IS NULL
ORDER BY
created_at ASC
`

View File

@@ -137,8 +137,6 @@ WHERE
type (
ProcessingActivity struct {
ID gid.GID `db:"id"`
SnapshotID *gid.GID `db:"snapshot_id"`
SourceID *gid.GID `db:"source_id"`
OrganizationID gid.GID `db:"organization_id"`
Name string `db:"name"`
Purpose *string `db:"purpose"`
@@ -200,8 +198,6 @@ func (p *ProcessingActivity) LoadByID(
q := `
SELECT
id,
snapshot_id,
source_id,
organization_id,
name,
purpose,
@@ -266,7 +262,6 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND snapshot_id IS NULL
`
q = fmt.Sprintf(q, scope.SQLFragment())
@@ -299,8 +294,6 @@ func (p *ProcessingActivities) LoadByIDs(
q := `
SELECT
id,
snapshot_id,
source_id,
organization_id,
name,
purpose,
@@ -365,8 +358,6 @@ func (p *ProcessingActivities) LoadByOrganizationID(
q := `
SELECT
id,
snapshot_id,
source_id,
organization_id,
name,
purpose,
@@ -394,7 +385,6 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND snapshot_id IS NULL
AND %s
`
@@ -428,8 +418,6 @@ func (p *ProcessingActivities) LoadAllByOrganizationID(
q := `
SELECT
id,
snapshot_id,
source_id,
organization_id,
name,
purpose,
@@ -457,7 +445,6 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND snapshot_id IS NULL
ORDER BY created_at DESC
`
@@ -490,8 +477,6 @@ func (p *ProcessingActivity) Insert(
INSERT INTO processing_activities (
id,
tenant_id,
snapshot_id,
source_id,
organization_id,
name,
purpose,
@@ -517,8 +502,6 @@ INSERT INTO processing_activities (
) VALUES (
@id,
@tenant_id,
@snapshot_id,
@source_id,
@organization_id,
@name,
@purpose,
@@ -547,8 +530,6 @@ INSERT INTO processing_activities (
args := pgx.StrictNamedArgs{
"id": p.ID,
"tenant_id": scope.GetTenantID(),
"snapshot_id": p.SnapshotID,
"source_id": p.SourceID,
"organization_id": p.OrganizationID,
"name": p.Name,
"purpose": p.Purpose,
@@ -612,7 +593,6 @@ SET
WHERE
%s
AND id = @id
AND snapshot_id IS NULL
`
q = fmt.Sprintf(q, scope.SQLFragment())
@@ -660,7 +640,6 @@ DELETE FROM processing_activities
WHERE
%s
AND id = @id
AND snapshot_id IS NULL
`
q = fmt.Sprintf(q, scope.SQLFragment())

View File

@@ -29,7 +29,6 @@ type (
ProcessingActivityID gid.GID `db:"processing_activity_id"`
ThirdPartyID gid.GID `db:"third_party_id"`
TenantID gid.TenantID `db:"tenant_id"`
SnapshotID *gid.GID `db:"snapshot_id"`
CreatedAt time.Time `db:"created_at"`
}

View File

@@ -214,7 +214,6 @@ WITH rsks AS (
risks_measures rm ON r.id = rm.risk_id
WHERE
rm.measure_id = @measure_id
AND r.snapshot_id IS NULL
)
SELECT
COUNT(id)
@@ -277,7 +276,6 @@ 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,
@@ -338,7 +336,6 @@ 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())
@@ -393,7 +390,6 @@ 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,
@@ -470,7 +466,6 @@ 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())
@@ -649,7 +644,6 @@ SET
updated_at = @updated_at
WHERE %s
AND id = @risk_id
AND snapshot_id IS NULL
RETURNING inherent_risk_score, residual_risk_score
`
q = fmt.Sprintf(q, scope.SQLFragment())
@@ -688,7 +682,7 @@ func (r *Risk) Delete(
riskID gid.GID,
) error {
q := `
DELETE FROM risks WHERE %s AND id = @id AND snapshot_id IS NULL
DELETE FROM risks WHERE %s AND id = @id
`
q = fmt.Sprintf(q, scope.SQLFragment())
@@ -718,7 +712,6 @@ WITH rsks AS (
risks_documents rd ON r.id = rd.risk_id
WHERE
rd.document_id = @document_id
AND r.snapshot_id IS NULL
)
SELECT
COUNT(id)

View File

@@ -85,7 +85,6 @@ FROM
WHERE
%s
AND id = @statement_of_applicability_id
AND snapshot_id IS NULL
LIMIT 1;
`
@@ -132,7 +131,6 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND snapshot_id IS NULL
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
@@ -169,7 +167,6 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND snapshot_id IS NULL
`
q = fmt.Sprintf(q, scope.SQLFragment())
@@ -252,7 +249,6 @@ SET
WHERE
%s
AND id = @statement_of_applicability_id
AND snapshot_id IS NULL
`
q = fmt.Sprintf(q, scope.SQLFragment())
@@ -293,7 +289,6 @@ DELETE FROM statements_of_applicability
WHERE
%s
AND id = @statement_of_applicability_id
AND snapshot_id IS NULL
`
q = fmt.Sprintf(q, scope.SQLFragment())

View File

@@ -446,7 +446,6 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND snapshot_id IS NULL
AND %s
`
@@ -505,7 +504,6 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND snapshot_id IS NULL
ORDER BY name ASC
`
q = fmt.Sprintf(q, scope.SQLFragment())
@@ -568,7 +566,6 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND snapshot_id IS NULL
AND %s
AND %s
`
@@ -1160,7 +1157,6 @@ WITH filtered_processing_activities AS (
WHERE
pa.tenant_id = @tenant_id
AND pa.organization_id = @organization_id
AND pa.snapshot_id IS NULL
),
filtered_third_parties AS (
SELECT
@@ -1170,7 +1166,6 @@ filtered_third_parties AS (
third_parties v
WHERE
v.tenant_id = @tenant_id
AND v.snapshot_id IS NULL
)
SELECT
pav.processing_activity_id,

View File

@@ -22,7 +22,6 @@ import (
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
@@ -89,7 +88,6 @@ FROM
WHERE
%s
AND third_party_id = @third_party_id
AND snapshot_id IS NULL
LIMIT 1;
`
@@ -139,7 +137,6 @@ FROM
WHERE
%s
AND third_party_id = ANY(@third_party_ids)
AND snapshot_id IS NULL
`
q = fmt.Sprintf(q, scope.SQLFragment())
@@ -227,7 +224,6 @@ SET
WHERE
%s
AND id = @id
AND snapshot_id IS NULL
`
q = fmt.Sprintf(q, scope.SQLFragment())
@@ -299,12 +295,6 @@ ON CONFLICT (organization_id, third_party_id) DO UPDATE SET
_, err := conn.Exec(ctx, q, args)
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "third_party_business_associate_agreements_source_id_snapshot_id_key" {
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot upsert thirdParty business associate agreement: %w", err)
}
return nil
@@ -322,7 +312,6 @@ FROM
WHERE
%s
AND id = @id
AND snapshot_id IS NULL
`
q = fmt.Sprintf(q, scope.SQLFragment())
@@ -347,7 +336,6 @@ FROM
WHERE
%s
AND third_party_id = @third_party_id
AND snapshot_id IS NULL
`
q = fmt.Sprintf(q, scope.SQLFragment())

View File

@@ -91,7 +91,6 @@ FROM
WHERE
%s
AND third_party_id = @third_party_id
AND snapshot_id IS NULL
AND %s
`
@@ -143,7 +142,6 @@ FROM
WHERE
%s
AND third_party_id = ANY(@third_party_ids)
AND snapshot_id IS NULL
ORDER BY
third_party_id, report_date DESC
`
@@ -279,7 +277,6 @@ FROM
WHERE
%s
AND id = @id
AND snapshot_id IS NULL
RETURNING report_file_id
`

View File

@@ -144,7 +144,6 @@ FROM
WHERE
%s
AND third_party_id = @third_party_id
AND snapshot_id IS NULL
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
@@ -198,7 +197,6 @@ FROM
WHERE
%s
AND third_party_id = ANY(@third_party_ids)
AND snapshot_id IS NULL
ORDER BY
third_party_id, full_name ASC
`
@@ -299,7 +297,6 @@ SET
WHERE
%s
AND id = @third_party_contact_id
AND snapshot_id IS NULL
`
q = fmt.Sprintf(q, scope.SQLFragment())
@@ -333,7 +330,6 @@ DELETE FROM
WHERE
%s
AND id = @third_party_contact_id
AND snapshot_id IS NULL
`
q = fmt.Sprintf(q, scope.SQLFragment())

View File

@@ -22,7 +22,6 @@ import (
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
@@ -89,7 +88,6 @@ FROM
WHERE
%s
AND third_party_id = @third_party_id
AND snapshot_id IS NULL
LIMIT 1;
`
@@ -139,7 +137,6 @@ FROM
WHERE
%s
AND third_party_id = ANY(@third_party_ids)
AND snapshot_id IS NULL
`
q = fmt.Sprintf(q, scope.SQLFragment())
@@ -227,7 +224,6 @@ SET
WHERE
%s
AND id = @id
AND snapshot_id IS NULL
`
q = fmt.Sprintf(q, scope.SQLFragment())
@@ -299,12 +295,6 @@ ON CONFLICT (organization_id, third_party_id) DO UPDATE SET
_, err := conn.Exec(ctx, q, args)
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "third_party_data_privacy_agreements_source_id_snapshot_id_key" {
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot upsert thirdParty data privacy agreement: %w", err)
}
return nil
@@ -322,7 +312,6 @@ FROM
WHERE
%s
AND id = @id
AND snapshot_id IS NULL
`
q = fmt.Sprintf(q, scope.SQLFragment())

View File

@@ -189,7 +189,6 @@ FROM
WHERE
%s
AND third_party_id = @third_party_id
AND snapshot_id IS NULL
ORDER BY
created_at DESC
LIMIT 1;
@@ -240,7 +239,6 @@ FROM
WHERE
%s
AND third_party_id = @third_party_id
AND snapshot_id IS NULL
AND %s
`
@@ -292,7 +290,6 @@ FROM
WHERE
%s
AND third_party_id = ANY(@third_party_ids)
AND snapshot_id IS NULL
ORDER BY
third_party_id, created_at DESC
`

View File

@@ -135,7 +135,6 @@ FROM
WHERE
%s
AND third_party_id = @third_party_id
AND snapshot_id IS NULL
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
@@ -187,7 +186,6 @@ FROM
WHERE
%s
AND third_party_id = ANY(@third_party_ids)
AND snapshot_id IS NULL
ORDER BY
third_party_id, name ASC
`
@@ -280,7 +278,6 @@ SET
WHERE
%s
AND id = @third_party_service_id
AND snapshot_id IS NULL
`
q = fmt.Sprintf(q, scope.SQLFragment())
@@ -312,7 +309,6 @@ DELETE FROM
WHERE
%s
AND id = @third_party_service_id
AND snapshot_id IS NULL
`
q = fmt.Sprintf(q, scope.SQLFragment())

View File

@@ -138,8 +138,6 @@ WHERE
type (
TransferImpactAssessment struct {
ID gid.GID `db:"id"`
SnapshotID *gid.GID `db:"snapshot_id"`
SourceID *gid.GID `db:"source_id"`
OrganizationID gid.GID `db:"organization_id"`
ProcessingActivityID gid.GID `db:"processing_activity_id"`
DataSubjects *string `db:"data_subjects"`
@@ -192,7 +190,6 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND snapshot_id IS NULL
`
q = fmt.Sprintf(q, scope.SQLFragment())
@@ -221,8 +218,6 @@ func (tias *TransferImpactAssessments) LoadByOrganizationID(
q := `
SELECT
id,
snapshot_id,
source_id,
organization_id,
processing_activity_id,
data_subjects,
@@ -237,7 +232,6 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND snapshot_id IS NULL
AND %s
`
@@ -271,8 +265,6 @@ func (tias *TransferImpactAssessments) LoadAllByOrganizationID(
q := `
SELECT
id,
snapshot_id,
source_id,
organization_id,
processing_activity_id,
data_subjects,
@@ -287,7 +279,6 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND snapshot_id IS NULL
`
q = fmt.Sprintf(q, scope.SQLFragment())
@@ -319,8 +310,6 @@ func (tia *TransferImpactAssessment) LoadByID(
q := `
SELECT
id,
snapshot_id,
source_id,
organization_id,
processing_activity_id,
data_subjects,
@@ -370,8 +359,6 @@ func (tia *TransferImpactAssessment) LoadByProcessingActivityID(
q := `
SELECT
id,
snapshot_id,
source_id,
organization_id,
processing_activity_id,
data_subjects,