Assets as document: replace snapshot with publish workflow

Remove assets from the snapshot system and replace with a publish-based
document workflow that generates versioned ProseMirror documents.

- Remove snapshot_id/source_id from asset and asset_vendor models
- Delete AssetFilter (no longer needed without snapshot filtering)
- Add PublishAssetList service, GraphQL mutation, MCP tool, CLI command,
  and n8n operation
- Add asset_list_document_id column to generated_documents table
- Generate ProseMirror documents with asset inventory tables
  (name, type, amount, data types stored, owner, vendors)
- Add AssetListDocument resolver on Organization type
- Update frontend to remove snapshot routes/params and add publish dialog
- Add e2e tests for asset publish (immediate, with approvers, reuse, RBAC)
- Add migration script for converting legacy asset snapshots to documents
- Exclude ASSETS from snapshot type lists and e2e snapshot tests
- Move generated_documents SQL to coredata methods on Datum and Asset
- Clear generated document and SOA references on soft delete and archive

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-04-21 18:40:49 +02:00
parent 565b71526c
commit b603d04d8d
40 changed files with 2315 additions and 486 deletions

View File

@@ -117,12 +117,7 @@ func (r *assetConnectionResolver) TotalCount(ctx context.Context, obj *types.Ass
switch obj.Resolver.(type) {
case *organizationResolver:
assetFilter := coredata.NewAssetFilter(nil)
if obj.Filter != nil {
assetFilter = coredata.NewAssetFilter(&obj.Filter.SnapshotID)
}
count, err := prb.Assets.CountForOrganizationID(ctx, obj.ParentID, assetFilter)
count, err := prb.Assets.CountForOrganizationID(ctx, obj.ParentID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot count assets", log.Error(err))
return 0, gqlutils.Internal(ctx)
@@ -423,6 +418,29 @@ func (r *mutationResolver) PublishDataList(ctx context.Context, input types.Publ
}, nil
}
// PublishAssetList is the resolver for the publishAssetList field.
func (r *mutationResolver) PublishAssetList(ctx context.Context, input types.PublishAssetListInput) (*types.PublishAssetListPayload, error) {
if err := r.authorize(ctx, input.OrganizationID, probo.ActionAssetPublish); err != nil {
return nil, err
}
prb := r.ProboService(ctx, input.OrganizationID.TenantID())
document, documentVersion, err := prb.GeneratedDocuments.PublishAssetList(ctx, input.OrganizationID, input.ApproverIds)
if err != nil {
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
return nil, gqlutils.Conflict(ctx, err)
}
r.logger.ErrorCtx(ctx, "cannot publish asset list", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.PublishAssetListPayload{
DocumentEdge: types.NewDocumentEdge(document, coredata.DocumentOrderFieldCreatedAt),
DocumentVersionEdge: types.NewDocumentVersionEdge(documentVersion, coredata.DocumentVersionOrderFieldCreatedAt),
}, nil
}
// Asset returns schema.AssetResolver implementation.
func (r *Resolver) Asset() schema.AssetResolver { return &assetResolver{r} }

View File

@@ -62,13 +62,8 @@ input DatumOrder
field: DatumOrderField!
}
input AssetFilter {
snapshotId: ID
}
type Asset implements Node {
id: ID!
snapshotId: ID
name: String!
amount: Int!
owner: Profile! @goField(forceResolver: true)
@@ -148,6 +143,9 @@ extend type Mutation {
publishDataList(
input: PublishDataListInput!
): PublishDataListPayload!
publishAssetList(
input: PublishAssetListInput!
): PublishAssetListPayload!
}
input CreateAssetInput {
@@ -227,3 +225,13 @@ type PublishDataListPayload {
documentEdge: DocumentEdge!
documentVersionEdge: DocumentVersionEdge!
}
input PublishAssetListInput {
organizationId: ID!
approverIds: [ID!]
}
type PublishAssetListPayload {
documentEdge: DocumentEdge!
documentVersionEdge: DocumentVersionEdge!
}

View File

@@ -121,13 +121,14 @@ type Organization implements Node {
orderBy: AccessReviewCampaignOrder
): AccessReviewCampaignConnection! @goField(forceResolver: true)
assetListDocument: Document @goField(forceResolver: true)
assets(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: AssetOrder
filter: AssetFilter = { snapshotId: null }
): AssetConnection! @goField(forceResolver: true)
dataListDocument: Document @goField(forceResolver: true)

View File

@@ -3,7 +3,6 @@ enum SnapshotsType
RISKS @goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeRisks")
VENDORS
@goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeVendors")
ASSETS @goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeAssets")
FINDINGS
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeFindings"

View File

@@ -221,8 +221,32 @@ func (r *organizationResolver) AccessReviewCampaigns(ctx context.Context, obj *t
return types.NewAccessReviewCampaignConnection(p, r, obj.ID), nil
}
// AssetListDocument is the resolver for the assetListDocument field.
func (r *organizationResolver) AssetListDocument(ctx context.Context, obj *types.Organization) (*types.Document, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionDocumentGet); err != nil {
return nil, err
}
prb := r.ProboService(ctx, obj.ID.TenantID())
assetDocumentID, err := prb.GeneratedDocuments.GetAssetListDocumentID(ctx, obj.ID)
if err != nil {
return nil, fmt.Errorf("cannot get asset list document ID: %w", err)
}
if assetDocumentID == nil {
return nil, nil
}
doc, err := prb.Documents.Get(ctx, *assetDocumentID)
if err != nil {
return nil, fmt.Errorf("cannot get asset list document: %w", err)
}
return types.NewDocument(doc), nil
}
// Assets is the resolver for the assets field.
func (r *organizationResolver) Assets(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.AssetOrderBy, filter *types.AssetFilter) (*types.AssetConnection, error) {
func (r *organizationResolver) Assets(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.AssetOrderBy) (*types.AssetConnection, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionAssetList); err != nil {
return nil, err
}
@@ -242,18 +266,13 @@ func (r *organizationResolver) Assets(ctx context.Context, obj *types.Organizati
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
assetFilter := coredata.NewAssetFilter(nil)
if filter != nil {
assetFilter = coredata.NewAssetFilter(&filter.SnapshotID)
}
page, err := prb.Assets.ListForOrganizationID(ctx, obj.ID, cursor, assetFilter)
page, err := prb.Assets.ListForOrganizationID(ctx, obj.ID, cursor)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list organization assets", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewAssetConnection(page, r, obj.ID, filter), nil
return types.NewAssetConnection(page, r, obj.ID), nil
}
// DataListDocument is the resolver for the dataListDocument field.

View File

@@ -30,7 +30,6 @@ type (
Resolver any
ParentID gid.GID
Filter *AssetFilter
}
)
@@ -38,7 +37,6 @@ func NewAssetConnection(
p *page.Page[*coredata.Asset, coredata.AssetOrderField],
resolver any,
parentID gid.GID,
filter *AssetFilter,
) *AssetConnection {
edges := make([]*AssetEdge, len(p.Data))
for i, asset := range p.Data {
@@ -51,7 +49,6 @@ func NewAssetConnection(
Resolver: resolver,
ParentID: parentID,
Filter: filter,
}
}
@@ -64,10 +61,9 @@ func NewAssetEdge(asset *coredata.Asset, orderField coredata.AssetOrderField) *A
func NewAsset(asset *coredata.Asset) *Asset {
return &Asset{
ID: asset.ID,
SnapshotID: asset.SnapshotID,
Name: asset.Name,
Amount: asset.Amount,
ID: asset.ID,
Name: asset.Name,
Amount: asset.Amount,
Owner: &Profile{
ID: asset.OwnerID,
},

View File

@@ -559,13 +559,7 @@ func (r *Resolver) ListAssetsTool(ctx context.Context, req *mcp.CallToolRequest,
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
noSnapshot := (*gid.GID)(nil)
assetFilter := coredata.NewAssetFilter(&noSnapshot)
if input.Filter != nil {
assetFilter = coredata.NewAssetFilter(&input.Filter.SnapshotID)
}
page, err := prb.Assets.ListForOrganizationID(ctx, input.OrganizationID, cursor, assetFilter)
page, err := prb.Assets.ListForOrganizationID(ctx, input.OrganizationID, cursor)
if err != nil {
panic(fmt.Errorf("cannot list organization assets: %w", err))
}
@@ -4022,3 +4016,19 @@ func (r *Resolver) PublishDataListTool(ctx context.Context, req *mcp.CallToolReq
DocumentVersionID: documentVersion.ID,
}, nil
}
func (r *Resolver) PublishAssetListTool(ctx context.Context, req *mcp.CallToolRequest, input *types.PublishAssetListInput) (*mcp.CallToolResult, types.PublishAssetListOutput, error) {
r.MustAuthorize(ctx, input.OrganizationID, probo.ActionAssetPublish)
svc := r.ProboService(ctx, input.OrganizationID)
document, documentVersion, err := svc.GeneratedDocuments.PublishAssetList(ctx, input.OrganizationID, input.ApproverIds)
if err != nil {
return nil, types.PublishAssetListOutput{}, fmt.Errorf("cannot publish asset list: %w", err)
}
return nil, types.PublishAssetListOutput{
DocumentID: document.ID,
DocumentVersionID: documentVersion.ID,
}, nil
}

View File

@@ -2003,11 +2003,6 @@ components:
organization_id:
$ref: "#/components/schemas/GID"
description: Organization ID
snapshot_id:
type:
- string
- "null"
description: Snapshot ID
name:
type: string
description: Asset name
@@ -2049,15 +2044,6 @@ components:
cursor:
$ref: "#/components/schemas/CursorKey"
description: Page cursor
filter:
type: object
properties:
snapshot_id:
anyOf:
- $ref: "#/components/schemas/GID"
- type: "null"
description: Filter by snapshot ID. Defaults to null, which returns only assets with no snapshot (current live data). Pass a specific snapshot ID to retrieve assets as they were at that snapshot.
default: null
ListAssetsOutput:
type: object
@@ -4999,7 +4985,6 @@ components:
enum:
- RISKS
- VENDORS
- ASSETS
- NONCONFORMITIES
- OBLIGATIONS
- CONTINUAL_IMPROVEMENTS
@@ -6584,6 +6569,33 @@ components:
$ref: "#/components/schemas/GID"
description: Created document version ID
PublishAssetListInput:
type: object
required:
- organization_id
properties:
organization_id:
$ref: "#/components/schemas/GID"
description: Organization ID
approver_ids:
type: array
items:
$ref: "#/components/schemas/GID"
description: Optional approver profile IDs. If provided, creates a draft pending approval instead of publishing immediately.
PublishAssetListOutput:
type: object
required:
- document_id
- document_version_id
properties:
document_id:
$ref: "#/components/schemas/GID"
description: Created or updated document ID
document_version_id:
$ref: "#/components/schemas/GID"
description: Created document version ID
PublishStatementOfApplicabilityInput:
type: object
required:
@@ -8650,7 +8662,7 @@ tools:
outputSchema:
$ref: "#/components/schemas/GetSnapshotOutput"
- name: takeSnapshot
description: Take a snapshot of a collection of objects (risks, vendors, assets, findings, obligations, or processing activities)
description: Take a snapshot of a collection of objects (risks, vendors, findings, obligations, or processing activities)
hints:
readonly: false
inputSchema:
@@ -8870,6 +8882,14 @@ tools:
$ref: "#/components/schemas/PublishDataListInput"
outputSchema:
$ref: "#/components/schemas/PublishDataListOutput"
- name: publishAssetList
description: Publish the asset list for an organization as a document. If a document already exists, a new version is created.
hints:
readonly: false
inputSchema:
$ref: "#/components/schemas/PublishAssetListInput"
outputSchema:
$ref: "#/components/schemas/PublishAssetListOutput"
- name: publishStatementOfApplicability
description: Publish a statement of applicability as a document. If a document already exists, a new version is created.
hints:

View File

@@ -20,7 +20,7 @@ import (
)
func NewAsset(a *coredata.Asset) *Asset {
asset := &Asset{
return &Asset{
ID: a.ID,
Name: a.Name,
Amount: a.Amount,
@@ -31,13 +31,6 @@ func NewAsset(a *coredata.Asset) *Asset {
CreatedAt: a.CreatedAt,
UpdatedAt: a.UpdatedAt,
}
if a.SnapshotID != nil {
s := a.SnapshotID.String()
asset.SnapshotID = &s
}
return asset
}
func NewListAssetsOutput(assetPage *page.Page[*coredata.Asset, coredata.AssetOrderField]) ListAssetsOutput {