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,
},