diff --git a/apps/console/src/components/assets/ReadOnlyAssetsTable.tsx b/apps/console/src/components/assets/ReadOnlyAssetsTable.tsx
index a87440559..fabf299b2 100644
--- a/apps/console/src/components/assets/ReadOnlyAssetsTable.tsx
+++ b/apps/console/src/components/assets/ReadOnlyAssetsTable.tsx
@@ -16,7 +16,6 @@ import { faviconUrl, getAssetTypeVariant } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { Avatar, Badge, Tbody, Td, Th, Thead, Tr } from "@probo/ui";
import type { usePaginationFragmentHookType } from "react-relay/relay-hooks/usePaginationFragment";
-import { useParams } from "react-router";
import type { OperationType } from "relay-runtime";
import type {
@@ -66,17 +65,10 @@ export function ReadOnlyAssetsTable(props: Props) {
function AssetRow({ entry }: { entry: AssetEntry }) {
const organizationId = useOrganizationId();
const { __ } = useTranslate();
- const { snapshotId } = useParams<{ snapshotId?: string }>();
const vendors = entry.vendors?.edges.map(edge => edge.node) ?? [];
return (
-
| {entry.name} |
diff --git a/apps/console/src/hooks/graph/AssetGraph.ts b/apps/console/src/hooks/graph/AssetGraph.ts
index 727b816f5..88a497619 100644
--- a/apps/console/src/hooks/graph/AssetGraph.ts
+++ b/apps/console/src/hooks/graph/AssetGraph.ts
@@ -18,16 +18,28 @@ import { useConfirm } from "@probo/ui";
import { useMutation } from "react-relay";
import { graphql } from "relay-runtime";
-import { useMutationWithToasts } from "../useMutationWithToasts";
+import type {
+ AssetGraphCreateMutation,
+ AssetType,
+} from "#/__generated__/core/AssetGraphCreateMutation.graphql";
+import type { AssetGraphDeleteMutation } from "#/__generated__/core/AssetGraphDeleteMutation.graphql";
+import type { AssetGraphUpdateMutation } from "#/__generated__/core/AssetGraphUpdateMutation.graphql";
/* eslint-disable relay/unused-fields, relay/must-colocate-fragment-spreads */
export const assetsQuery = graphql`
- query AssetGraphListQuery($organizationId: ID!, $snapshotId: ID) {
+ query AssetGraphListQuery($organizationId: ID!) {
node(id: $organizationId) {
... on Organization {
canCreateAsset: permission(action: "core:asset:create")
- ...AssetsPageFragment @arguments(snapshotId: $snapshotId)
+ canPublishAssets: permission(action: "core:asset:publish")
+ assetListDocument {
+ id
+ defaultApprovers {
+ id
+ }
+ }
+ ...AssetsPageFragment
}
}
}
@@ -38,7 +50,6 @@ export const assetNodeQuery = graphql`
node(id: $assetId) {
... on Asset {
id
- snapshotId
name
amount
assetType
@@ -75,7 +86,6 @@ export const createAssetMutation = graphql`
assetEdge @appendEdge(connections: $connections) {
node {
id
- snapshotId
name
amount
assetType
@@ -107,7 +117,6 @@ export const updateAssetMutation = graphql`
updateAsset(input: $input) {
asset {
id
- snapshotId
name
amount
assetType
@@ -146,8 +155,7 @@ export const useDeleteAsset = (
asset: { id?: string; name?: string },
connectionId: string,
) => {
- // eslint-disable-next-line relay/generated-typescript-types
- const [mutate] = useMutation(deleteAssetMutation);
+ const [mutate] = useMutation(deleteAssetMutation);
const confirm = useConfirm();
const { __ } = useTranslate();
@@ -160,7 +168,7 @@ export const useDeleteAsset = (
promisifyMutation(mutate)({
variables: {
input: {
- assetId: asset.id,
+ assetId: asset.id!,
},
connections: [connectionId],
},
@@ -178,15 +186,14 @@ export const useDeleteAsset = (
};
export const useCreateAsset = (connectionId: string) => {
- // eslint-disable-next-line relay/generated-typescript-types
- const [mutate, isMutating] = useMutation(createAssetMutation);
+ const [mutate, isMutating] = useMutation(createAssetMutation);
const { __ } = useTranslate();
return [
(input: {
name: string;
amount: number;
- assetType: string;
+ assetType: AssetType;
ownerId: string;
organizationId: string;
vendorIds?: string[];
@@ -228,16 +235,13 @@ export const useCreateAsset = (connectionId: string) => {
export const useUpdateAsset = () => {
const { __ } = useTranslate();
- const [mutate] = useMutationWithToasts(updateAssetMutation, {
- successMessage: __("Asset updated successfully"),
- errorMessage: __("Failed to update asset"),
- });
+ const [mutate] = useMutation(updateAssetMutation);
return (input: {
id: string;
name?: string;
amount?: number;
- assetType?: string;
+ assetType?: AssetType;
dataTypesStored?: string;
ownerId?: string;
vendorIds?: string[];
@@ -246,7 +250,7 @@ export const useUpdateAsset = () => {
return alert(__("Failed to update asset: asset ID is required"));
}
- return mutate({
+ return promisifyMutation(mutate)({
variables: {
input,
},
diff --git a/apps/console/src/pages/organizations/assets/AssetDetailsPage.tsx b/apps/console/src/pages/organizations/assets/AssetDetailsPage.tsx
index 99039d94a..07f41736b 100644
--- a/apps/console/src/pages/organizations/assets/AssetDetailsPage.tsx
+++ b/apps/console/src/pages/organizations/assets/AssetDetailsPage.tsx
@@ -12,10 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
-import {
- getAssetTypeVariant,
- validateSnapshotConsistency,
-} from "@probo/helpers";
+import { getAssetTypeVariant } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
ActionDropdown,
@@ -32,14 +29,12 @@ import {
type PreloadedQuery,
usePreloadedQuery,
} from "react-relay";
-import { useParams } from "react-router";
import { z } from "zod";
import type { AssetGraphNodeQuery } from "#/__generated__/core/AssetGraphNodeQuery.graphql";
import { ControlledField } from "#/components/form/ControlledField";
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
import { VendorsMultiSelectField } from "#/components/form/VendorsMultiSelectField";
-import { SnapshotBanner } from "#/components/SnapshotBanner";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useOrganizationId } from "#/hooks/useOrganizationId";
@@ -70,15 +65,10 @@ export default function AssetDetailsPage(props: Props) {
const assetEntry = asset.node;
const { __ } = useTranslate();
const organizationId = useOrganizationId();
- const { snapshotId } = useParams<{ snapshotId?: string }>();
- const isSnapshotMode = Boolean(snapshotId);
-
- validateSnapshotConsistency(assetEntry, snapshotId);
const connectionId = ConnectionHandler.getConnectionID(
organizationId,
"AssetsPage_assets",
- { filter: { snapshotId: snapshotId || null } },
);
const deleteAsset = useDeleteAsset(assetEntry, connectionId);
@@ -107,25 +97,19 @@ export default function AssetDetailsPage(props: Props) {
reset(formData);
});
- const breadcrumbAssetsUrl
- = isSnapshotMode && snapshotId
- ? `/organizations/${organizationId}/snapshots/${snapshotId}/assets`
- : `/organizations/${organizationId}/assets`;
+ const breadcrumbItems = [
+ {
+ label: __("Assets"),
+ to: `/organizations/${organizationId}/assets`,
+ },
+ {
+ label: assetEntry?.name ?? "",
+ },
+ ];
return (
- {snapshotId && }
-
+
@@ -138,7 +122,7 @@ export default function AssetDetailsPage(props: Props) {
: __("Virtual")}
- {!isSnapshotMode && asset.node.canDelete && (
+ {asset.node.canDelete && (
@@ -181,7 +165,7 @@ export default function AssetDetailsPage(props: Props) {
label={__("Data Types Stored")}
{...register("dataTypesStored")}
type="text"
- disabled={isSnapshotMode}
+ disabled={!assetEntry.canUpdate}
/>
- {formState.isDirty && !isSnapshotMode && asset.node.canUpdate && (
+ {formState.isDirty && assetEntry.canUpdate && (
diff --git a/apps/console/src/pages/organizations/assets/AssetsPage.tsx b/apps/console/src/pages/organizations/assets/AssetsPage.tsx
index 4c543db2c..a6d57bcea 100644
--- a/apps/console/src/pages/organizations/assets/AssetsPage.tsx
+++ b/apps/console/src/pages/organizations/assets/AssetsPage.tsx
@@ -14,19 +14,24 @@
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
-import { Button, IconPlusLarge, PageHeader } from "@probo/ui";
+import {
+ Button,
+ IconPageTextLine,
+ IconPlusLarge,
+ IconUpload,
+ PageHeader,
+} from "@probo/ui";
import {
graphql,
type PreloadedQuery,
usePaginationFragment,
usePreloadedQuery,
} from "react-relay";
-import { useParams } from "react-router";
+import { Link, useNavigate } from "react-router";
import type { AssetGraphListQuery } from "#/__generated__/core/AssetGraphListQuery.graphql";
import type { AssetsListQuery } from "#/__generated__/core/AssetsListQuery.graphql";
import type { AssetsPageFragment$key } from "#/__generated__/core/AssetsPageFragment.graphql";
-import { SnapshotBanner } from "#/components/SnapshotBanner";
import { assetsQuery } from "#/hooks/graph/AssetGraph";
import { useOrganizationId } from "#/hooks/useOrganizationId";
@@ -34,6 +39,7 @@ import { AssetsTable } from "../../../components/assets/AssetsTable";
import { ReadOnlyAssetsTable } from "../../../components/assets/ReadOnlyAssetsTable";
import { CreateAssetDialog } from "./dialogs/CreateAssetDialog";
+import { PublishAssetListDialog } from "./dialogs/PublishAssetListDialog";
const paginatedAssetsFragment = graphql`
fragment AssetsPageFragment on Organization
@@ -44,7 +50,6 @@ const paginatedAssetsFragment = graphql`
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
- snapshotId: { type: "ID", defaultValue: null }
) {
assets(
first: $first
@@ -52,14 +57,12 @@ const paginatedAssetsFragment = graphql`
last: $last
before: $before
orderBy: $orderBy
- filter: { snapshotId: $snapshotId }
- ) @connection(key: "AssetsPage_assets", filters: ["filter"]) {
+ ) @connection(key: "AssetsPage_assets") {
__id
edges {
node {
# eslint-disable-next-line relay/unused-fields
id
- snapshotId
# eslint-disable-next-line relay/unused-fields
name
# eslint-disable-next-line relay/unused-fields
@@ -102,8 +105,7 @@ type Props = {
export default function AssetsPage(props: Props) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
- const { snapshotId } = useParams<{ snapshotId?: string }>();
- const isSnapshotMode = Boolean(snapshotId);
+ const navigate = useNavigate();
const data = usePreloadedQuery (
assetsQuery,
@@ -115,29 +117,56 @@ export default function AssetsPage(props: Props) {
);
const assets = pagination.data.assets?.edges.map(edge => edge.node);
const connectionId = pagination.data.assets.__id;
+ const defaultApproverIds = (data.node.assetListDocument?.defaultApprovers ?? []).map(a => a.id);
const canWrite = assets.some(asset => asset.canDelete || asset.canUpdate);
usePageTitle(__("Assets"));
return (
- {snapshotId && }
- {!isSnapshotMode && data.node.canCreateAsset && (
-
-
-
- )}
+
+ {data.node.assetListDocument?.id && (
+
+ )}
+ {data.node.canPublishAssets && (
+ {
+ void navigate(
+ `/organizations/${organizationId}/documents/${documentId}`,
+ );
+ }}
+ >
+
+
+ )}
+ {data.node.canCreateAsset && (
+
+
+
+ )}
+
- {isSnapshotMode || !canWrite
+ {!canWrite
? (
)
diff --git a/apps/console/src/pages/organizations/assets/dialogs/PublishAssetListDialog.tsx b/apps/console/src/pages/organizations/assets/dialogs/PublishAssetListDialog.tsx
new file mode 100644
index 000000000..84d06531d
--- /dev/null
+++ b/apps/console/src/pages/organizations/assets/dialogs/PublishAssetListDialog.tsx
@@ -0,0 +1,159 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// 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.
+
+import { formatError, type GraphQLError } from "@probo/helpers";
+import { useTranslate } from "@probo/i18n";
+import {
+ Button,
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ IconSend,
+ IconUpload,
+ useDialogRef,
+ useToast,
+} from "@probo/ui";
+import type { ReactNode } from "react";
+import { useMemo } from "react";
+import { useMutation } from "react-relay";
+import { graphql } from "relay-runtime";
+import { z } from "zod";
+
+import type { PublishAssetListDialogMutation } from "#/__generated__/core/PublishAssetListDialogMutation.graphql";
+import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField";
+import { useFormWithSchema } from "#/hooks/useFormWithSchema";
+
+const publishMutation = graphql`
+ mutation PublishAssetListDialogMutation(
+ $input: PublishAssetListInput!
+ ) {
+ publishAssetList(input: $input) {
+ documentEdge {
+ node {
+ id
+ }
+ }
+ }
+ }
+`;
+
+interface PublishAssetListDialogProps {
+ children: ReactNode;
+ organizationId: string;
+ defaultApproverIds?: string[];
+ onPublished?: (documentId: string) => void;
+}
+
+export function PublishAssetListDialog({
+ children,
+ organizationId,
+ defaultApproverIds,
+ onPublished,
+}: PublishAssetListDialogProps) {
+ const { __ } = useTranslate();
+ const { toast } = useToast();
+ const dialogRef = useDialogRef();
+
+ const schema = useMemo(() => z.object({
+ approverIds: z.array(z.string()),
+ }), []);
+
+ const {
+ control,
+ handleSubmit,
+ reset,
+ watch,
+ } = useFormWithSchema(schema, {
+ defaultValues: {
+ approverIds: defaultApproverIds ?? [],
+ },
+ });
+
+ const [publish, isPublishing]
+ = useMutation(publishMutation);
+
+ const approverIds = watch("approverIds");
+ const hasApprovers = approverIds.length > 0;
+
+ const onSubmit = (data: z.infer) => {
+ publish({
+ variables: {
+ input: {
+ organizationId,
+ approverIds: data.approverIds.length > 0 ? data.approverIds : undefined,
+ },
+ },
+ onCompleted(response) {
+ const documentId = response.publishAssetList?.documentEdge?.node?.id;
+ if (documentId) {
+ toast({
+ title: __("Success"),
+ description: hasApprovers
+ ? __("Approval requested successfully.")
+ : __("Asset list published successfully."),
+ variant: "success",
+ });
+ dialogRef.current?.close();
+ reset();
+ onPublished?.(documentId);
+ }
+ },
+ onError(error) {
+ toast({
+ title: __("Error"),
+ description: formatError(
+ __("Failed to publish asset list"),
+ error as GraphQLError,
+ ),
+ variant: "error",
+ });
+ },
+ });
+ };
+
+ return (
+
+ );
+}
diff --git a/apps/console/src/routes/assetRoutes.ts b/apps/console/src/routes/assetRoutes.ts
index 902c9f6ed..2ec843ea1 100644
--- a/apps/console/src/routes/assetRoutes.ts
+++ b/apps/console/src/routes/assetRoutes.ts
@@ -33,21 +33,7 @@ export const assetRoutes = [
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery(coreEnvironment, assetsQuery, {
- organizationId: organizationId,
- snapshotId: null,
- }),
- ),
- Component: withQueryRef(
- lazy(() => import("#/pages/organizations/assets/AssetsPage")),
- ),
- },
- {
- path: "snapshots/:snapshotId/assets",
- Fallback: PageSkeleton,
- loader: loaderFromQueryLoader(({ organizationId, snapshotId }) =>
- loadQuery(coreEnvironment, assetsQuery, {
- organizationId: organizationId,
- snapshotId: snapshotId,
+ organizationId,
}),
),
Component: withQueryRef(
@@ -66,16 +52,4 @@ export const assetRoutes = [
lazy(() => import("#/pages/organizations/assets/AssetDetailsPage")),
),
},
- {
- path: "snapshots/:snapshotId/assets/:assetId",
- Fallback: PageSkeleton,
- loader: loaderFromQueryLoader(({ assetId }) =>
- loadQuery(coreEnvironment, assetNodeQuery, {
- assetId,
- }),
- ),
- Component: withQueryRef(
- lazy(() => import("#/pages/organizations/assets/AssetDetailsPage")),
- ),
- },
] satisfies AppRoute[];
diff --git a/cmd/migrate-asset-snapshots-to-documents/main.go b/cmd/migrate-asset-snapshots-to-documents/main.go
new file mode 100644
index 000000000..5dd34b72e
--- /dev/null
+++ b/cmd/migrate-asset-snapshots-to-documents/main.go
@@ -0,0 +1,434 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// 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/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": "Asset List",
+ "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
+ }
+
+ vendorRows, err := tx.Query(
+ ctx,
+ `
+SELECT
+ av.asset_id,
+ v.name
+FROM asset_vendors av
+JOIN vendors v ON v.id = av.vendor_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 vendors: %w", err)
+ }
+ defer vendorRows.Close()
+
+ vendorsByAsset := make(map[string][]string)
+ for vendorRows.Next() {
+ var assetID, vendorName string
+ if err := vendorRows.Scan(&assetID, &vendorName); err != nil {
+ return "", fmt.Errorf("cannot scan vendor: %w", err)
+ }
+ vendorsByAsset[assetID] = append(vendorsByAsset[assetID], vendorName)
+ }
+ if err := vendorRows.Err(); err != nil {
+ return "", err
+ }
+
+ assetRows := make([]docgen.AssetListRow, len(assets))
+ for i, a := range assets {
+ vendors := "-"
+ if v, ok := vendorsByAsset[a.id]; ok && len(v) > 0 {
+ vendors = strings.Join(v, ", ")
+ }
+
+ assetRows[i] = docgen.AssetListRow{
+ Name: a.name,
+ AssetType: formatAssetTypeString(a.assetType),
+ Amount: a.amount,
+ DataTypesStored: a.dataTypesStored,
+ Owner: a.ownerName,
+ Vendors: vendors,
+ }
+ }
+
+ docData := docgen.AssetListData{
+ Title: "Asset List",
+ 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 != "" {
+ opts = append(opts, pg.WithAddr(u.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...)
+}
diff --git a/contrib/claude/coredata.md b/contrib/claude/coredata.md
index 7a18f19a9..80bc5c6b3 100644
--- a/contrib/claude/coredata.md
+++ b/contrib/claude/coredata.md
@@ -155,32 +155,30 @@ Map `pgx.ErrNoRows` to `ErrResourceNotFound`. Check unique constraint violations
Filters implement `SQLFragment() string` and `SQLArguments() pgx.NamedArgs`. Use double pointers for three-state filtering: `nil` = no filter, `*nil` = IS NULL, `*val` = equals.
```go
-type AssetFilter struct {
- snapshotID **gid.GID
+type CookieBannerFilter struct {
+ state *CookieBannerState
}
-func NewAssetFilter(snapshotID **gid.GID) *AssetFilter {
- return &AssetFilter{snapshotID: snapshotID}
+func NewCookieBannerFilter(state *CookieBannerState) *CookieBannerFilter {
+ return &CookieBannerFilter{state: state}
}
-func (f *AssetFilter) SQLFragment() string {
- if f.snapshotID == nil {
- return "TRUE"
- }
-
- if *f.snapshotID == nil {
- return "snapshot_id IS NULL"
- }
-
- return "snapshot_id = @filter_snapshot_id"
+func (f *CookieBannerFilter) SQLFragment() string {
+ return `(
+ CASE
+ WHEN @filter_state::text IS NOT NULL THEN
+ state = @filter_state::cookie_banner_state
+ ELSE TRUE
+ END
+ )`
}
-func (f *AssetFilter) SQLArguments() pgx.NamedArgs {
- if f.snapshotID == nil || *f.snapshotID == nil {
- return pgx.NamedArgs{}
+func (f *CookieBannerFilter) SQLArguments() pgx.StrictNamedArgs {
+ args := pgx.StrictNamedArgs{"filter_state": nil}
+ if f.state != nil {
+ args["filter_state"] = string(*f.state)
}
-
- return pgx.NamedArgs{"filter_snapshot_id": **f.snapshotID}
+ return args
}
```
diff --git a/e2e/console/asset_publish_test.go b/e2e/console/asset_publish_test.go
new file mode 100644
index 000000000..b1634cbe8
--- /dev/null
+++ b/e2e/console/asset_publish_test.go
@@ -0,0 +1,406 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+package console_test
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.probo.inc/probo/e2e/internal/factory"
+ "go.probo.inc/probo/e2e/internal/testutil"
+)
+
+func TestAsset_PublishAssetList(t *testing.T) {
+ t.Parallel()
+
+ owner := testutil.NewClient(t, testutil.RoleOwner)
+
+ t.Run(
+ "publish without approvers publishes immediately",
+ func(t *testing.T) {
+ t.Parallel()
+
+ owner := testutil.NewClient(t, testutil.RoleOwner)
+ profileID := factory.CreateUser(owner)
+
+ createAssetForPublish(t, owner, profileID, "Test Asset")
+
+ const query = `
+ mutation($input: PublishAssetListInput!) {
+ publishAssetList(input: $input) {
+ documentEdge {
+ node {
+ id
+ writeMode
+ status
+ }
+ }
+ documentVersionEdge {
+ node {
+ id
+ title
+ documentType
+ status
+ major
+ minor
+ content
+ }
+ }
+ }
+ }
+ `
+
+ var result struct {
+ PublishAssetList struct {
+ DocumentEdge struct {
+ Node struct {
+ ID string `json:"id"`
+ WriteMode string `json:"writeMode"`
+ Status string `json:"status"`
+ } `json:"node"`
+ } `json:"documentEdge"`
+ DocumentVersionEdge struct {
+ Node struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ DocumentType string `json:"documentType"`
+ Status string `json:"status"`
+ Major int `json:"major"`
+ Minor int `json:"minor"`
+ Content string `json:"content"`
+ } `json:"node"`
+ } `json:"documentVersionEdge"`
+ } `json:"publishAssetList"`
+ }
+
+ err := owner.Execute(
+ query,
+ map[string]any{
+ "input": map[string]any{
+ "organizationId": owner.GetOrganizationID(),
+ },
+ },
+ &result,
+ )
+
+ require.NoError(t, err)
+
+ doc := result.PublishAssetList.DocumentEdge.Node
+ assert.NotEmpty(t, doc.ID)
+ assert.Equal(t, "GENERATED", doc.WriteMode)
+ assert.Equal(t, "ACTIVE", doc.Status)
+
+ ver := result.PublishAssetList.DocumentVersionEdge.Node
+ assert.NotEmpty(t, ver.ID)
+ assert.Equal(t, "REGISTER", ver.DocumentType)
+ assert.Equal(t, "PUBLISHED", ver.Status)
+ assert.Equal(t, 1, ver.Major)
+ assert.Equal(t, 0, ver.Minor)
+ assert.Contains(t, ver.Content, "Purpose")
+ assert.Contains(t, ver.Content, "Test Asset")
+ },
+ )
+
+ t.Run(
+ "publish with approvers creates draft with quorum",
+ func(t *testing.T) {
+ t.Parallel()
+
+ const query = `
+ mutation($input: PublishAssetListInput!) {
+ publishAssetList(input: $input) {
+ documentEdge {
+ node {
+ id
+ writeMode
+ }
+ }
+ documentVersionEdge {
+ node {
+ id
+ status
+ major
+ }
+ }
+ }
+ }
+ `
+
+ var result struct {
+ PublishAssetList struct {
+ DocumentEdge struct {
+ Node struct {
+ ID string `json:"id"`
+ WriteMode string `json:"writeMode"`
+ } `json:"node"`
+ } `json:"documentEdge"`
+ DocumentVersionEdge struct {
+ Node struct {
+ ID string `json:"id"`
+ Status string `json:"status"`
+ Major int `json:"major"`
+ } `json:"node"`
+ } `json:"documentVersionEdge"`
+ } `json:"publishAssetList"`
+ }
+
+ err := owner.Execute(
+ query,
+ map[string]any{
+ "input": map[string]any{
+ "organizationId": owner.GetOrganizationID(),
+ "approverIds": []string{owner.GetProfileID().String()},
+ },
+ },
+ &result,
+ )
+
+ require.NoError(t, err)
+
+ doc := result.PublishAssetList.DocumentEdge.Node
+ assert.NotEmpty(t, doc.ID)
+ assert.Equal(t, "GENERATED", doc.WriteMode)
+
+ ver := result.PublishAssetList.DocumentVersionEdge.Node
+ assert.NotEmpty(t, ver.ID)
+ assert.Equal(t, "PENDING_APPROVAL", ver.Status)
+ },
+ )
+
+ t.Run(
+ "creating second document reuses existing document",
+ func(t *testing.T) {
+ t.Parallel()
+
+ secondOwner := testutil.NewClient(t, testutil.RoleOwner)
+ profileID := factory.CreateUser(secondOwner)
+
+ createAssetForPublish(t, secondOwner, profileID, "Reuse Test Asset")
+
+ const query = `
+ mutation($input: PublishAssetListInput!) {
+ publishAssetList(input: $input) {
+ documentEdge {
+ node { id }
+ }
+ documentVersionEdge {
+ node { id major }
+ }
+ }
+ }
+ `
+
+ var result1, result2 struct {
+ PublishAssetList struct {
+ DocumentEdge struct {
+ Node struct {
+ ID string `json:"id"`
+ } `json:"node"`
+ } `json:"documentEdge"`
+ DocumentVersionEdge struct {
+ Node struct {
+ ID string `json:"id"`
+ Major int `json:"major"`
+ } `json:"node"`
+ } `json:"documentVersionEdge"`
+ } `json:"publishAssetList"`
+ }
+
+ input := map[string]any{
+ "input": map[string]any{
+ "organizationId": secondOwner.GetOrganizationID(),
+ },
+ }
+
+ err := secondOwner.Execute(query, input, &result1)
+ require.NoError(t, err)
+
+ err = secondOwner.Execute(query, input, &result2)
+ require.NoError(t, err)
+
+ doc1 := result1.PublishAssetList.DocumentEdge.Node.ID
+ doc2 := result2.PublishAssetList.DocumentEdge.Node.ID
+ assert.Equal(t, doc1, doc2, "should reuse same document")
+
+ ver1Major := result1.PublishAssetList.DocumentVersionEdge.Node.Major
+ ver2Major := result2.PublishAssetList.DocumentVersionEdge.Node.Major
+ assert.Equal(t, 1, ver1Major)
+ assert.Equal(t, 2, ver2Major)
+ },
+ )
+
+ t.Run(
+ "document linked back to organization",
+ func(t *testing.T) {
+ t.Parallel()
+
+ thirdOwner := testutil.NewClient(t, testutil.RoleOwner)
+ profileID := factory.CreateUser(thirdOwner)
+
+ createAssetForPublish(t, thirdOwner, profileID, "Link Test Asset")
+
+ const publishQuery = `
+ mutation($input: PublishAssetListInput!) {
+ publishAssetList(input: $input) {
+ documentEdge {
+ node { id }
+ }
+ documentVersionEdge {
+ node { id }
+ }
+ }
+ }
+ `
+
+ var publishResult struct {
+ PublishAssetList struct {
+ DocumentEdge struct {
+ Node struct {
+ ID string `json:"id"`
+ } `json:"node"`
+ } `json:"documentEdge"`
+ DocumentVersionEdge struct {
+ Node struct {
+ ID string `json:"id"`
+ } `json:"node"`
+ } `json:"documentVersionEdge"`
+ } `json:"publishAssetList"`
+ }
+
+ err := thirdOwner.Execute(
+ publishQuery,
+ map[string]any{
+ "input": map[string]any{
+ "organizationId": thirdOwner.GetOrganizationID(),
+ },
+ },
+ &publishResult,
+ )
+ require.NoError(t, err)
+
+ docID := publishResult.PublishAssetList.DocumentEdge.Node.ID
+
+ const orgQuery = `
+ query($id: ID!) {
+ node(id: $id) {
+ ... on Organization {
+ id
+ assetListDocument { id }
+ }
+ }
+ }
+ `
+
+ var orgResult struct {
+ Node struct {
+ ID string `json:"id"`
+ AssetListDocument *struct {
+ ID string `json:"id"`
+ } `json:"assetListDocument"`
+ } `json:"node"`
+ }
+
+ err = thirdOwner.Execute(
+ orgQuery,
+ map[string]any{"id": thirdOwner.GetOrganizationID()},
+ &orgResult,
+ )
+ require.NoError(t, err)
+ require.NotNil(t, orgResult.Node.AssetListDocument)
+ assert.Equal(t, docID, orgResult.Node.AssetListDocument.ID)
+ },
+ )
+}
+
+func TestAsset_PublishAssetList_RBAC(t *testing.T) {
+ t.Parallel()
+
+ owner := testutil.NewClient(t, testutil.RoleOwner)
+ viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
+
+ profileID := factory.CreateUser(owner)
+ createAssetForPublish(t, owner, profileID, "RBAC Test Asset")
+
+ const query = `
+ mutation($input: PublishAssetListInput!) {
+ publishAssetList(input: $input) {
+ documentEdge {
+ node { id }
+ }
+ documentVersionEdge {
+ node { id }
+ }
+ }
+ }
+ `
+
+ t.Run(
+ "viewer cannot publish asset list",
+ func(t *testing.T) {
+ t.Parallel()
+
+ err := viewer.ExecuteShouldFail(
+ query,
+ map[string]any{
+ "input": map[string]any{
+ "organizationId": owner.GetOrganizationID(),
+ },
+ },
+ )
+ testutil.RequireForbiddenError(t, err)
+ },
+ )
+}
+
+func createAssetForPublish(t *testing.T, client *testutil.Client, ownerProfileID string, name string) string {
+ t.Helper()
+
+ const query = `
+ mutation($input: CreateAssetInput!) {
+ createAsset(input: $input) {
+ assetEdge {
+ node {
+ id
+ }
+ }
+ }
+ }
+ `
+
+ var result struct {
+ CreateAsset struct {
+ AssetEdge struct {
+ Node struct {
+ ID string `json:"id"`
+ } `json:"node"`
+ } `json:"assetEdge"`
+ } `json:"createAsset"`
+ }
+
+ err := client.Execute(query, map[string]any{
+ "input": map[string]any{
+ "organizationId": client.GetOrganizationID().String(),
+ "name": name,
+ "amount": 1,
+ "ownerId": ownerProfileID,
+ "assetType": "VIRTUAL",
+ "dataTypesStored": "Test data types",
+ },
+ }, &result)
+ require.NoError(t, err)
+
+ return result.CreateAsset.AssetEdge.Node.ID
+}
diff --git a/e2e/console/snapshot_test.go b/e2e/console/snapshot_test.go
index 4b3525eb2..ed52a5081 100644
--- a/e2e/console/snapshot_test.go
+++ b/e2e/console/snapshot_test.go
@@ -104,7 +104,7 @@ func TestSnapshot_Delete(t *testing.T) {
"input": map[string]any{
"organizationId": owner.GetOrganizationID().String(),
"name": fmt.Sprintf("Snapshot to Delete %d", time.Now().UnixNano()),
- "type": "ASSETS",
+ "type": "VENDORS",
},
}, &createResult)
require.NoError(t, err)
@@ -140,7 +140,7 @@ func TestSnapshot_List(t *testing.T) {
owner := testutil.NewClient(t, testutil.RoleOwner)
// Create multiple snapshots
- snapshotTypes := []string{"RISKS", "VENDORS", "ASSETS"}
+ snapshotTypes := []string{"RISKS", "VENDORS"}
for i, snapshotType := range snapshotTypes {
query := `
mutation CreateSnapshot($input: CreateSnapshotInput!) {
@@ -219,7 +219,7 @@ func TestSnapshot_Types(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
- snapshotTypes := []string{"RISKS", "VENDORS", "ASSETS"}
+ snapshotTypes := []string{"RISKS", "VENDORS"}
for _, snapshotType := range snapshotTypes {
t.Run(snapshotType, func(t *testing.T) {
diff --git a/packages/helpers/src/snapshots.ts b/packages/helpers/src/snapshots.ts
index de686269c..f96d76ba9 100644
--- a/packages/helpers/src/snapshots.ts
+++ b/packages/helpers/src/snapshots.ts
@@ -17,7 +17,6 @@ type Translator = (s: string) => string;
export const snapshotTypes = [
"RISKS",
"VENDORS",
- "ASSETS",
"FINDINGS",
"OBLIGATIONS",
"PROCESSING_ACTIVITIES",
@@ -33,8 +32,6 @@ export function getSnapshotTypeLabel(__: Translator, type: string | null | undef
return __("Risks");
case "VENDORS":
return __("Vendors");
- case "ASSETS":
- return __("Assets");
case "FINDINGS":
case "NONCONFORMITIES":
case "CONTINUAL_IMPROVEMENTS":
@@ -54,8 +51,6 @@ export function getSnapshotTypeUrlPath(type?: string): string {
return "/risks";
case "VENDORS":
return "/vendors";
- case "ASSETS":
- return "/assets";
case "FINDINGS":
case "NONCONFORMITIES":
case "CONTINUAL_IMPROVEMENTS":
diff --git a/packages/n8n-node/nodes/Probo/actions/asset/index.ts b/packages/n8n-node/nodes/Probo/actions/asset/index.ts
index 142d29819..4ef5c77d5 100644
--- a/packages/n8n-node/nodes/Probo/actions/asset/index.ts
+++ b/packages/n8n-node/nodes/Probo/actions/asset/index.ts
@@ -18,6 +18,7 @@ import * as updateOp from './update.operation';
import * as deleteOp from './delete.operation';
import * as getOp from './get.operation';
import * as getAllOp from './getAll.operation';
+import * as publishOp from './publish.operation';
export const description: INodeProperties[] = [
{
@@ -55,6 +56,12 @@ export const description: INodeProperties[] = [
description: 'Get many assets',
action: 'Get many assets',
},
+ {
+ name: 'Publish',
+ value: 'publish',
+ description: 'Publish the asset list as a document',
+ action: 'Publish the asset list',
+ },
{
name: 'Update',
value: 'update',
@@ -69,6 +76,7 @@ export const description: INodeProperties[] = [
...deleteOp.description,
...getOp.description,
...getAllOp.description,
+ ...publishOp.description,
];
-export { createOp as create, updateOp as update, deleteOp as delete, getOp as get, getAllOp as getAll };
+export { createOp as create, updateOp as update, deleteOp as delete, getOp as get, getAllOp as getAll, publishOp as publish };
diff --git a/packages/n8n-node/nodes/Probo/actions/asset/publish.operation.ts b/packages/n8n-node/nodes/Probo/actions/asset/publish.operation.ts
new file mode 100644
index 000000000..d70cef013
--- /dev/null
+++ b/packages/n8n-node/nodes/Probo/actions/asset/publish.operation.ts
@@ -0,0 +1,101 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// 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.
+
+import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
+import { proboApiRequest } from '../../GenericFunctions';
+
+export const description: INodeProperties[] = [
+ {
+ displayName: 'Organization ID',
+ name: 'organizationId',
+ type: 'string',
+ displayOptions: {
+ show: {
+ resource: ['asset'],
+ operation: ['publish'],
+ },
+ },
+ default: '',
+ description: 'The ID of the organization whose asset list to publish',
+ required: true,
+ },
+ {
+ displayName: 'Approver IDs',
+ name: 'approverIds',
+ type: 'string',
+ displayOptions: {
+ show: {
+ resource: ['asset'],
+ operation: ['publish'],
+ },
+ },
+ default: '',
+ description: 'Comma-separated list of approver profile IDs',
+ },
+];
+
+export async function execute(
+ this: IExecuteFunctions,
+ itemIndex: number,
+): Promise {
+ const organizationId = this.getNodeParameter('organizationId', itemIndex) as string;
+ const approverIds = this.getNodeParameter('approverIds', itemIndex, '') as string;
+
+ const query = `
+ mutation PublishAssetList($input: PublishAssetListInput!) {
+ publishAssetList(input: $input) {
+ documentEdge {
+ node {
+ id
+ status
+ currentPublishedMajor
+ currentPublishedMinor
+ createdAt
+ updatedAt
+ }
+ }
+ documentVersionEdge {
+ node {
+ id
+ title
+ major
+ minor
+ status
+ classification
+ documentType
+ publishedAt
+ createdAt
+ updatedAt
+ }
+ }
+ }
+ }
+ `;
+
+ const input: Record = { organizationId };
+
+ if (approverIds) {
+ input.approverIds = approverIds
+ .split(',')
+ .map(id => id.trim())
+ .filter(Boolean);
+ }
+
+ const responseData = await proboApiRequest.call(this, query, { input });
+
+ return {
+ json: responseData,
+ pairedItem: { item: itemIndex },
+ };
+}
diff --git a/pkg/coredata/asset_filter.go b/pkg/cmd/asset/asset.go
similarity index 51%
rename from pkg/coredata/asset_filter.go
rename to pkg/cmd/asset/asset.go
index 908dce6c6..b11522a92 100644
--- a/pkg/coredata/asset_filter.go
+++ b/pkg/cmd/asset/asset.go
@@ -1,4 +1,4 @@
-// Copyright (c) 2025-2026 Probo Inc .
+// Copyright (c) 2026 Probo Inc .
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
@@ -12,43 +12,21 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
-package coredata
+package asset
import (
- "github.com/jackc/pgx/v5"
- "go.probo.inc/probo/pkg/gid"
+ "github.com/spf13/cobra"
+ "go.probo.inc/probo/pkg/cmd/asset/publish"
+ "go.probo.inc/probo/pkg/cmd/cmdutil"
)
-type (
- AssetFilter struct {
- snapshotID **gid.GID
+func NewCmdAsset(f *cmdutil.Factory) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "asset ",
+ Short: "Manage assets",
}
-)
-func NewAssetFilter(snapshotID **gid.GID) *AssetFilter {
- return &AssetFilter{
- snapshotID: snapshotID,
- }
-}
-
-func (f *AssetFilter) SQLArguments() pgx.NamedArgs {
- args := pgx.NamedArgs{}
-
- if f.snapshotID != nil && *f.snapshotID != nil {
- args["filter_snapshot_id"] = **f.snapshotID
- }
-
- return args
-}
-
-func (f *AssetFilter) SQLFragment() string {
- if f.snapshotID == nil {
- return "TRUE"
- }
-
- if *f.snapshotID == nil {
- return "snapshot_id IS NULL"
- } else {
- return "snapshot_id = @filter_snapshot_id"
- }
+ cmd.AddCommand(publish.NewCmdPublish(f))
+
+ return cmd
}
diff --git a/pkg/cmd/asset/publish/publish.go b/pkg/cmd/asset/publish/publish.go
new file mode 100644
index 000000000..8c6b99bb3
--- /dev/null
+++ b/pkg/cmd/asset/publish/publish.go
@@ -0,0 +1,147 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+package publish
+
+import (
+ "encoding/json"
+ "fmt"
+
+ "github.com/spf13/cobra"
+ "go.probo.inc/probo/pkg/cli/api"
+ "go.probo.inc/probo/pkg/cmd/cmdutil"
+)
+
+const publishMutation = `
+mutation($input: PublishAssetListInput!) {
+ publishAssetList(input: $input) {
+ documentEdge {
+ node {
+ id
+ status
+ createdAt
+ }
+ }
+ documentVersionEdge {
+ node {
+ id
+ title
+ major
+ minor
+ status
+ }
+ }
+ }
+}
+`
+
+type publishResponse struct {
+ PublishAssetList struct {
+ DocumentEdge struct {
+ Node struct {
+ ID string `json:"id"`
+ Status string `json:"status"`
+ CreatedAt string `json:"createdAt"`
+ } `json:"node"`
+ } `json:"documentEdge"`
+ DocumentVersionEdge struct {
+ Node struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ Major int `json:"major"`
+ Minor int `json:"minor"`
+ Status string `json:"status"`
+ } `json:"node"`
+ } `json:"documentVersionEdge"`
+ } `json:"publishAssetList"`
+}
+
+func NewCmdPublish(f *cmdutil.Factory) *cobra.Command {
+ var (
+ flagOrg string
+ flagApprover []string
+ )
+
+ cmd := &cobra.Command{
+ Use: "publish",
+ Short: "Publish the asset list as a document version",
+ Example: ` # Publish the asset list
+ prb asset publish --org ORG_ID
+
+ # Publish with approvers
+ prb asset publish --org ORG_ID --approver PROFILE_ID1 --approver PROFILE_ID2`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ cfg, err := f.Config()
+ if err != nil {
+ return err
+ }
+
+ host, hc, err := cfg.DefaultHost()
+ if err != nil {
+ return err
+ }
+
+ if flagOrg == "" {
+ flagOrg = hc.Organization
+ }
+ if flagOrg == "" {
+ return fmt.Errorf("organization is required: pass --org or run `prb auth login`")
+ }
+
+ client := api.NewClient(
+ host,
+ hc.Token,
+ "/api/console/v1/graphql",
+ cfg.HTTPTimeoutDuration(),
+ )
+
+ input := map[string]any{
+ "organizationId": flagOrg,
+ }
+
+ if len(flagApprover) > 0 {
+ input["approverIds"] = flagApprover
+ }
+
+ data, err := client.Do(
+ publishMutation,
+ map[string]any{"input": input},
+ )
+ if err != nil {
+ return err
+ }
+
+ var resp publishResponse
+ if err := json.Unmarshal(data, &resp); err != nil {
+ return fmt.Errorf("cannot parse response: %w", err)
+ }
+
+ v := resp.PublishAssetList.DocumentVersionEdge.Node
+ _, _ = fmt.Fprintf(
+ f.IOStreams.Out,
+ "Published asset list %s (v%d.%d)\n",
+ v.Title,
+ v.Major,
+ v.Minor,
+ )
+
+ return nil
+ },
+ }
+
+ cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID")
+ cmd.Flags().StringArrayVar(&flagApprover, "approver", nil, "Approver profile ID (can be repeated)")
+
+ return cmd
+}
diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go
index 3e53fd81d..d1d71204e 100644
--- a/pkg/cmd/root/root.go
+++ b/pkg/cmd/root/root.go
@@ -18,6 +18,7 @@ import (
"github.com/spf13/cobra"
accessreview "go.probo.inc/probo/pkg/cmd/access-review"
cmdapi "go.probo.inc/probo/pkg/cmd/api"
+ "go.probo.inc/probo/pkg/cmd/asset"
"go.probo.inc/probo/pkg/cmd/auditlog"
"go.probo.inc/probo/pkg/cmd/auth"
"go.probo.inc/probo/pkg/cmd/browse"
@@ -71,6 +72,7 @@ func NewCmdRoot(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(accessreview.NewCmdAccessReview(f))
cmd.AddCommand(cmdapi.NewCmdAPI(f))
+ cmd.AddCommand(asset.NewCmdAsset(f))
cmd.AddCommand(auditlog.NewCmdAuditLog(f))
cmd.AddCommand(auth.NewCmdAuth(f))
cmd.AddCommand(browse.NewCmdBrowse(f))
diff --git a/pkg/coredata/asset.go b/pkg/coredata/asset.go
index 05bc5f642..b1cde4f2b 100644
--- a/pkg/coredata/asset.go
+++ b/pkg/coredata/asset.go
@@ -30,8 +30,6 @@ import (
type (
Asset struct {
ID gid.GID `db:"id"`
- SnapshotID *gid.GID `db:"snapshot_id"`
- SourceID *gid.GID `db:"source_id"`
Name string `db:"name"`
Amount int `db:"amount"`
OwnerID gid.GID `db:"owner_profile_id"`
@@ -80,8 +78,6 @@ func (a *Asset) LoadByID(
q := `
SELECT
id,
- snapshot_id,
- source_id,
name,
organization_id,
owner_profile_id,
@@ -95,6 +91,7 @@ FROM
WHERE
%s
AND id = @asset_id
+ AND snapshot_id IS NULL
LIMIT 1;
`
@@ -130,8 +127,6 @@ func (a *Asset) LoadByOwnerID(
q := `
SELECT
id,
- snapshot_id,
- source_id,
name,
organization_id,
owner_profile_id,
@@ -145,6 +140,7 @@ FROM
WHERE
%s
AND owner_profile_id = @owner_profile_id
+ AND snapshot_id IS NULL
LIMIT 1;
`
@@ -177,7 +173,6 @@ func (a *Assets) CountByOrganizationID(
conn pg.Querier,
scope Scoper,
organizationID gid.GID,
- filter *AssetFilter,
) (int, error) {
q := `
SELECT
@@ -187,14 +182,13 @@ FROM
WHERE
%s
AND organization_id = @organization_id
- AND %s
+ AND snapshot_id IS NULL
`
- q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
+ q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"organization_id": organizationID}
maps.Copy(args, scope.SQLArguments())
- maps.Copy(args, filter.SQLArguments())
row := conn.QueryRow(ctx, q, args)
@@ -212,13 +206,10 @@ func (a *Assets) LoadByOrganizationID(
scope Scoper,
organizationID gid.GID,
cursor *page.Cursor[AssetOrderField],
- filter *AssetFilter,
) error {
q := `
SELECT
id,
- snapshot_id,
- source_id,
name,
organization_id,
owner_profile_id,
@@ -232,15 +223,14 @@ FROM
WHERE
%s
AND organization_id = @organization_id
- AND %s
+ AND snapshot_id IS NULL
AND %s
`
- q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment())
+ q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
args := pgx.StrictNamedArgs{"organization_id": organizationID}
maps.Copy(args, scope.SQLArguments())
- maps.Copy(args, filter.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
@@ -258,6 +248,53 @@ WHERE
return nil
}
+func (a *Assets) LoadAllByOrganizationID(
+ ctx context.Context,
+ conn pg.Querier,
+ scope Scoper,
+ organizationID gid.GID,
+) error {
+ q := `
+SELECT
+ id,
+ name,
+ organization_id,
+ owner_profile_id,
+ amount,
+ asset_type,
+ data_types_stored,
+ created_at,
+ updated_at
+FROM
+ assets
+WHERE
+ %s
+ AND organization_id = @organization_id
+ AND snapshot_id IS NULL
+ORDER BY
+ name ASC
+`
+
+ q = fmt.Sprintf(q, scope.SQLFragment())
+
+ args := pgx.StrictNamedArgs{"organization_id": organizationID}
+ maps.Copy(args, scope.SQLArguments())
+
+ rows, err := conn.Query(ctx, q, args)
+ if err != nil {
+ return fmt.Errorf("cannot query assets: %w", err)
+ }
+
+ assets, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Asset])
+ if err != nil {
+ return fmt.Errorf("cannot collect assets: %w", err)
+ }
+
+ *a = assets
+
+ return nil
+}
+
func (a *Asset) Insert(
ctx context.Context,
conn pg.Tx,
@@ -330,8 +367,6 @@ WHERE
AND snapshot_id IS NULL
RETURNING
id,
- snapshot_id,
- source_id,
name,
organization_id,
owner_profile_id,
@@ -396,75 +431,108 @@ WHERE
return nil
}
-func (assets Assets) Snapshot(ctx context.Context, conn pg.Tx, scope Scoper, organizationID, snapshotID gid.GID) error {
- snapshotters := []AssetSnapshotter{Assets{}, Vendors{}, AssetVendors{}}
+func (a Asset) GetGeneratedDocumentID(
+ ctx context.Context,
+ conn pg.Querier,
+ organizationID gid.GID,
+) (*gid.GID, error) {
+ var documentID *gid.GID
- for _, snapshotter := range snapshotters {
- if err := snapshotter.InsertAssetSnapshots(ctx, conn, scope, organizationID, snapshotID); err != nil {
- return fmt.Errorf("cannot create asset snapshots: (%T) %w", snapshotter, err)
- }
+ err := conn.QueryRow(
+ ctx,
+ `
+SELECT
+ asset_list_document_id
+FROM
+ generated_documents
+WHERE
+ organization_id = @organization_id
+`,
+ pgx.NamedArgs{"organization_id": organizationID},
+ ).Scan(&documentID)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, fmt.Errorf("cannot get asset list document ID: %w", err)
}
- return nil
+ return documentID, nil
}
-func (assets Assets) InsertAssetSnapshots(
+func (a Asset) UpsertGeneratedDocumentID(
ctx context.Context,
conn pg.Tx,
- scope Scoper,
organizationID gid.GID,
- snapshotID gid.GID,
+ tenantID gid.TenantID,
+ documentID gid.GID,
) error {
- query := `
-WITH
- source_assets AS (
- SELECT *
- FROM assets
- WHERE %s AND organization_id = @organization_id AND snapshot_id IS NULL
- )
-INSERT INTO assets (
- tenant_id,
- id,
- snapshot_id,
- source_id,
- name,
+ now := time.Now()
+
+ _, err := conn.Exec(
+ ctx,
+ `
+INSERT INTO generated_documents (
organization_id,
- owner_profile_id,
- amount,
- asset_type,
- data_types_stored,
+ tenant_id,
+ asset_list_document_id,
created_at,
updated_at
-)
-SELECT
+) VALUES (
+ @organization_id,
@tenant_id,
- generate_gid(decode_base64_unpadded(@tenant_id), @asset_entity_type),
- @snapshot_id,
- a.id,
- a.name,
- a.organization_id,
- a.owner_profile_id,
- a.amount,
- a.asset_type,
- a.data_types_stored,
- a.created_at,
- a.updated_at
-FROM source_assets a
- `
-
- query = fmt.Sprintf(query, scope.SQLFragment())
-
- args := pgx.StrictNamedArgs{
- "tenant_id": scope.GetTenantID(),
- "snapshot_id": snapshotID,
- "organization_id": organizationID,
- "asset_entity_type": AssetEntityType,
- }
- maps.Copy(args, scope.SQLArguments())
-
- _, err := conn.Exec(ctx, query, args)
+ @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": organizationID,
+ "tenant_id": tenantID,
+ "asset_list_document_id": documentID,
+ "created_at": now,
+ "updated_at": now,
+ },
+ )
if err != nil {
- return fmt.Errorf("cannot insert asset snapshots: %w", err)
+ return fmt.Errorf("cannot upsert asset list document ID: %w", err)
+ }
+
+ return nil
+}
+
+func (a Asset) ClearGeneratedDocumentID(
+ ctx context.Context,
+ conn pg.Tx,
+ documentIDs []gid.GID,
+) error {
+ ids := make([]string, len(documentIDs))
+ for i, id := range documentIDs {
+ ids[i] = id.String()
+ }
+
+ _, err := conn.Exec(
+ ctx,
+ `
+UPDATE
+ generated_documents
+SET
+ asset_list_document_id = NULL,
+ updated_at = @now
+WHERE
+ asset_list_document_id = ANY(@ids)
+`,
+ pgx.NamedArgs{
+ "ids": ids,
+ "now": time.Now(),
+ },
+ )
+ if err != nil {
+ return fmt.Errorf("cannot clear asset list document references: %w", err)
}
return nil
diff --git a/pkg/coredata/asset_vendor.go b/pkg/coredata/asset_vendor.go
index 47d647532..7dfb6289b 100644
--- a/pkg/coredata/asset_vendor.go
+++ b/pkg/coredata/asset_vendor.go
@@ -17,7 +17,6 @@ package coredata
import (
"context"
"fmt"
- "maps"
"time"
"github.com/jackc/pgx/v5"
@@ -27,18 +26,13 @@ import (
type (
AssetVendor struct {
- AssetID gid.GID `db:"asset_id"`
- VendorID gid.GID `db:"vendor_id"`
- TenantID gid.TenantID `db:"tenant_id"`
- SnapshotID *gid.GID `db:"snapshot_id"`
- CreatedAt time.Time `db:"created_at"`
+ AssetID gid.GID `db:"asset_id"`
+ VendorID gid.GID `db:"vendor_id"`
+ TenantID gid.TenantID `db:"tenant_id"`
+ CreatedAt time.Time `db:"created_at"`
}
AssetVendors []*AssetVendor
-
- AssetSnapshotter interface {
- InsertAssetSnapshots(ctx context.Context, conn pg.Tx, scope Scoper, organizationID, snapshotID gid.GID) error
- }
)
func (av AssetVendors) Merge(
@@ -124,61 +118,3 @@ FROM vendor_ids
return nil
}
-
-func (av AssetVendors) InsertAssetSnapshots(
- ctx context.Context,
- conn pg.Tx,
- scope Scoper,
- organizationID gid.GID,
- snapshotID gid.GID,
-) error {
- query := `
-WITH
- source_assets AS (
- SELECT id
- FROM assets
- WHERE organization_id = @organization_id AND snapshot_id IS NULL
- ),
- snapshot_assets AS (
- SELECT id, source_id
- FROM assets
- WHERE organization_id = @organization_id AND snapshot_id = @snapshot_id
- ),
- snapshot_vendors AS (
- SELECT id, source_id
- FROM vendors
- WHERE organization_id = @organization_id AND snapshot_id = @snapshot_id
- ),
- source_asset_vendors AS (
- SELECT asset_id, vendor_id, snapshot_id, created_at
- FROM asset_vendors
- WHERE %s AND asset_id = ANY(SELECT id FROM source_assets) AND snapshot_id IS NULL
- )
-INSERT INTO asset_vendors (tenant_id, asset_id, vendor_id, organization_id, snapshot_id, created_at)
-SELECT
- @tenant_id,
- sa.id,
- sv.id,
- @organization_id,
- @snapshot_id,
- av.created_at
-FROM source_asset_vendors av
-JOIN snapshot_assets sa ON sa.source_id = av.asset_id
-JOIN snapshot_vendors sv ON sv.source_id = av.vendor_id
-`
-
- query = fmt.Sprintf(query, scope.SQLFragment())
-
- args := pgx.StrictNamedArgs{
- "snapshot_id": snapshotID,
- "organization_id": organizationID,
- }
- maps.Copy(args, scope.SQLArguments())
-
- _, err := conn.Exec(ctx, query, args)
- if err != nil {
- return fmt.Errorf("cannot insert asset vendor snapshots: %w", err)
- }
-
- return nil
-}
diff --git a/pkg/coredata/datum.go b/pkg/coredata/datum.go
index 78a78b480..d33851643 100644
--- a/pkg/coredata/datum.go
+++ b/pkg/coredata/datum.go
@@ -403,3 +403,110 @@ WHERE
return nil
}
+
+func (d Datum) GetGeneratedDocumentID(
+ ctx context.Context,
+ conn pg.Querier,
+ organizationID gid.GID,
+) (*gid.GID, error) {
+ var documentID *gid.GID
+
+ err := conn.QueryRow(
+ ctx,
+ `
+SELECT
+ data_document_id
+FROM
+ generated_documents
+WHERE
+ organization_id = @organization_id
+`,
+ pgx.NamedArgs{"organization_id": organizationID},
+ ).Scan(&documentID)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, fmt.Errorf("cannot get data document ID: %w", err)
+ }
+
+ return documentID, nil
+}
+
+func (d Datum) UpsertGeneratedDocumentID(
+ ctx context.Context,
+ conn pg.Tx,
+ organizationID gid.GID,
+ tenantID gid.TenantID,
+ documentID gid.GID,
+) error {
+ now := time.Now()
+
+ _, err := conn.Exec(
+ ctx,
+ `
+INSERT INTO generated_documents (
+ organization_id,
+ tenant_id,
+ 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": organizationID,
+ "tenant_id": tenantID,
+ "data_document_id": documentID,
+ "created_at": now,
+ "updated_at": now,
+ },
+ )
+ if err != nil {
+ return fmt.Errorf("cannot upsert data document ID: %w", err)
+ }
+
+ return nil
+}
+
+func (d Datum) ClearGeneratedDocumentID(
+ ctx context.Context,
+ conn pg.Tx,
+ documentIDs []gid.GID,
+) error {
+ ids := make([]string, len(documentIDs))
+ for i, id := range documentIDs {
+ ids[i] = id.String()
+ }
+
+ _, err := conn.Exec(
+ ctx,
+ `
+UPDATE
+ generated_documents
+SET
+ data_document_id = NULL,
+ updated_at = @now
+WHERE
+ data_document_id = ANY(@ids)
+`,
+ pgx.NamedArgs{
+ "ids": ids,
+ "now": time.Now(),
+ },
+ )
+ if err != nil {
+ return fmt.Errorf("cannot clear data document references: %w", err)
+ }
+
+ return nil
+}
diff --git a/pkg/coredata/migrations/20260420T140000Z.sql b/pkg/coredata/migrations/20260420T140000Z.sql
new file mode 100644
index 000000000..c2c7b0750
--- /dev/null
+++ b/pkg/coredata/migrations/20260420T140000Z.sql
@@ -0,0 +1,16 @@
+-- Copyright (c) 2026 Probo Inc .
+--
+-- Permission to use, copy, modify, and/or distribute this software for any
+-- purpose with or without fee is hereby granted, provided that the above
+-- copyright notice and this permission notice appear in all copies.
+--
+-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+-- PERFORMANCE OF THIS SOFTWARE.
+
+ALTER TABLE generated_documents
+ ADD COLUMN asset_list_document_id TEXT REFERENCES documents(id) ON DELETE SET NULL;
diff --git a/pkg/coredata/snapshot.go b/pkg/coredata/snapshot.go
index 0289be9d8..2a8e9d677 100644
--- a/pkg/coredata/snapshot.go
+++ b/pkg/coredata/snapshot.go
@@ -124,7 +124,7 @@ FROM
WHERE
%s
AND organization_id = @organization_id
- AND type NOT IN ('STATEMENTS_OF_APPLICABILITY', 'DATA')
+ AND type NOT IN ('STATEMENTS_OF_APPLICABILITY', 'DATA', 'ASSETS')
AND %s
`
@@ -164,7 +164,7 @@ FROM
WHERE
%s
AND organization_id = @organization_id
- AND type NOT IN ('STATEMENTS_OF_APPLICABILITY', 'DATA')
+ AND type NOT IN ('STATEMENTS_OF_APPLICABILITY', 'DATA', 'ASSETS')
AND %s
`
diff --git a/pkg/coredata/snapshots_type.go b/pkg/coredata/snapshots_type.go
index e46deb7b1..4972714e1 100644
--- a/pkg/coredata/snapshots_type.go
+++ b/pkg/coredata/snapshots_type.go
@@ -38,7 +38,6 @@ func SnapshotsTypes() []SnapshotsType {
return []SnapshotsType{
SnapshotsTypeRisks,
SnapshotsTypeVendors,
- SnapshotsTypeAssets,
SnapshotsTypeFindings,
SnapshotsTypeObligations,
SnapshotsTypeProcessingActivities,
diff --git a/pkg/coredata/snapshottable.go b/pkg/coredata/snapshottable.go
index 073b931a5..91855a7ac 100644
--- a/pkg/coredata/snapshottable.go
+++ b/pkg/coredata/snapshottable.go
@@ -28,8 +28,6 @@ type Snapshottable interface {
func GetSnapshottable(snapshotType SnapshotsType) (Snapshottable, error) {
switch snapshotType {
- case SnapshotsTypeAssets:
- return Assets{}, nil
case SnapshotsTypeRisks:
return Risks{}, nil
case SnapshotsTypeFindings:
diff --git a/pkg/coredata/statement_of_applicability.go b/pkg/coredata/statement_of_applicability.go
index 7854e4d57..ccb51dce6 100644
--- a/pkg/coredata/statement_of_applicability.go
+++ b/pkg/coredata/statement_of_applicability.go
@@ -313,3 +313,36 @@ WHERE
return nil
}
+
+func (s StatementOfApplicability) ClearDocumentIDByDocumentIDs(
+ ctx context.Context,
+ conn pg.Tx,
+ documentIDs []gid.GID,
+) error {
+ ids := make([]string, len(documentIDs))
+ for i, id := range documentIDs {
+ ids[i] = id.String()
+ }
+
+ _, err := conn.Exec(
+ ctx,
+ `
+UPDATE
+ statements_of_applicability
+SET
+ document_id = NULL,
+ updated_at = @now
+WHERE
+ document_id = ANY(@ids)
+`,
+ pgx.NamedArgs{
+ "ids": ids,
+ "now": time.Now(),
+ },
+ )
+ if err != nil {
+ return fmt.Errorf("cannot clear statement of applicability document references: %w", err)
+ }
+
+ return nil
+}
diff --git a/pkg/coredata/vendor.go b/pkg/coredata/vendor.go
index a5b2b0cfd..9b77ddaba 100644
--- a/pkg/coredata/vendor.go
+++ b/pkg/coredata/vendor.go
@@ -1077,35 +1077,52 @@ ORDER BY
return vendorMap, nil
}
-func (vs Vendors) InsertAssetSnapshots(
+func (vs *Vendors) LoadAllByAssetID(
ctx context.Context,
- conn pg.Tx,
+ conn pg.Querier,
scope Scoper,
- organizationID gid.GID,
- snapshotID gid.GID,
+ assetID gid.GID,
) error {
- query := `
-WITH
- source_assets AS (
- SELECT id
- FROM assets
- WHERE organization_id = @organization_id AND snapshot_id IS NULL
- ),
- source_asset_vendors AS (
- SELECT asset_id, vendor_id, snapshot_id, created_at
- FROM asset_vendors
- WHERE asset_id = ANY(SELECT id FROM source_assets)
- ),
- source_vendors AS (
- SELECT *
- FROM vendors
- WHERE %s AND id = ANY(SELECT vendor_id FROM source_asset_vendors)
- )
-INSERT INTO vendors (
- tenant_id,
+ q := `
+WITH vend AS (
+ SELECT
+ v.id,
+ v.tenant_id,
+ v.organization_id,
+ v.name,
+ v.description,
+ v.category,
+ v.headquarter_address,
+ v.legal_name,
+ 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.certifications,
+ v.countries,
+ v.business_owner_profile_id,
+ v.security_owner_profile_id,
+ v.status_page_url,
+ v.terms_of_service_url,
+ v.security_page_url,
+ v.trust_page_url,
+ v.show_on_trust_center,
+ v.snapshot_id,
+ v.source_id,
+ v.created_at,
+ v.updated_at
+ FROM
+ vendors v
+ INNER JOIN
+ asset_vendors av ON v.id = av.vendor_id
+ WHERE
+ av.asset_id = @asset_id
+)
+SELECT
id,
- snapshot_id,
- source_id,
+ tenant_id,
organization_id,
name,
description,
@@ -1127,55 +1144,32 @@ INSERT INTO vendors (
security_page_url,
trust_page_url,
show_on_trust_center,
+ snapshot_id,
+ source_id,
created_at,
updated_at
-)
-SELECT
- @tenant_id,
- generate_gid(decode_base64_unpadded(@tenant_id), @vendor_entity_type),
- @snapshot_id,
- v.id,
- v.organization_id,
- v.name,
- v.description,
- v.category,
- v.headquarter_address,
- v.legal_name,
- 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.certifications,
- v.countries,
- v.business_owner_profile_id,
- v.security_owner_profile_id,
- v.status_page_url,
- v.terms_of_service_url,
- v.security_page_url,
- v.trust_page_url,
- v.show_on_trust_center,
- v.created_at,
- v.updated_at
-FROM source_vendors v
- `
+FROM
+ vend
+WHERE %s
+ORDER BY name ASC
+`
+ q = fmt.Sprintf(q, scope.SQLFragment())
- query = fmt.Sprintf(query, scope.SQLFragment())
-
- args := pgx.StrictNamedArgs{
- "tenant_id": scope.GetTenantID(),
- "snapshot_id": snapshotID,
- "organization_id": organizationID,
- "vendor_entity_type": VendorEntityType,
- }
+ args := pgx.StrictNamedArgs{"asset_id": assetID}
maps.Copy(args, scope.SQLArguments())
- _, err := conn.Exec(ctx, query, args)
+ rows, err := conn.Query(ctx, q, args)
if err != nil {
- return fmt.Errorf("cannot insert vendor snapshots for assets: %w", err)
+ return fmt.Errorf("cannot query vendors: %w", err)
}
+ vendors, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Vendor])
+ if err != nil {
+ return fmt.Errorf("cannot collect vendors: %w", err)
+ }
+
+ *vs = vendors
+
return nil
}
diff --git a/pkg/docgen/generator.go b/pkg/docgen/generator.go
index 8e4d56de1..10d84d478 100644
--- a/pkg/docgen/generator.go
+++ b/pkg/docgen/generator.go
@@ -311,6 +311,23 @@ type (
Owner string
Vendors string
}
+
+ AssetListData struct {
+ Title string
+ OrganizationName string
+ CreatedAt time.Time
+ TotalAssets int
+ Rows []AssetListRow
+ }
+
+ AssetListRow struct {
+ Name string
+ AssetType string
+ Amount int
+ DataTypesStored string
+ Owner string
+ Vendors string
+ }
)
func BoolLabel(v bool) string {
diff --git a/pkg/probo/actions.go b/pkg/probo/actions.go
index b6bb9efcf..8d6d7ae3d 100644
--- a/pkg/probo/actions.go
+++ b/pkg/probo/actions.go
@@ -227,11 +227,12 @@ const (
ActionRiskObligationMappingDelete = "core:risk:delete-obligation-mapping"
// Asset actions
- ActionAssetGet = "core:asset:get"
- ActionAssetList = "core:asset:list"
- ActionAssetCreate = "core:asset:create"
- ActionAssetUpdate = "core:asset:update"
- ActionAssetDelete = "core:asset:delete"
+ ActionAssetGet = "core:asset:get"
+ ActionAssetList = "core:asset:list"
+ ActionAssetCreate = "core:asset:create"
+ ActionAssetUpdate = "core:asset:update"
+ ActionAssetDelete = "core:asset:delete"
+ ActionAssetPublish = "core:asset:publish"
// Datum actions
ActionDatumGet = "core:datum:get"
diff --git a/pkg/probo/asset_service.go b/pkg/probo/asset_service.go
index 952ff556d..795810479 100644
--- a/pkg/probo/asset_service.go
+++ b/pkg/probo/asset_service.go
@@ -125,7 +125,6 @@ func (s AssetService) GetByOwnerID(
func (s AssetService) CountForOrganizationID(
ctx context.Context,
organizationID gid.GID,
- filter *coredata.AssetFilter,
) (int, error) {
var count int
@@ -133,7 +132,7 @@ func (s AssetService) CountForOrganizationID(
ctx,
func(ctx context.Context, conn pg.Querier) (err error) {
assets := coredata.Assets{}
- count, err = assets.CountByOrganizationID(ctx, conn, s.svc.scope, organizationID, filter)
+ count, err = assets.CountByOrganizationID(ctx, conn, s.svc.scope, organizationID)
if err != nil {
return fmt.Errorf("cannot count assets: %w", err)
}
@@ -153,7 +152,6 @@ func (s AssetService) ListForOrganizationID(
ctx context.Context,
organizationID gid.GID,
cursor *page.Cursor[coredata.AssetOrderField],
- filter *coredata.AssetFilter,
) (*page.Page[*coredata.Asset, coredata.AssetOrderField], error) {
var assets coredata.Assets
@@ -166,7 +164,6 @@ func (s AssetService) ListForOrganizationID(
s.svc.scope,
organizationID,
cursor,
- filter,
)
},
)
diff --git a/pkg/probo/document_service.go b/pkg/probo/document_service.go
index 2536886ae..4da2c0168 100644
--- a/pkg/probo/document_service.go
+++ b/pkg/probo/document_service.go
@@ -1150,6 +1150,10 @@ func (s *DocumentService) SoftDelete(
return s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
+ if err := s.clearDocumentReferences(ctx, tx, []gid.GID{documentID}); err != nil {
+ return err
+ }
+
return document.SoftDelete(ctx, tx, s.svc.scope)
},
)
@@ -1168,6 +1172,10 @@ func (s *DocumentService) BulkSoftDelete(
return s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
+ if err := s.clearDocumentReferences(ctx, tx, documentIDs); err != nil {
+ return err
+ }
+
return documents.BulkSoftDelete(ctx, tx, s.svc.scope)
},
)
@@ -1201,6 +1209,10 @@ func (s *DocumentService) BulkArchive(
return fmt.Errorf("cannot delete measure mappings: %w", err)
}
+ if err := s.clearDocumentReferences(ctx, tx, documentIDs); err != nil {
+ return err
+ }
+
return documents.BulkArchive(ctx, tx, s.svc.scope)
},
)
@@ -1224,6 +1236,33 @@ func (s *DocumentService) BulkUnarchive(
)
}
+// clearDocumentReferences nullifies references to the given document IDs in
+// generated_documents and statements_of_applicability. This must be called
+// inside a transaction before soft-deleting or archiving documents, because
+// those operations are UPDATEs and do not trigger ON DELETE SET NULL.
+func (s *DocumentService) clearDocumentReferences(
+ ctx context.Context,
+ tx pg.Tx,
+ documentIDs []gid.GID,
+) error {
+ datum := coredata.Datum{}
+ if err := datum.ClearGeneratedDocumentID(ctx, tx, documentIDs); err != nil {
+ return err
+ }
+
+ asset := coredata.Asset{}
+ if err := asset.ClearGeneratedDocumentID(ctx, tx, documentIDs); err != nil {
+ return err
+ }
+
+ soa := coredata.StatementOfApplicability{}
+ if err := soa.ClearDocumentIDByDocumentIDs(ctx, tx, documentIDs); err != nil {
+ return err
+ }
+
+ return nil
+}
+
func (s *DocumentService) RequestExport(
ctx context.Context,
documentIDs []gid.GID,
@@ -1849,6 +1888,10 @@ func (s *DocumentService) Archive(
return fmt.Errorf("cannot delete measure mappings: %w", err)
}
+ if err := s.clearDocumentReferences(ctx, tx, []gid.GID{documentID}); err != nil {
+ return err
+ }
+
document.Status = coredata.DocumentStatusArchived
document.ArchivedAt = &now
document.UpdatedAt = now
diff --git a/pkg/probo/generated_document_service.go b/pkg/probo/generated_document_service.go
index 87273cc1a..99eb15fcb 100644
--- a/pkg/probo/generated_document_service.go
+++ b/pkg/probo/generated_document_service.go
@@ -24,7 +24,6 @@ import (
"text/template"
"time"
- "github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/docgen"
@@ -367,13 +366,9 @@ func (s *GeneratedDocumentService) PublishDataList(
now := time.Now()
- var dataDocumentID *gid.GID
- err = tx.QueryRow(
- ctx,
- `SELECT data_document_id FROM generated_documents WHERE organization_id = @organization_id`,
- pgx.NamedArgs{"organization_id": organizationID},
- ).Scan(&dataDocumentID)
- if err != nil && !errors.Is(err, pgx.ErrNoRows) {
+ datum := coredata.Datum{}
+ dataDocumentID, err := datum.GetGeneratedDocumentID(ctx, tx, organizationID)
+ if err != nil {
return fmt.Errorf("cannot query generated documents: %w", err)
}
@@ -388,12 +383,7 @@ func (s *GeneratedDocumentService) PublishDataList(
if err == nil && doc.ArchivedAt == nil {
existingDoc = doc
} else {
- _, err = tx.Exec(
- ctx,
- `UPDATE generated_documents SET data_document_id = NULL, updated_at = @updated_at WHERE organization_id = @organization_id`,
- pgx.NamedArgs{"organization_id": organizationID, "updated_at": now},
- )
- if err != nil {
+ if err := datum.ClearGeneratedDocumentID(ctx, tx, []gid.GID{*dataDocumentID}); err != nil {
return fmt.Errorf("cannot clear document reference: %w", err)
}
}
@@ -418,20 +408,7 @@ func (s *GeneratedDocumentService) PublishDataList(
return fmt.Errorf("cannot insert document: %w", err)
}
- _, 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": organizationID,
- "tenant_id": s.svc.scope.GetTenantID(),
- "data_document_id": documentID,
- "created_at": now,
- "updated_at": now,
- },
- )
- if err != nil {
+ if err := datum.UpsertGeneratedDocumentID(ctx, tx, organizationID, s.svc.scope.GetTenantID(), documentID); err != nil {
return fmt.Errorf("cannot upsert generated documents: %w", err)
}
} else {
@@ -523,15 +500,11 @@ func (s *GeneratedDocumentService) GetDataListDocumentID(
var dataDocumentID *gid.GID
err := s.svc.pg.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
- return conn.QueryRow(
- ctx,
- `SELECT data_document_id FROM generated_documents WHERE organization_id = @organization_id`,
- pgx.NamedArgs{"organization_id": organizationID},
- ).Scan(&dataDocumentID)
+ datum := coredata.Datum{}
+ var err error
+ dataDocumentID, err = datum.GetGeneratedDocumentID(ctx, conn, organizationID)
+ return err
})
- if errors.Is(err, pgx.ErrNoRows) {
- return nil, nil
- }
if err != nil {
return nil, fmt.Errorf("cannot get data list document ID: %w", err)
}
@@ -653,6 +626,295 @@ func BuildDataListDocument(data docgen.DataListData) (string, error) {
return buf.String(), nil
}
+func (s *GeneratedDocumentService) PublishAssetList(
+ ctx context.Context,
+ organizationID gid.GID,
+ approverIDs []gid.GID,
+) (*coredata.Document, *coredata.DocumentVersion, error) {
+ var (
+ document *coredata.Document
+ documentVersion *coredata.DocumentVersion
+ )
+
+ err := s.svc.pg.WithTx(
+ ctx,
+ func(ctx context.Context, tx pg.Tx) error {
+ organization := &coredata.Organization{}
+ if err := organization.LoadByID(ctx, tx, s.svc.scope, organizationID); err != nil {
+ return fmt.Errorf("cannot load organization: %w", err)
+ }
+
+ documentData, err := s.buildAssetListDocumentData(ctx, tx, organization)
+ if err != nil {
+ return fmt.Errorf("cannot build document data: %w", err)
+ }
+
+ prosemirrorJSON, err := BuildAssetListDocument(documentData)
+ if err != nil {
+ return fmt.Errorf("cannot build prosemirror document: %w", err)
+ }
+
+ now := time.Now()
+
+ asset := coredata.Asset{}
+ assetDocumentID, err := asset.GetGeneratedDocumentID(ctx, tx, organizationID)
+ if err != nil {
+ return fmt.Errorf("cannot query generated documents: %w", err)
+ }
+
+ var existingDoc *coredata.Document
+ if assetDocumentID != nil {
+ doc := &coredata.Document{}
+ err = doc.LoadByID(ctx, tx, s.svc.scope, *assetDocumentID)
+ if err != nil && !errors.Is(err, coredata.ErrResourceNotFound) {
+ return fmt.Errorf("cannot load asset list document: %w", err)
+ }
+
+ if err == nil && doc.ArchivedAt == nil {
+ existingDoc = doc
+ } else {
+ if err := asset.ClearGeneratedDocumentID(ctx, tx, []gid.GID{*assetDocumentID}); err != nil {
+ return fmt.Errorf("cannot clear document reference: %w", err)
+ }
+ }
+ }
+
+ hasApprovers := len(approverIDs) > 0
+
+ if existingDoc == nil {
+ documentID := gid.New(s.svc.scope.GetTenantID(), coredata.DocumentEntityType)
+
+ document = &coredata.Document{
+ ID: documentID,
+ OrganizationID: organizationID,
+ WriteMode: coredata.DocumentWriteModeGenerated,
+ TrustCenterVisibility: coredata.TrustCenterVisibilityNone,
+ Status: coredata.DocumentStatusActive,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+
+ if err := document.Insert(ctx, tx, s.svc.scope); err != nil {
+ return fmt.Errorf("cannot insert document: %w", err)
+ }
+
+ if err := asset.UpsertGeneratedDocumentID(ctx, tx, organizationID, s.svc.scope.GetTenantID(), documentID); err != nil {
+ return fmt.Errorf("cannot upsert generated documents: %w", err)
+ }
+ } else {
+ document = existingDoc
+ }
+
+ var newMajor int
+ if document.CurrentPublishedMajor != nil {
+ newMajor = *document.CurrentPublishedMajor + 1
+ } else {
+ newMajor = 1
+ }
+
+ versionStatus := coredata.DocumentVersionStatusPublished
+ var publishedAt *time.Time
+ if hasApprovers {
+ versionStatus = coredata.DocumentVersionStatusDraft
+ } else {
+ publishedAt = &now
+ }
+
+ documentVersionID := gid.New(s.svc.scope.GetTenantID(), coredata.DocumentVersionEntityType)
+ documentVersion = &coredata.DocumentVersion{
+ ID: documentVersionID,
+ OrganizationID: organizationID,
+ DocumentID: document.ID,
+ Title: "Asset List",
+ Major: newMajor,
+ Minor: 0,
+ Content: prosemirrorJSON,
+ Status: versionStatus,
+ Classification: coredata.DocumentClassificationConfidential,
+ DocumentType: coredata.DocumentTypeRegister,
+ Orientation: coredata.DocumentVersionOrientationPortrait,
+ PublishedAt: publishedAt,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+
+ if err := documentVersion.Insert(ctx, tx, s.svc.scope); err != nil {
+ if errors.Is(err, coredata.ErrResourceAlreadyExists) {
+ return fmt.Errorf("a version is pending approval, approve or reject it before publishing a new one: %w", err)
+ }
+ return fmt.Errorf("cannot insert document version: %w", err)
+ }
+
+ if hasApprovers {
+ defaultApprovers := &coredata.DocumentDefaultApprovers{}
+ if err := defaultApprovers.MergeByDocumentID(ctx, tx, s.svc.scope, document.ID, organizationID, approverIDs); err != nil {
+ return fmt.Errorf("cannot save default approvers: %w", err)
+ }
+
+ _, err := s.svc.DocumentApprovals.RequestApprovalInTx(
+ ctx,
+ tx,
+ document,
+ documentVersion,
+ approverIDs,
+ nil,
+ )
+ if err != nil {
+ return fmt.Errorf("cannot request approval: %w", err)
+ }
+ } else {
+ document.CurrentPublishedMajor = &newMajor
+ document.CurrentPublishedMinor = new(0)
+ document.UpdatedAt = now
+
+ if err := document.Update(ctx, tx, s.svc.scope); err != nil {
+ return fmt.Errorf("cannot update document: %w", err)
+ }
+ }
+
+ return nil
+ },
+ )
+
+ if err != nil {
+ return nil, nil, err
+ }
+
+ return document, documentVersion, nil
+}
+
+func (s *GeneratedDocumentService) GetAssetListDocumentID(
+ ctx context.Context,
+ organizationID gid.GID,
+) (*gid.GID, error) {
+ var assetDocumentID *gid.GID
+
+ err := s.svc.pg.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
+ asset := coredata.Asset{}
+ var err error
+ assetDocumentID, err = asset.GetGeneratedDocumentID(ctx, conn, organizationID)
+ return err
+ })
+ if err != nil {
+ return nil, fmt.Errorf("cannot get asset list document ID: %w", err)
+ }
+
+ return assetDocumentID, nil
+}
+
+func (s *GeneratedDocumentService) buildAssetListDocumentData(
+ ctx context.Context,
+ conn pg.Querier,
+ organization *coredata.Organization,
+) (docgen.AssetListData, error) {
+ var assets coredata.Assets
+ if err := assets.LoadAllByOrganizationID(ctx, conn, s.svc.scope, organization.ID); err != nil {
+ return docgen.AssetListData{}, fmt.Errorf("cannot load assets: %w", err)
+ }
+
+ if len(assets) == 0 {
+ return docgen.AssetListData{
+ Title: "Asset List",
+ OrganizationName: organization.Name,
+ CreatedAt: time.Now(),
+ TotalAssets: 0,
+ }, nil
+ }
+
+ ownerIDs := make([]gid.GID, 0, len(assets))
+ ownerIDSet := make(map[gid.GID]struct{})
+ for _, a := range assets {
+ if _, ok := ownerIDSet[a.OwnerID]; !ok {
+ ownerIDs = append(ownerIDs, a.OwnerID)
+ ownerIDSet[a.OwnerID] = struct{}{}
+ }
+ }
+
+ var profiles coredata.MembershipProfiles
+ if err := profiles.LoadByIDs(ctx, conn, s.svc.scope, ownerIDs); err != nil {
+ return docgen.AssetListData{}, fmt.Errorf("cannot load profiles: %w", err)
+ }
+
+ profileMap := make(map[gid.GID]*coredata.MembershipProfile, len(profiles))
+ for _, p := range profiles {
+ profileMap[p.ID] = p
+ }
+
+ rows := make([]docgen.AssetListRow, 0, len(assets))
+ for _, a := range assets {
+ ownerName := "-"
+ if p, ok := profileMap[a.OwnerID]; ok {
+ ownerName = p.FullName
+ }
+
+ var vendors coredata.Vendors
+ if err := vendors.LoadAllByAssetID(ctx, conn, s.svc.scope, a.ID); err != nil {
+ return docgen.AssetListData{}, fmt.Errorf("cannot load vendors for asset %s: %w", a.ID, err)
+ }
+
+ vendorNames := make([]string, 0, len(vendors))
+ for _, v := range vendors {
+ vendorNames = append(vendorNames, v.Name)
+ }
+
+ vendorStr := "-"
+ if len(vendorNames) > 0 {
+ vendorStr = strings.Join(vendorNames, ", ")
+ }
+
+ rows = append(rows, docgen.AssetListRow{
+ Name: a.Name,
+ AssetType: formatAssetType(a.AssetType),
+ Amount: a.Amount,
+ DataTypesStored: a.DataTypesStored,
+ Owner: ownerName,
+ Vendors: vendorStr,
+ })
+ }
+
+ return docgen.AssetListData{
+ Title: "Asset List",
+ OrganizationName: organization.Name,
+ CreatedAt: time.Now(),
+ TotalAssets: len(assets),
+ Rows: rows,
+ }, nil
+}
+
+func formatAssetType(t coredata.AssetType) string {
+ switch t {
+ case coredata.AssetTypePhysical:
+ return "Physical"
+ case coredata.AssetTypeVirtual:
+ return "Virtual"
+ default:
+ return string(t)
+ }
+}
+
+var assetListTemplate = template.Must(
+ template.New("asset_list.json.tmpl").
+ Funcs(template.FuncMap{
+ "json": func(v any) (string, error) {
+ b, err := json.Marshal(v)
+ if err != nil {
+ return "", err
+ }
+ return string(b), nil
+ },
+ "printf": fmt.Sprintf,
+ }).
+ ParseFS(Templates, "templates/asset_list.json.tmpl"),
+)
+
+func BuildAssetListDocument(data docgen.AssetListData) (string, error) {
+ var buf bytes.Buffer
+ if err := assetListTemplate.Execute(&buf, data); err != nil {
+ return "", fmt.Errorf("cannot execute asset list template: %w", err)
+ }
+ return buf.String(), nil
+}
+
var soaTemplate = template.Must(
template.New("statement_of_applicability.json.tmpl").
Funcs(template.FuncMap{
diff --git a/pkg/probo/templates/asset_list.json.tmpl b/pkg/probo/templates/asset_list.json.tmpl
new file mode 100644
index 000000000..84f1d439b
--- /dev/null
+++ b/pkg/probo/templates/asset_list.json.tmpl
@@ -0,0 +1,83 @@
+{
+ "type": "doc",
+ "content": [
+ {
+ "type": "heading",
+ "attrs": { "level": 1 },
+ "content": [{ "type": "text", "text": "1. Purpose" }]
+ },
+ {
+ "type": "paragraph",
+ "content": [{ "type": "text", "text": "This document provides a comprehensive inventory of assets managed by the organization. It serves as a record of all assets, their types, quantities, data stored, ownership, and associated vendors." }]
+ },
+ { "type": "horizontalRule" },
+ {
+ "type": "heading",
+ "attrs": { "level": 1 },
+ "content": [{ "type": "text", "text": "2. Asset Inventory" }]
+ },
+ {
+ "type": "table",
+ "content": [
+ {
+ "type": "tableRow",
+ "content": [
+ { "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [150] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Name", "marks": [{ "type": "bold" }] }] }] },
+ { "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [100] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Type", "marks": [{ "type": "bold" }] }] }] },
+ { "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [80] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Amount", "marks": [{ "type": "bold" }] }] }] },
+ { "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [120] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Data Types Stored", "marks": [{ "type": "bold" }] }] }] },
+ { "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [150] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Owner", "marks": [{ "type": "bold" }] }] }] },
+ { "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [200] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Vendors", "marks": [{ "type": "bold" }] }] }] }
+ ]
+ }{{range .Rows}},
+ {
+ "type": "tableRow",
+ "content": [
+ { "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [150] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .Name}} }] }] },
+ { "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [100] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .AssetType}} }] }] },
+ { "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [80] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json (printf "%d" .Amount)}} }] }] },
+ { "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [120] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .DataTypesStored}} }] }] },
+ { "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [150] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .Owner}} }] }] },
+ { "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [200] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .Vendors}} }] }] }
+ ]
+ }{{end}}
+ ]
+ },
+ { "type": "horizontalRule" },
+ {
+ "type": "heading",
+ "attrs": { "level": 1 },
+ "content": [{ "type": "text", "text": "3. Definitions" }]
+ },
+ {
+ "type": "heading",
+ "attrs": { "level": 3 },
+ "content": [{ "type": "text", "text": "Asset Type" }]
+ },
+ {
+ "type": "bulletList",
+ "content": [
+ { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Physical: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Tangible hardware assets such as servers, workstations, networking equipment, and storage devices." }] }] },
+ { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Virtual: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Software-based assets such as cloud services, virtual machines, SaaS applications, and databases." }] }] }
+ ]
+ },
+ {
+ "type": "heading",
+ "attrs": { "level": 3 },
+ "content": [{ "type": "text", "text": "Owner" }]
+ },
+ {
+ "type": "paragraph",
+ "content": [{ "type": "text", "text": "The individual responsible for the asset, including its maintenance, security, and compliance with applicable policies." }]
+ },
+ {
+ "type": "heading",
+ "attrs": { "level": 3 },
+ "content": [{ "type": "text", "text": "Vendors" }]
+ },
+ {
+ "type": "paragraph",
+ "content": [{ "type": "text", "text": "Third-party vendors that provide or support the asset." }]
+ }
+ ]
+}
diff --git a/pkg/server/api/console/v1/asset_resolvers.go b/pkg/server/api/console/v1/asset_resolvers.go
index ff11d067b..c2de7610e 100644
--- a/pkg/server/api/console/v1/asset_resolvers.go
+++ b/pkg/server/api/console/v1/asset_resolvers.go
@@ -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} }
diff --git a/pkg/server/api/console/v1/graphql/asset.graphql b/pkg/server/api/console/v1/graphql/asset.graphql
index 9876e5db6..eb48c630e 100644
--- a/pkg/server/api/console/v1/graphql/asset.graphql
+++ b/pkg/server/api/console/v1/graphql/asset.graphql
@@ -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!
+}
diff --git a/pkg/server/api/console/v1/graphql/organization.graphql b/pkg/server/api/console/v1/graphql/organization.graphql
index 1db67b72a..0db78280c 100644
--- a/pkg/server/api/console/v1/graphql/organization.graphql
+++ b/pkg/server/api/console/v1/graphql/organization.graphql
@@ -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)
diff --git a/pkg/server/api/console/v1/graphql/snapshot.graphql b/pkg/server/api/console/v1/graphql/snapshot.graphql
index 5d7c06c6a..858fcc1c0 100644
--- a/pkg/server/api/console/v1/graphql/snapshot.graphql
+++ b/pkg/server/api/console/v1/graphql/snapshot.graphql
@@ -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"
diff --git a/pkg/server/api/console/v1/organization_resolvers.go b/pkg/server/api/console/v1/organization_resolvers.go
index 8cf97b17b..4bbab9c0b 100644
--- a/pkg/server/api/console/v1/organization_resolvers.go
+++ b/pkg/server/api/console/v1/organization_resolvers.go
@@ -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.
diff --git a/pkg/server/api/console/v1/types/asset.go b/pkg/server/api/console/v1/types/asset.go
index 439e7e403..5da879d2e 100644
--- a/pkg/server/api/console/v1/types/asset.go
+++ b/pkg/server/api/console/v1/types/asset.go
@@ -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,
},
diff --git a/pkg/server/api/mcp/v1/schema.resolvers.go b/pkg/server/api/mcp/v1/schema.resolvers.go
index e6cdba84c..96855578e 100644
--- a/pkg/server/api/mcp/v1/schema.resolvers.go
+++ b/pkg/server/api/mcp/v1/schema.resolvers.go
@@ -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
+}
diff --git a/pkg/server/api/mcp/v1/specification.yaml b/pkg/server/api/mcp/v1/specification.yaml
index e03e1949c..5377601da 100644
--- a/pkg/server/api/mcp/v1/specification.yaml
+++ b/pkg/server/api/mcp/v1/specification.yaml
@@ -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:
diff --git a/pkg/server/api/mcp/v1/types/asset.go b/pkg/server/api/mcp/v1/types/asset.go
index 447efdf0c..a0e029c11 100644
--- a/pkg/server/api/mcp/v1/types/asset.go
+++ b/pkg/server/api/mcp/v1/types/asset.go
@@ -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 {
|