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:
@@ -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...)
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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, ¬es); 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...)
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
`
|
||||
|
||||
30
pkg/coredata/migrations/20260506T150405Z.sql
Normal file
30
pkg/coredata/migrations/20260506T150405Z.sql
Normal 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;
|
||||
@@ -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
|
||||
`
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
`
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
`
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user