Assets as document: replace snapshot with publish workflow
Remove assets from the snapshot system and replace with a publish-based document workflow that generates versioned ProseMirror documents. - Remove snapshot_id/source_id from asset and asset_vendor models - Delete AssetFilter (no longer needed without snapshot filtering) - Add PublishAssetList service, GraphQL mutation, MCP tool, CLI command, and n8n operation - Add asset_list_document_id column to generated_documents table - Generate ProseMirror documents with asset inventory tables (name, type, amount, data types stored, owner, vendors) - Add AssetListDocument resolver on Organization type - Update frontend to remove snapshot routes/params and add publish dialog - Add e2e tests for asset publish (immediate, with approvers, reuse, RBAC) - Add migration script for converting legacy asset snapshots to documents - Exclude ASSETS from snapshot type lists and e2e snapshot tests - Move generated_documents SQL to coredata methods on Datum and Asset - Clear generated document and SOA references on soft delete and archive Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
@@ -16,7 +16,6 @@ import { faviconUrl, getAssetTypeVariant } from "@probo/helpers";
|
|||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import { Avatar, Badge, Tbody, Td, Th, Thead, Tr } from "@probo/ui";
|
import { Avatar, Badge, Tbody, Td, Th, Thead, Tr } from "@probo/ui";
|
||||||
import type { usePaginationFragmentHookType } from "react-relay/relay-hooks/usePaginationFragment";
|
import type { usePaginationFragmentHookType } from "react-relay/relay-hooks/usePaginationFragment";
|
||||||
import { useParams } from "react-router";
|
|
||||||
import type { OperationType } from "relay-runtime";
|
import type { OperationType } from "relay-runtime";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -66,17 +65,10 @@ export function ReadOnlyAssetsTable(props: Props) {
|
|||||||
function AssetRow({ entry }: { entry: AssetEntry }) {
|
function AssetRow({ entry }: { entry: AssetEntry }) {
|
||||||
const organizationId = useOrganizationId();
|
const organizationId = useOrganizationId();
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const { snapshotId } = useParams<{ snapshotId?: string }>();
|
|
||||||
const vendors = entry.vendors?.edges.map(edge => edge.node) ?? [];
|
const vendors = entry.vendors?.edges.map(edge => edge.node) ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tr
|
<Tr to={`/organizations/${organizationId}/assets/${entry.id}`}>
|
||||||
to={
|
|
||||||
snapshotId
|
|
||||||
? `/organizations/${organizationId}/snapshots/${snapshotId}/assets/${entry.id}`
|
|
||||||
: `/organizations/${organizationId}/assets/${entry.id}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Td>{entry.name}</Td>
|
<Td>{entry.name}</Td>
|
||||||
<Td>
|
<Td>
|
||||||
<Badge variant={getAssetTypeVariant(entry.assetType)}>
|
<Badge variant={getAssetTypeVariant(entry.assetType)}>
|
||||||
|
|||||||
@@ -18,16 +18,28 @@ import { useConfirm } from "@probo/ui";
|
|||||||
import { useMutation } from "react-relay";
|
import { useMutation } from "react-relay";
|
||||||
import { graphql } from "relay-runtime";
|
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 */
|
/* eslint-disable relay/unused-fields, relay/must-colocate-fragment-spreads */
|
||||||
|
|
||||||
export const assetsQuery = graphql`
|
export const assetsQuery = graphql`
|
||||||
query AssetGraphListQuery($organizationId: ID!, $snapshotId: ID) {
|
query AssetGraphListQuery($organizationId: ID!) {
|
||||||
node(id: $organizationId) {
|
node(id: $organizationId) {
|
||||||
... on Organization {
|
... on Organization {
|
||||||
canCreateAsset: permission(action: "core:asset:create")
|
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) {
|
node(id: $assetId) {
|
||||||
... on Asset {
|
... on Asset {
|
||||||
id
|
id
|
||||||
snapshotId
|
|
||||||
name
|
name
|
||||||
amount
|
amount
|
||||||
assetType
|
assetType
|
||||||
@@ -75,7 +86,6 @@ export const createAssetMutation = graphql`
|
|||||||
assetEdge @appendEdge(connections: $connections) {
|
assetEdge @appendEdge(connections: $connections) {
|
||||||
node {
|
node {
|
||||||
id
|
id
|
||||||
snapshotId
|
|
||||||
name
|
name
|
||||||
amount
|
amount
|
||||||
assetType
|
assetType
|
||||||
@@ -107,7 +117,6 @@ export const updateAssetMutation = graphql`
|
|||||||
updateAsset(input: $input) {
|
updateAsset(input: $input) {
|
||||||
asset {
|
asset {
|
||||||
id
|
id
|
||||||
snapshotId
|
|
||||||
name
|
name
|
||||||
amount
|
amount
|
||||||
assetType
|
assetType
|
||||||
@@ -146,8 +155,7 @@ export const useDeleteAsset = (
|
|||||||
asset: { id?: string; name?: string },
|
asset: { id?: string; name?: string },
|
||||||
connectionId: string,
|
connectionId: string,
|
||||||
) => {
|
) => {
|
||||||
// eslint-disable-next-line relay/generated-typescript-types
|
const [mutate] = useMutation<AssetGraphDeleteMutation>(deleteAssetMutation);
|
||||||
const [mutate] = useMutation(deleteAssetMutation);
|
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
|
|
||||||
@@ -160,7 +168,7 @@ export const useDeleteAsset = (
|
|||||||
promisifyMutation(mutate)({
|
promisifyMutation(mutate)({
|
||||||
variables: {
|
variables: {
|
||||||
input: {
|
input: {
|
||||||
assetId: asset.id,
|
assetId: asset.id!,
|
||||||
},
|
},
|
||||||
connections: [connectionId],
|
connections: [connectionId],
|
||||||
},
|
},
|
||||||
@@ -178,15 +186,14 @@ export const useDeleteAsset = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const useCreateAsset = (connectionId: string) => {
|
export const useCreateAsset = (connectionId: string) => {
|
||||||
// eslint-disable-next-line relay/generated-typescript-types
|
const [mutate, isMutating] = useMutation<AssetGraphCreateMutation>(createAssetMutation);
|
||||||
const [mutate, isMutating] = useMutation(createAssetMutation);
|
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
(input: {
|
(input: {
|
||||||
name: string;
|
name: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
assetType: string;
|
assetType: AssetType;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
vendorIds?: string[];
|
vendorIds?: string[];
|
||||||
@@ -228,16 +235,13 @@ export const useCreateAsset = (connectionId: string) => {
|
|||||||
|
|
||||||
export const useUpdateAsset = () => {
|
export const useUpdateAsset = () => {
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const [mutate] = useMutationWithToasts(updateAssetMutation, {
|
const [mutate] = useMutation<AssetGraphUpdateMutation>(updateAssetMutation);
|
||||||
successMessage: __("Asset updated successfully"),
|
|
||||||
errorMessage: __("Failed to update asset"),
|
|
||||||
});
|
|
||||||
|
|
||||||
return (input: {
|
return (input: {
|
||||||
id: string;
|
id: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
amount?: number;
|
amount?: number;
|
||||||
assetType?: string;
|
assetType?: AssetType;
|
||||||
dataTypesStored?: string;
|
dataTypesStored?: string;
|
||||||
ownerId?: string;
|
ownerId?: string;
|
||||||
vendorIds?: string[];
|
vendorIds?: string[];
|
||||||
@@ -246,7 +250,7 @@ export const useUpdateAsset = () => {
|
|||||||
return alert(__("Failed to update asset: asset ID is required"));
|
return alert(__("Failed to update asset: asset ID is required"));
|
||||||
}
|
}
|
||||||
|
|
||||||
return mutate({
|
return promisifyMutation(mutate)({
|
||||||
variables: {
|
variables: {
|
||||||
input,
|
input,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,10 +12,7 @@
|
|||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
import {
|
import { getAssetTypeVariant } from "@probo/helpers";
|
||||||
getAssetTypeVariant,
|
|
||||||
validateSnapshotConsistency,
|
|
||||||
} from "@probo/helpers";
|
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import {
|
import {
|
||||||
ActionDropdown,
|
ActionDropdown,
|
||||||
@@ -32,14 +29,12 @@ import {
|
|||||||
type PreloadedQuery,
|
type PreloadedQuery,
|
||||||
usePreloadedQuery,
|
usePreloadedQuery,
|
||||||
} from "react-relay";
|
} from "react-relay";
|
||||||
import { useParams } from "react-router";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import type { AssetGraphNodeQuery } from "#/__generated__/core/AssetGraphNodeQuery.graphql";
|
import type { AssetGraphNodeQuery } from "#/__generated__/core/AssetGraphNodeQuery.graphql";
|
||||||
import { ControlledField } from "#/components/form/ControlledField";
|
import { ControlledField } from "#/components/form/ControlledField";
|
||||||
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
|
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
|
||||||
import { VendorsMultiSelectField } from "#/components/form/VendorsMultiSelectField";
|
import { VendorsMultiSelectField } from "#/components/form/VendorsMultiSelectField";
|
||||||
import { SnapshotBanner } from "#/components/SnapshotBanner";
|
|
||||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||||
|
|
||||||
@@ -70,15 +65,10 @@ export default function AssetDetailsPage(props: Props) {
|
|||||||
const assetEntry = asset.node;
|
const assetEntry = asset.node;
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const organizationId = useOrganizationId();
|
const organizationId = useOrganizationId();
|
||||||
const { snapshotId } = useParams<{ snapshotId?: string }>();
|
|
||||||
const isSnapshotMode = Boolean(snapshotId);
|
|
||||||
|
|
||||||
validateSnapshotConsistency(assetEntry, snapshotId);
|
|
||||||
|
|
||||||
const connectionId = ConnectionHandler.getConnectionID(
|
const connectionId = ConnectionHandler.getConnectionID(
|
||||||
organizationId,
|
organizationId,
|
||||||
"AssetsPage_assets",
|
"AssetsPage_assets",
|
||||||
{ filter: { snapshotId: snapshotId || null } },
|
|
||||||
);
|
);
|
||||||
const deleteAsset = useDeleteAsset(assetEntry, connectionId);
|
const deleteAsset = useDeleteAsset(assetEntry, connectionId);
|
||||||
|
|
||||||
@@ -107,25 +97,19 @@ export default function AssetDetailsPage(props: Props) {
|
|||||||
reset(formData);
|
reset(formData);
|
||||||
});
|
});
|
||||||
|
|
||||||
const breadcrumbAssetsUrl
|
const breadcrumbItems = [
|
||||||
= isSnapshotMode && snapshotId
|
|
||||||
? `/organizations/${organizationId}/snapshots/${snapshotId}/assets`
|
|
||||||
: `/organizations/${organizationId}/assets`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{snapshotId && <SnapshotBanner snapshotId={snapshotId} />}
|
|
||||||
<Breadcrumb
|
|
||||||
items={[
|
|
||||||
{
|
{
|
||||||
label: __("Assets"),
|
label: __("Assets"),
|
||||||
to: breadcrumbAssetsUrl,
|
to: `/organizations/${organizationId}/assets`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: assetEntry?.name ?? "",
|
label: assetEntry?.name ?? "",
|
||||||
},
|
},
|
||||||
]}
|
];
|
||||||
/>
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Breadcrumb items={breadcrumbItems} />
|
||||||
|
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -138,7 +122,7 @@ export default function AssetDetailsPage(props: Props) {
|
|||||||
: __("Virtual")}
|
: __("Virtual")}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{!isSnapshotMode && asset.node.canDelete && (
|
{asset.node.canDelete && (
|
||||||
<ActionDropdown variant="secondary">
|
<ActionDropdown variant="secondary">
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
variant="danger"
|
variant="danger"
|
||||||
@@ -156,14 +140,14 @@ export default function AssetDetailsPage(props: Props) {
|
|||||||
label={__("Name")}
|
label={__("Name")}
|
||||||
{...register("name")}
|
{...register("name")}
|
||||||
type="text"
|
type="text"
|
||||||
disabled={isSnapshotMode}
|
disabled={!assetEntry.canUpdate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
label={__("Amount")}
|
label={__("Amount")}
|
||||||
{...register("amount", { valueAsNumber: true })}
|
{...register("amount", { valueAsNumber: true })}
|
||||||
type="number"
|
type="number"
|
||||||
disabled={isSnapshotMode}
|
disabled={!assetEntry.canUpdate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ControlledField
|
<ControlledField
|
||||||
@@ -171,7 +155,7 @@ export default function AssetDetailsPage(props: Props) {
|
|||||||
name="assetType"
|
name="assetType"
|
||||||
type="select"
|
type="select"
|
||||||
label={__("Asset Type")}
|
label={__("Asset Type")}
|
||||||
disabled={isSnapshotMode}
|
disabled={!assetEntry.canUpdate}
|
||||||
>
|
>
|
||||||
<Option value="VIRTUAL">{__("Virtual")}</Option>
|
<Option value="VIRTUAL">{__("Virtual")}</Option>
|
||||||
<Option value="PHYSICAL">{__("Physical")}</Option>
|
<Option value="PHYSICAL">{__("Physical")}</Option>
|
||||||
@@ -181,7 +165,7 @@ export default function AssetDetailsPage(props: Props) {
|
|||||||
label={__("Data Types Stored")}
|
label={__("Data Types Stored")}
|
||||||
{...register("dataTypesStored")}
|
{...register("dataTypesStored")}
|
||||||
type="text"
|
type="text"
|
||||||
disabled={isSnapshotMode}
|
disabled={!assetEntry.canUpdate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PeopleSelectField
|
<PeopleSelectField
|
||||||
@@ -189,7 +173,7 @@ export default function AssetDetailsPage(props: Props) {
|
|||||||
control={control}
|
control={control}
|
||||||
name="ownerId"
|
name="ownerId"
|
||||||
label={__("Owner")}
|
label={__("Owner")}
|
||||||
disabled={isSnapshotMode}
|
disabled={!assetEntry.canUpdate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<VendorsMultiSelectField
|
<VendorsMultiSelectField
|
||||||
@@ -198,11 +182,11 @@ export default function AssetDetailsPage(props: Props) {
|
|||||||
name="vendorIds"
|
name="vendorIds"
|
||||||
selectedVendors={vendors}
|
selectedVendors={vendors}
|
||||||
label={__("Vendors")}
|
label={__("Vendors")}
|
||||||
disabled={isSnapshotMode}
|
disabled={!assetEntry.canUpdate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
{formState.isDirty && !isSnapshotMode && asset.node.canUpdate && (
|
{formState.isDirty && assetEntry.canUpdate && (
|
||||||
<Button type="submit" disabled={formState.isSubmitting}>
|
<Button type="submit" disabled={formState.isSubmitting}>
|
||||||
{formState.isSubmitting ? __("Updating...") : __("Update")}
|
{formState.isSubmitting ? __("Updating...") : __("Update")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -14,19 +14,24 @@
|
|||||||
|
|
||||||
import { usePageTitle } from "@probo/hooks";
|
import { usePageTitle } from "@probo/hooks";
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import { Button, IconPlusLarge, PageHeader } from "@probo/ui";
|
import {
|
||||||
|
Button,
|
||||||
|
IconPageTextLine,
|
||||||
|
IconPlusLarge,
|
||||||
|
IconUpload,
|
||||||
|
PageHeader,
|
||||||
|
} from "@probo/ui";
|
||||||
import {
|
import {
|
||||||
graphql,
|
graphql,
|
||||||
type PreloadedQuery,
|
type PreloadedQuery,
|
||||||
usePaginationFragment,
|
usePaginationFragment,
|
||||||
usePreloadedQuery,
|
usePreloadedQuery,
|
||||||
} from "react-relay";
|
} from "react-relay";
|
||||||
import { useParams } from "react-router";
|
import { Link, useNavigate } from "react-router";
|
||||||
|
|
||||||
import type { AssetGraphListQuery } from "#/__generated__/core/AssetGraphListQuery.graphql";
|
import type { AssetGraphListQuery } from "#/__generated__/core/AssetGraphListQuery.graphql";
|
||||||
import type { AssetsListQuery } from "#/__generated__/core/AssetsListQuery.graphql";
|
import type { AssetsListQuery } from "#/__generated__/core/AssetsListQuery.graphql";
|
||||||
import type { AssetsPageFragment$key } from "#/__generated__/core/AssetsPageFragment.graphql";
|
import type { AssetsPageFragment$key } from "#/__generated__/core/AssetsPageFragment.graphql";
|
||||||
import { SnapshotBanner } from "#/components/SnapshotBanner";
|
|
||||||
import { assetsQuery } from "#/hooks/graph/AssetGraph";
|
import { assetsQuery } from "#/hooks/graph/AssetGraph";
|
||||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||||
|
|
||||||
@@ -34,6 +39,7 @@ import { AssetsTable } from "../../../components/assets/AssetsTable";
|
|||||||
import { ReadOnlyAssetsTable } from "../../../components/assets/ReadOnlyAssetsTable";
|
import { ReadOnlyAssetsTable } from "../../../components/assets/ReadOnlyAssetsTable";
|
||||||
|
|
||||||
import { CreateAssetDialog } from "./dialogs/CreateAssetDialog";
|
import { CreateAssetDialog } from "./dialogs/CreateAssetDialog";
|
||||||
|
import { PublishAssetListDialog } from "./dialogs/PublishAssetListDialog";
|
||||||
|
|
||||||
const paginatedAssetsFragment = graphql`
|
const paginatedAssetsFragment = graphql`
|
||||||
fragment AssetsPageFragment on Organization
|
fragment AssetsPageFragment on Organization
|
||||||
@@ -44,7 +50,6 @@ const paginatedAssetsFragment = graphql`
|
|||||||
after: { type: "CursorKey", defaultValue: null }
|
after: { type: "CursorKey", defaultValue: null }
|
||||||
before: { type: "CursorKey", defaultValue: null }
|
before: { type: "CursorKey", defaultValue: null }
|
||||||
last: { type: "Int", defaultValue: null }
|
last: { type: "Int", defaultValue: null }
|
||||||
snapshotId: { type: "ID", defaultValue: null }
|
|
||||||
) {
|
) {
|
||||||
assets(
|
assets(
|
||||||
first: $first
|
first: $first
|
||||||
@@ -52,14 +57,12 @@ const paginatedAssetsFragment = graphql`
|
|||||||
last: $last
|
last: $last
|
||||||
before: $before
|
before: $before
|
||||||
orderBy: $orderBy
|
orderBy: $orderBy
|
||||||
filter: { snapshotId: $snapshotId }
|
) @connection(key: "AssetsPage_assets") {
|
||||||
) @connection(key: "AssetsPage_assets", filters: ["filter"]) {
|
|
||||||
__id
|
__id
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
# eslint-disable-next-line relay/unused-fields
|
# eslint-disable-next-line relay/unused-fields
|
||||||
id
|
id
|
||||||
snapshotId
|
|
||||||
# eslint-disable-next-line relay/unused-fields
|
# eslint-disable-next-line relay/unused-fields
|
||||||
name
|
name
|
||||||
# eslint-disable-next-line relay/unused-fields
|
# eslint-disable-next-line relay/unused-fields
|
||||||
@@ -102,8 +105,7 @@ type Props = {
|
|||||||
export default function AssetsPage(props: Props) {
|
export default function AssetsPage(props: Props) {
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const organizationId = useOrganizationId();
|
const organizationId = useOrganizationId();
|
||||||
const { snapshotId } = useParams<{ snapshotId?: string }>();
|
const navigate = useNavigate();
|
||||||
const isSnapshotMode = Boolean(snapshotId);
|
|
||||||
|
|
||||||
const data = usePreloadedQuery<AssetGraphListQuery>(
|
const data = usePreloadedQuery<AssetGraphListQuery>(
|
||||||
assetsQuery,
|
assetsQuery,
|
||||||
@@ -115,20 +117,46 @@ export default function AssetsPage(props: Props) {
|
|||||||
);
|
);
|
||||||
const assets = pagination.data.assets?.edges.map(edge => edge.node);
|
const assets = pagination.data.assets?.edges.map(edge => edge.node);
|
||||||
const connectionId = pagination.data.assets.__id;
|
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);
|
const canWrite = assets.some(asset => asset.canDelete || asset.canUpdate);
|
||||||
usePageTitle(__("Assets"));
|
usePageTitle(__("Assets"));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{snapshotId && <SnapshotBanner snapshotId={snapshotId} />}
|
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={__("Assets")}
|
title={__("Assets")}
|
||||||
description={__(
|
description={__(
|
||||||
"Manage your organization's assets and their classifications.",
|
"Manage your organization's assets and their classifications.",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!isSnapshotMode && data.node.canCreateAsset && (
|
<div className="flex gap-2">
|
||||||
|
{data.node.assetListDocument?.id && (
|
||||||
|
<Button variant="secondary" asChild>
|
||||||
|
<Link
|
||||||
|
to={`/organizations/${organizationId}/documents/${data.node.assetListDocument.id}`}
|
||||||
|
>
|
||||||
|
<IconPageTextLine size={16} />
|
||||||
|
{__("Document")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{data.node.canPublishAssets && (
|
||||||
|
<PublishAssetListDialog
|
||||||
|
organizationId={organizationId}
|
||||||
|
defaultApproverIds={defaultApproverIds}
|
||||||
|
onPublished={(documentId) => {
|
||||||
|
void navigate(
|
||||||
|
`/organizations/${organizationId}/documents/${documentId}`,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button variant="secondary" icon={IconUpload}>
|
||||||
|
{__("Publish")}
|
||||||
|
</Button>
|
||||||
|
</PublishAssetListDialog>
|
||||||
|
)}
|
||||||
|
{data.node.canCreateAsset && (
|
||||||
<CreateAssetDialog
|
<CreateAssetDialog
|
||||||
connection={connectionId}
|
connection={connectionId}
|
||||||
organizationId={organizationId}
|
organizationId={organizationId}
|
||||||
@@ -136,8 +164,9 @@ export default function AssetsPage(props: Props) {
|
|||||||
<Button icon={IconPlusLarge}>{__("Add asset")}</Button>
|
<Button icon={IconPlusLarge}>{__("Add asset")}</Button>
|
||||||
</CreateAssetDialog>
|
</CreateAssetDialog>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
{isSnapshotMode || !canWrite
|
{!canWrite
|
||||||
? (
|
? (
|
||||||
<ReadOnlyAssetsTable pagination={pagination} assets={assets} />
|
<ReadOnlyAssetsTable pagination={pagination} assets={assets} />
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
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<PublishAssetListDialogMutation>(publishMutation);
|
||||||
|
|
||||||
|
const approverIds = watch("approverIds");
|
||||||
|
const hasApprovers = approverIds.length > 0;
|
||||||
|
|
||||||
|
const onSubmit = (data: z.infer<typeof schema>) => {
|
||||||
|
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 (
|
||||||
|
<Dialog
|
||||||
|
className="max-w-xl"
|
||||||
|
ref={dialogRef}
|
||||||
|
trigger={children}
|
||||||
|
title={__("Publish Asset List")}
|
||||||
|
>
|
||||||
|
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
|
||||||
|
<DialogContent padded>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-txt-secondary">
|
||||||
|
{__("Select approvers to request approval before publishing, or publish directly without approvers.")}
|
||||||
|
</p>
|
||||||
|
<PeopleMultiSelectField
|
||||||
|
name="approverIds"
|
||||||
|
label={__("Approvers")}
|
||||||
|
control={control}
|
||||||
|
organizationId={organizationId}
|
||||||
|
placeholder={__("Add approvers...")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
icon={hasApprovers ? IconSend : IconUpload}
|
||||||
|
disabled={isPublishing}
|
||||||
|
>
|
||||||
|
{hasApprovers ? __("Request approval") : __("Publish")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -33,21 +33,7 @@ export const assetRoutes = [
|
|||||||
Fallback: PageSkeleton,
|
Fallback: PageSkeleton,
|
||||||
loader: loaderFromQueryLoader(({ organizationId }) =>
|
loader: loaderFromQueryLoader(({ organizationId }) =>
|
||||||
loadQuery<AssetGraphListQuery>(coreEnvironment, assetsQuery, {
|
loadQuery<AssetGraphListQuery>(coreEnvironment, assetsQuery, {
|
||||||
organizationId: organizationId,
|
organizationId,
|
||||||
snapshotId: null,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
Component: withQueryRef(
|
|
||||||
lazy(() => import("#/pages/organizations/assets/AssetsPage")),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "snapshots/:snapshotId/assets",
|
|
||||||
Fallback: PageSkeleton,
|
|
||||||
loader: loaderFromQueryLoader(({ organizationId, snapshotId }) =>
|
|
||||||
loadQuery<AssetGraphListQuery>(coreEnvironment, assetsQuery, {
|
|
||||||
organizationId: organizationId,
|
|
||||||
snapshotId: snapshotId,
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
Component: withQueryRef(
|
Component: withQueryRef(
|
||||||
@@ -66,16 +52,4 @@ export const assetRoutes = [
|
|||||||
lazy(() => import("#/pages/organizations/assets/AssetDetailsPage")),
|
lazy(() => import("#/pages/organizations/assets/AssetDetailsPage")),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "snapshots/:snapshotId/assets/:assetId",
|
|
||||||
Fallback: PageSkeleton,
|
|
||||||
loader: loaderFromQueryLoader(({ assetId }) =>
|
|
||||||
loadQuery<AssetGraphNodeQuery>(coreEnvironment, assetNodeQuery, {
|
|
||||||
assetId,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
Component: withQueryRef(
|
|
||||||
lazy(() => import("#/pages/organizations/assets/AssetDetailsPage")),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
] satisfies AppRoute[];
|
] satisfies AppRoute[];
|
||||||
|
|||||||
434
cmd/migrate-asset-snapshots-to-documents/main.go
Normal file
434
cmd/migrate-asset-snapshots-to-documents/main.go
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
// Command migrate-asset-snapshots-to-documents creates documents and document
|
||||||
|
// versions from existing asset snapshots. For each organization that has asset
|
||||||
|
// snapshots, it generates an asset list document using the same ProseMirror
|
||||||
|
// builder as the publish flow.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"net/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...)
|
||||||
|
}
|
||||||
@@ -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.
|
Filters implement `SQLFragment() string` and `SQLArguments() pgx.NamedArgs`. Use double pointers for three-state filtering: `nil` = no filter, `*nil` = IS NULL, `*val` = equals.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type AssetFilter struct {
|
type CookieBannerFilter struct {
|
||||||
snapshotID **gid.GID
|
state *CookieBannerState
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAssetFilter(snapshotID **gid.GID) *AssetFilter {
|
func NewCookieBannerFilter(state *CookieBannerState) *CookieBannerFilter {
|
||||||
return &AssetFilter{snapshotID: snapshotID}
|
return &CookieBannerFilter{state: state}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *AssetFilter) SQLFragment() string {
|
func (f *CookieBannerFilter) SQLFragment() string {
|
||||||
if f.snapshotID == nil {
|
return `(
|
||||||
return "TRUE"
|
CASE
|
||||||
|
WHEN @filter_state::text IS NOT NULL THEN
|
||||||
|
state = @filter_state::cookie_banner_state
|
||||||
|
ELSE TRUE
|
||||||
|
END
|
||||||
|
)`
|
||||||
}
|
}
|
||||||
|
|
||||||
if *f.snapshotID == nil {
|
func (f *CookieBannerFilter) SQLArguments() pgx.StrictNamedArgs {
|
||||||
return "snapshot_id IS NULL"
|
args := pgx.StrictNamedArgs{"filter_state": nil}
|
||||||
|
if f.state != nil {
|
||||||
|
args["filter_state"] = string(*f.state)
|
||||||
}
|
}
|
||||||
|
return args
|
||||||
return "snapshot_id = @filter_snapshot_id"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *AssetFilter) SQLArguments() pgx.NamedArgs {
|
|
||||||
if f.snapshotID == nil || *f.snapshotID == nil {
|
|
||||||
return pgx.NamedArgs{}
|
|
||||||
}
|
|
||||||
|
|
||||||
return pgx.NamedArgs{"filter_snapshot_id": **f.snapshotID}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
406
e2e/console/asset_publish_test.go
Normal file
406
e2e/console/asset_publish_test.go
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -104,7 +104,7 @@ func TestSnapshot_Delete(t *testing.T) {
|
|||||||
"input": map[string]any{
|
"input": map[string]any{
|
||||||
"organizationId": owner.GetOrganizationID().String(),
|
"organizationId": owner.GetOrganizationID().String(),
|
||||||
"name": fmt.Sprintf("Snapshot to Delete %d", time.Now().UnixNano()),
|
"name": fmt.Sprintf("Snapshot to Delete %d", time.Now().UnixNano()),
|
||||||
"type": "ASSETS",
|
"type": "VENDORS",
|
||||||
},
|
},
|
||||||
}, &createResult)
|
}, &createResult)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -140,7 +140,7 @@ func TestSnapshot_List(t *testing.T) {
|
|||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
|
|
||||||
// Create multiple snapshots
|
// Create multiple snapshots
|
||||||
snapshotTypes := []string{"RISKS", "VENDORS", "ASSETS"}
|
snapshotTypes := []string{"RISKS", "VENDORS"}
|
||||||
for i, snapshotType := range snapshotTypes {
|
for i, snapshotType := range snapshotTypes {
|
||||||
query := `
|
query := `
|
||||||
mutation CreateSnapshot($input: CreateSnapshotInput!) {
|
mutation CreateSnapshot($input: CreateSnapshotInput!) {
|
||||||
@@ -219,7 +219,7 @@ func TestSnapshot_Types(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
|
|
||||||
snapshotTypes := []string{"RISKS", "VENDORS", "ASSETS"}
|
snapshotTypes := []string{"RISKS", "VENDORS"}
|
||||||
|
|
||||||
for _, snapshotType := range snapshotTypes {
|
for _, snapshotType := range snapshotTypes {
|
||||||
t.Run(snapshotType, func(t *testing.T) {
|
t.Run(snapshotType, func(t *testing.T) {
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ type Translator = (s: string) => string;
|
|||||||
export const snapshotTypes = [
|
export const snapshotTypes = [
|
||||||
"RISKS",
|
"RISKS",
|
||||||
"VENDORS",
|
"VENDORS",
|
||||||
"ASSETS",
|
|
||||||
"FINDINGS",
|
"FINDINGS",
|
||||||
"OBLIGATIONS",
|
"OBLIGATIONS",
|
||||||
"PROCESSING_ACTIVITIES",
|
"PROCESSING_ACTIVITIES",
|
||||||
@@ -33,8 +32,6 @@ export function getSnapshotTypeLabel(__: Translator, type: string | null | undef
|
|||||||
return __("Risks");
|
return __("Risks");
|
||||||
case "VENDORS":
|
case "VENDORS":
|
||||||
return __("Vendors");
|
return __("Vendors");
|
||||||
case "ASSETS":
|
|
||||||
return __("Assets");
|
|
||||||
case "FINDINGS":
|
case "FINDINGS":
|
||||||
case "NONCONFORMITIES":
|
case "NONCONFORMITIES":
|
||||||
case "CONTINUAL_IMPROVEMENTS":
|
case "CONTINUAL_IMPROVEMENTS":
|
||||||
@@ -54,8 +51,6 @@ export function getSnapshotTypeUrlPath(type?: string): string {
|
|||||||
return "/risks";
|
return "/risks";
|
||||||
case "VENDORS":
|
case "VENDORS":
|
||||||
return "/vendors";
|
return "/vendors";
|
||||||
case "ASSETS":
|
|
||||||
return "/assets";
|
|
||||||
case "FINDINGS":
|
case "FINDINGS":
|
||||||
case "NONCONFORMITIES":
|
case "NONCONFORMITIES":
|
||||||
case "CONTINUAL_IMPROVEMENTS":
|
case "CONTINUAL_IMPROVEMENTS":
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import * as updateOp from './update.operation';
|
|||||||
import * as deleteOp from './delete.operation';
|
import * as deleteOp from './delete.operation';
|
||||||
import * as getOp from './get.operation';
|
import * as getOp from './get.operation';
|
||||||
import * as getAllOp from './getAll.operation';
|
import * as getAllOp from './getAll.operation';
|
||||||
|
import * as publishOp from './publish.operation';
|
||||||
|
|
||||||
export const description: INodeProperties[] = [
|
export const description: INodeProperties[] = [
|
||||||
{
|
{
|
||||||
@@ -55,6 +56,12 @@ export const description: INodeProperties[] = [
|
|||||||
description: 'Get many assets',
|
description: 'Get many assets',
|
||||||
action: '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',
|
name: 'Update',
|
||||||
value: 'update',
|
value: 'update',
|
||||||
@@ -69,6 +76,7 @@ export const description: INodeProperties[] = [
|
|||||||
...deleteOp.description,
|
...deleteOp.description,
|
||||||
...getOp.description,
|
...getOp.description,
|
||||||
...getAllOp.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 };
|
||||||
|
|||||||
101
packages/n8n-node/nodes/Probo/actions/asset/publish.operation.ts
Normal file
101
packages/n8n-node/nodes/Probo/actions/asset/publish.operation.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
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<INodeExecutionData> {
|
||||||
|
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<string, unknown> = { 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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
|
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||||
//
|
//
|
||||||
// Permission to use, copy, modify, and/or distribute this software for any
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
// purpose with or without fee is hereby granted, provided that the above
|
// 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
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
package coredata
|
package asset
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/spf13/cobra"
|
||||||
"go.probo.inc/probo/pkg/gid"
|
"go.probo.inc/probo/pkg/cmd/asset/publish"
|
||||||
|
"go.probo.inc/probo/pkg/cmd/cmdutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
func NewCmdAsset(f *cmdutil.Factory) *cobra.Command {
|
||||||
AssetFilter struct {
|
cmd := &cobra.Command{
|
||||||
snapshotID **gid.GID
|
Use: "asset <command>",
|
||||||
}
|
Short: "Manage assets",
|
||||||
)
|
|
||||||
|
|
||||||
func NewAssetFilter(snapshotID **gid.GID) *AssetFilter {
|
|
||||||
return &AssetFilter{
|
|
||||||
snapshotID: snapshotID,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *AssetFilter) SQLArguments() pgx.NamedArgs {
|
cmd.AddCommand(publish.NewCmdPublish(f))
|
||||||
args := pgx.NamedArgs{}
|
|
||||||
|
|
||||||
if f.snapshotID != nil && *f.snapshotID != nil {
|
return cmd
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
147
pkg/cmd/asset/publish/publish.go
Normal file
147
pkg/cmd/asset/publish/publish.go
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
accessreview "go.probo.inc/probo/pkg/cmd/access-review"
|
accessreview "go.probo.inc/probo/pkg/cmd/access-review"
|
||||||
cmdapi "go.probo.inc/probo/pkg/cmd/api"
|
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/auditlog"
|
||||||
"go.probo.inc/probo/pkg/cmd/auth"
|
"go.probo.inc/probo/pkg/cmd/auth"
|
||||||
"go.probo.inc/probo/pkg/cmd/browse"
|
"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(accessreview.NewCmdAccessReview(f))
|
||||||
cmd.AddCommand(cmdapi.NewCmdAPI(f))
|
cmd.AddCommand(cmdapi.NewCmdAPI(f))
|
||||||
|
cmd.AddCommand(asset.NewCmdAsset(f))
|
||||||
cmd.AddCommand(auditlog.NewCmdAuditLog(f))
|
cmd.AddCommand(auditlog.NewCmdAuditLog(f))
|
||||||
cmd.AddCommand(auth.NewCmdAuth(f))
|
cmd.AddCommand(auth.NewCmdAuth(f))
|
||||||
cmd.AddCommand(browse.NewCmdBrowse(f))
|
cmd.AddCommand(browse.NewCmdBrowse(f))
|
||||||
|
|||||||
@@ -30,8 +30,6 @@ import (
|
|||||||
type (
|
type (
|
||||||
Asset struct {
|
Asset struct {
|
||||||
ID gid.GID `db:"id"`
|
ID gid.GID `db:"id"`
|
||||||
SnapshotID *gid.GID `db:"snapshot_id"`
|
|
||||||
SourceID *gid.GID `db:"source_id"`
|
|
||||||
Name string `db:"name"`
|
Name string `db:"name"`
|
||||||
Amount int `db:"amount"`
|
Amount int `db:"amount"`
|
||||||
OwnerID gid.GID `db:"owner_profile_id"`
|
OwnerID gid.GID `db:"owner_profile_id"`
|
||||||
@@ -80,8 +78,6 @@ func (a *Asset) LoadByID(
|
|||||||
q := `
|
q := `
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
snapshot_id,
|
|
||||||
source_id,
|
|
||||||
name,
|
name,
|
||||||
organization_id,
|
organization_id,
|
||||||
owner_profile_id,
|
owner_profile_id,
|
||||||
@@ -95,6 +91,7 @@ FROM
|
|||||||
WHERE
|
WHERE
|
||||||
%s
|
%s
|
||||||
AND id = @asset_id
|
AND id = @asset_id
|
||||||
|
AND snapshot_id IS NULL
|
||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -130,8 +127,6 @@ func (a *Asset) LoadByOwnerID(
|
|||||||
q := `
|
q := `
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
snapshot_id,
|
|
||||||
source_id,
|
|
||||||
name,
|
name,
|
||||||
organization_id,
|
organization_id,
|
||||||
owner_profile_id,
|
owner_profile_id,
|
||||||
@@ -145,6 +140,7 @@ FROM
|
|||||||
WHERE
|
WHERE
|
||||||
%s
|
%s
|
||||||
AND owner_profile_id = @owner_profile_id
|
AND owner_profile_id = @owner_profile_id
|
||||||
|
AND snapshot_id IS NULL
|
||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -177,7 +173,6 @@ func (a *Assets) CountByOrganizationID(
|
|||||||
conn pg.Querier,
|
conn pg.Querier,
|
||||||
scope Scoper,
|
scope Scoper,
|
||||||
organizationID gid.GID,
|
organizationID gid.GID,
|
||||||
filter *AssetFilter,
|
|
||||||
) (int, error) {
|
) (int, error) {
|
||||||
q := `
|
q := `
|
||||||
SELECT
|
SELECT
|
||||||
@@ -187,14 +182,13 @@ FROM
|
|||||||
WHERE
|
WHERE
|
||||||
%s
|
%s
|
||||||
AND organization_id = @organization_id
|
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}
|
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
||||||
maps.Copy(args, scope.SQLArguments())
|
maps.Copy(args, scope.SQLArguments())
|
||||||
maps.Copy(args, filter.SQLArguments())
|
|
||||||
|
|
||||||
row := conn.QueryRow(ctx, q, args)
|
row := conn.QueryRow(ctx, q, args)
|
||||||
|
|
||||||
@@ -212,13 +206,10 @@ func (a *Assets) LoadByOrganizationID(
|
|||||||
scope Scoper,
|
scope Scoper,
|
||||||
organizationID gid.GID,
|
organizationID gid.GID,
|
||||||
cursor *page.Cursor[AssetOrderField],
|
cursor *page.Cursor[AssetOrderField],
|
||||||
filter *AssetFilter,
|
|
||||||
) error {
|
) error {
|
||||||
q := `
|
q := `
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
snapshot_id,
|
|
||||||
source_id,
|
|
||||||
name,
|
name,
|
||||||
organization_id,
|
organization_id,
|
||||||
owner_profile_id,
|
owner_profile_id,
|
||||||
@@ -232,15 +223,14 @@ FROM
|
|||||||
WHERE
|
WHERE
|
||||||
%s
|
%s
|
||||||
AND organization_id = @organization_id
|
AND organization_id = @organization_id
|
||||||
AND %s
|
AND snapshot_id IS NULL
|
||||||
AND %s
|
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}
|
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
||||||
maps.Copy(args, scope.SQLArguments())
|
maps.Copy(args, scope.SQLArguments())
|
||||||
maps.Copy(args, filter.SQLArguments())
|
|
||||||
maps.Copy(args, cursor.SQLArguments())
|
maps.Copy(args, cursor.SQLArguments())
|
||||||
|
|
||||||
rows, err := conn.Query(ctx, q, args)
|
rows, err := conn.Query(ctx, q, args)
|
||||||
@@ -258,6 +248,53 @@ WHERE
|
|||||||
return nil
|
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(
|
func (a *Asset) Insert(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
conn pg.Tx,
|
conn pg.Tx,
|
||||||
@@ -330,8 +367,6 @@ WHERE
|
|||||||
AND snapshot_id IS NULL
|
AND snapshot_id IS NULL
|
||||||
RETURNING
|
RETURNING
|
||||||
id,
|
id,
|
||||||
snapshot_id,
|
|
||||||
source_id,
|
|
||||||
name,
|
name,
|
||||||
organization_id,
|
organization_id,
|
||||||
owner_profile_id,
|
owner_profile_id,
|
||||||
@@ -396,75 +431,108 @@ WHERE
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (assets Assets) Snapshot(ctx context.Context, conn pg.Tx, scope Scoper, organizationID, snapshotID gid.GID) error {
|
func (a Asset) GetGeneratedDocumentID(
|
||||||
snapshotters := []AssetSnapshotter{Assets{}, Vendors{}, AssetVendors{}}
|
ctx context.Context,
|
||||||
|
conn pg.Querier,
|
||||||
|
organizationID gid.GID,
|
||||||
|
) (*gid.GID, error) {
|
||||||
|
var documentID *gid.GID
|
||||||
|
|
||||||
for _, snapshotter := range snapshotters {
|
err := conn.QueryRow(
|
||||||
if err := snapshotter.InsertAssetSnapshots(ctx, conn, scope, organizationID, snapshotID); err != nil {
|
ctx,
|
||||||
return fmt.Errorf("cannot create asset snapshots: (%T) %w", snapshotter, err)
|
`
|
||||||
|
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,
|
ctx context.Context,
|
||||||
conn pg.Tx,
|
conn pg.Tx,
|
||||||
scope Scoper,
|
|
||||||
organizationID gid.GID,
|
organizationID gid.GID,
|
||||||
snapshotID gid.GID,
|
tenantID gid.TenantID,
|
||||||
|
documentID gid.GID,
|
||||||
) error {
|
) error {
|
||||||
query := `
|
now := time.Now()
|
||||||
WITH
|
|
||||||
source_assets AS (
|
_, err := conn.Exec(
|
||||||
SELECT *
|
ctx,
|
||||||
FROM assets
|
`
|
||||||
WHERE %s AND organization_id = @organization_id AND snapshot_id IS NULL
|
INSERT INTO generated_documents (
|
||||||
)
|
|
||||||
INSERT INTO assets (
|
|
||||||
tenant_id,
|
|
||||||
id,
|
|
||||||
snapshot_id,
|
|
||||||
source_id,
|
|
||||||
name,
|
|
||||||
organization_id,
|
organization_id,
|
||||||
owner_profile_id,
|
tenant_id,
|
||||||
amount,
|
asset_list_document_id,
|
||||||
asset_type,
|
|
||||||
data_types_stored,
|
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
)
|
) VALUES (
|
||||||
SELECT
|
@organization_id,
|
||||||
@tenant_id,
|
@tenant_id,
|
||||||
generate_gid(decode_base64_unpadded(@tenant_id), @asset_entity_type),
|
@asset_list_document_id,
|
||||||
@snapshot_id,
|
@created_at,
|
||||||
a.id,
|
@updated_at
|
||||||
a.name,
|
)
|
||||||
a.organization_id,
|
ON CONFLICT (organization_id) DO UPDATE
|
||||||
a.owner_profile_id,
|
SET
|
||||||
a.amount,
|
asset_list_document_id = @asset_list_document_id,
|
||||||
a.asset_type,
|
updated_at = @updated_at
|
||||||
a.data_types_stored,
|
`,
|
||||||
a.created_at,
|
pgx.NamedArgs{
|
||||||
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,
|
"organization_id": organizationID,
|
||||||
"asset_entity_type": AssetEntityType,
|
"tenant_id": tenantID,
|
||||||
}
|
"asset_list_document_id": documentID,
|
||||||
maps.Copy(args, scope.SQLArguments())
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
_, err := conn.Exec(ctx, query, args)
|
},
|
||||||
|
)
|
||||||
if err != nil {
|
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
|
return nil
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ package coredata
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
@@ -30,15 +29,10 @@ type (
|
|||||||
AssetID gid.GID `db:"asset_id"`
|
AssetID gid.GID `db:"asset_id"`
|
||||||
VendorID gid.GID `db:"vendor_id"`
|
VendorID gid.GID `db:"vendor_id"`
|
||||||
TenantID gid.TenantID `db:"tenant_id"`
|
TenantID gid.TenantID `db:"tenant_id"`
|
||||||
SnapshotID *gid.GID `db:"snapshot_id"`
|
|
||||||
CreatedAt time.Time `db:"created_at"`
|
CreatedAt time.Time `db:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
AssetVendors []*AssetVendor
|
AssetVendors []*AssetVendor
|
||||||
|
|
||||||
AssetSnapshotter interface {
|
|
||||||
InsertAssetSnapshots(ctx context.Context, conn pg.Tx, scope Scoper, organizationID, snapshotID gid.GID) error
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (av AssetVendors) Merge(
|
func (av AssetVendors) Merge(
|
||||||
@@ -124,61 +118,3 @@ FROM vendor_ids
|
|||||||
|
|
||||||
return nil
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -403,3 +403,110 @@ WHERE
|
|||||||
|
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
16
pkg/coredata/migrations/20260420T140000Z.sql
Normal file
16
pkg/coredata/migrations/20260420T140000Z.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
-- Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||||
|
--
|
||||||
|
-- Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
-- purpose with or without fee is hereby granted, provided that the above
|
||||||
|
-- copyright notice and this permission notice appear in all copies.
|
||||||
|
--
|
||||||
|
-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
-- PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
ALTER TABLE generated_documents
|
||||||
|
ADD COLUMN asset_list_document_id TEXT REFERENCES documents(id) ON DELETE SET NULL;
|
||||||
@@ -124,7 +124,7 @@ FROM
|
|||||||
WHERE
|
WHERE
|
||||||
%s
|
%s
|
||||||
AND organization_id = @organization_id
|
AND organization_id = @organization_id
|
||||||
AND type NOT IN ('STATEMENTS_OF_APPLICABILITY', 'DATA')
|
AND type NOT IN ('STATEMENTS_OF_APPLICABILITY', 'DATA', 'ASSETS')
|
||||||
AND %s
|
AND %s
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -164,7 +164,7 @@ FROM
|
|||||||
WHERE
|
WHERE
|
||||||
%s
|
%s
|
||||||
AND organization_id = @organization_id
|
AND organization_id = @organization_id
|
||||||
AND type NOT IN ('STATEMENTS_OF_APPLICABILITY', 'DATA')
|
AND type NOT IN ('STATEMENTS_OF_APPLICABILITY', 'DATA', 'ASSETS')
|
||||||
AND %s
|
AND %s
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ func SnapshotsTypes() []SnapshotsType {
|
|||||||
return []SnapshotsType{
|
return []SnapshotsType{
|
||||||
SnapshotsTypeRisks,
|
SnapshotsTypeRisks,
|
||||||
SnapshotsTypeVendors,
|
SnapshotsTypeVendors,
|
||||||
SnapshotsTypeAssets,
|
|
||||||
SnapshotsTypeFindings,
|
SnapshotsTypeFindings,
|
||||||
SnapshotsTypeObligations,
|
SnapshotsTypeObligations,
|
||||||
SnapshotsTypeProcessingActivities,
|
SnapshotsTypeProcessingActivities,
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ type Snapshottable interface {
|
|||||||
|
|
||||||
func GetSnapshottable(snapshotType SnapshotsType) (Snapshottable, error) {
|
func GetSnapshottable(snapshotType SnapshotsType) (Snapshottable, error) {
|
||||||
switch snapshotType {
|
switch snapshotType {
|
||||||
case SnapshotsTypeAssets:
|
|
||||||
return Assets{}, nil
|
|
||||||
case SnapshotsTypeRisks:
|
case SnapshotsTypeRisks:
|
||||||
return Risks{}, nil
|
return Risks{}, nil
|
||||||
case SnapshotsTypeFindings:
|
case SnapshotsTypeFindings:
|
||||||
|
|||||||
@@ -313,3 +313,36 @@ WHERE
|
|||||||
|
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1077,64 +1077,17 @@ ORDER BY
|
|||||||
return vendorMap, nil
|
return vendorMap, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (vs Vendors) InsertAssetSnapshots(
|
func (vs *Vendors) LoadAllByAssetID(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
conn pg.Tx,
|
conn pg.Querier,
|
||||||
scope Scoper,
|
scope Scoper,
|
||||||
organizationID gid.GID,
|
assetID gid.GID,
|
||||||
snapshotID gid.GID,
|
|
||||||
) error {
|
) error {
|
||||||
query := `
|
q := `
|
||||||
WITH
|
WITH vend AS (
|
||||||
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,
|
|
||||||
id,
|
|
||||||
snapshot_id,
|
|
||||||
source_id,
|
|
||||||
organization_id,
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
category,
|
|
||||||
headquarter_address,
|
|
||||||
legal_name,
|
|
||||||
website_url,
|
|
||||||
privacy_policy_url,
|
|
||||||
service_level_agreement_url,
|
|
||||||
data_processing_agreement_url,
|
|
||||||
business_associate_agreement_url,
|
|
||||||
subprocessors_list_url,
|
|
||||||
certifications,
|
|
||||||
countries,
|
|
||||||
business_owner_profile_id,
|
|
||||||
security_owner_profile_id,
|
|
||||||
status_page_url,
|
|
||||||
terms_of_service_url,
|
|
||||||
security_page_url,
|
|
||||||
trust_page_url,
|
|
||||||
show_on_trust_center,
|
|
||||||
created_at,
|
|
||||||
updated_at
|
|
||||||
)
|
|
||||||
SELECT
|
SELECT
|
||||||
@tenant_id,
|
|
||||||
generate_gid(decode_base64_unpadded(@tenant_id), @vendor_entity_type),
|
|
||||||
@snapshot_id,
|
|
||||||
v.id,
|
v.id,
|
||||||
|
v.tenant_id,
|
||||||
v.organization_id,
|
v.organization_id,
|
||||||
v.name,
|
v.name,
|
||||||
v.description,
|
v.description,
|
||||||
@@ -1156,26 +1109,67 @@ SELECT
|
|||||||
v.security_page_url,
|
v.security_page_url,
|
||||||
v.trust_page_url,
|
v.trust_page_url,
|
||||||
v.show_on_trust_center,
|
v.show_on_trust_center,
|
||||||
|
v.snapshot_id,
|
||||||
|
v.source_id,
|
||||||
v.created_at,
|
v.created_at,
|
||||||
v.updated_at
|
v.updated_at
|
||||||
FROM source_vendors v
|
FROM
|
||||||
|
vendors v
|
||||||
|
INNER JOIN
|
||||||
|
asset_vendors av ON v.id = av.vendor_id
|
||||||
|
WHERE
|
||||||
|
av.asset_id = @asset_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
tenant_id,
|
||||||
|
organization_id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
category,
|
||||||
|
headquarter_address,
|
||||||
|
legal_name,
|
||||||
|
website_url,
|
||||||
|
privacy_policy_url,
|
||||||
|
service_level_agreement_url,
|
||||||
|
data_processing_agreement_url,
|
||||||
|
business_associate_agreement_url,
|
||||||
|
subprocessors_list_url,
|
||||||
|
certifications,
|
||||||
|
countries,
|
||||||
|
business_owner_profile_id,
|
||||||
|
security_owner_profile_id,
|
||||||
|
status_page_url,
|
||||||
|
terms_of_service_url,
|
||||||
|
security_page_url,
|
||||||
|
trust_page_url,
|
||||||
|
show_on_trust_center,
|
||||||
|
snapshot_id,
|
||||||
|
source_id,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM
|
||||||
|
vend
|
||||||
|
WHERE %s
|
||||||
|
ORDER BY name ASC
|
||||||
`
|
`
|
||||||
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||||
|
|
||||||
query = fmt.Sprintf(query, scope.SQLFragment())
|
args := pgx.StrictNamedArgs{"asset_id": assetID}
|
||||||
|
|
||||||
args := pgx.StrictNamedArgs{
|
|
||||||
"tenant_id": scope.GetTenantID(),
|
|
||||||
"snapshot_id": snapshotID,
|
|
||||||
"organization_id": organizationID,
|
|
||||||
"vendor_entity_type": VendorEntityType,
|
|
||||||
}
|
|
||||||
maps.Copy(args, scope.SQLArguments())
|
maps.Copy(args, scope.SQLArguments())
|
||||||
|
|
||||||
_, err := conn.Exec(ctx, query, args)
|
rows, err := conn.Query(ctx, q, args)
|
||||||
if err != nil {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -311,6 +311,23 @@ type (
|
|||||||
Owner string
|
Owner string
|
||||||
Vendors 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 {
|
func BoolLabel(v bool) string {
|
||||||
|
|||||||
@@ -232,6 +232,7 @@ const (
|
|||||||
ActionAssetCreate = "core:asset:create"
|
ActionAssetCreate = "core:asset:create"
|
||||||
ActionAssetUpdate = "core:asset:update"
|
ActionAssetUpdate = "core:asset:update"
|
||||||
ActionAssetDelete = "core:asset:delete"
|
ActionAssetDelete = "core:asset:delete"
|
||||||
|
ActionAssetPublish = "core:asset:publish"
|
||||||
|
|
||||||
// Datum actions
|
// Datum actions
|
||||||
ActionDatumGet = "core:datum:get"
|
ActionDatumGet = "core:datum:get"
|
||||||
|
|||||||
@@ -125,7 +125,6 @@ func (s AssetService) GetByOwnerID(
|
|||||||
func (s AssetService) CountForOrganizationID(
|
func (s AssetService) CountForOrganizationID(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
organizationID gid.GID,
|
organizationID gid.GID,
|
||||||
filter *coredata.AssetFilter,
|
|
||||||
) (int, error) {
|
) (int, error) {
|
||||||
var count int
|
var count int
|
||||||
|
|
||||||
@@ -133,7 +132,7 @@ func (s AssetService) CountForOrganizationID(
|
|||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Querier) (err error) {
|
func(ctx context.Context, conn pg.Querier) (err error) {
|
||||||
assets := coredata.Assets{}
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot count assets: %w", err)
|
return fmt.Errorf("cannot count assets: %w", err)
|
||||||
}
|
}
|
||||||
@@ -153,7 +152,6 @@ func (s AssetService) ListForOrganizationID(
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
organizationID gid.GID,
|
organizationID gid.GID,
|
||||||
cursor *page.Cursor[coredata.AssetOrderField],
|
cursor *page.Cursor[coredata.AssetOrderField],
|
||||||
filter *coredata.AssetFilter,
|
|
||||||
) (*page.Page[*coredata.Asset, coredata.AssetOrderField], error) {
|
) (*page.Page[*coredata.Asset, coredata.AssetOrderField], error) {
|
||||||
var assets coredata.Assets
|
var assets coredata.Assets
|
||||||
|
|
||||||
@@ -166,7 +164,6 @@ func (s AssetService) ListForOrganizationID(
|
|||||||
s.svc.scope,
|
s.svc.scope,
|
||||||
organizationID,
|
organizationID,
|
||||||
cursor,
|
cursor,
|
||||||
filter,
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1150,6 +1150,10 @@ func (s *DocumentService) SoftDelete(
|
|||||||
return s.svc.pg.WithTx(
|
return s.svc.pg.WithTx(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, tx pg.Tx) error {
|
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)
|
return document.SoftDelete(ctx, tx, s.svc.scope)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -1168,6 +1172,10 @@ func (s *DocumentService) BulkSoftDelete(
|
|||||||
return s.svc.pg.WithTx(
|
return s.svc.pg.WithTx(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, tx pg.Tx) error {
|
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)
|
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)
|
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)
|
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(
|
func (s *DocumentService) RequestExport(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
documentIDs []gid.GID,
|
documentIDs []gid.GID,
|
||||||
@@ -1849,6 +1888,10 @@ func (s *DocumentService) Archive(
|
|||||||
return fmt.Errorf("cannot delete measure mappings: %w", err)
|
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.Status = coredata.DocumentStatusArchived
|
||||||
document.ArchivedAt = &now
|
document.ArchivedAt = &now
|
||||||
document.UpdatedAt = now
|
document.UpdatedAt = now
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import (
|
|||||||
"text/template"
|
"text/template"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"go.gearno.de/kit/pg"
|
"go.gearno.de/kit/pg"
|
||||||
"go.probo.inc/probo/pkg/coredata"
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
"go.probo.inc/probo/pkg/docgen"
|
"go.probo.inc/probo/pkg/docgen"
|
||||||
@@ -367,13 +366,9 @@ func (s *GeneratedDocumentService) PublishDataList(
|
|||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
var dataDocumentID *gid.GID
|
datum := coredata.Datum{}
|
||||||
err = tx.QueryRow(
|
dataDocumentID, err := datum.GetGeneratedDocumentID(ctx, tx, organizationID)
|
||||||
ctx,
|
if err != nil {
|
||||||
`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) {
|
|
||||||
return fmt.Errorf("cannot query generated documents: %w", err)
|
return fmt.Errorf("cannot query generated documents: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,12 +383,7 @@ func (s *GeneratedDocumentService) PublishDataList(
|
|||||||
if err == nil && doc.ArchivedAt == nil {
|
if err == nil && doc.ArchivedAt == nil {
|
||||||
existingDoc = doc
|
existingDoc = doc
|
||||||
} else {
|
} else {
|
||||||
_, err = tx.Exec(
|
if err := datum.ClearGeneratedDocumentID(ctx, tx, []gid.GID{*dataDocumentID}); err != nil {
|
||||||
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 {
|
|
||||||
return fmt.Errorf("cannot clear document reference: %w", err)
|
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)
|
return fmt.Errorf("cannot insert document: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = tx.Exec(
|
if err := datum.UpsertGeneratedDocumentID(ctx, tx, organizationID, s.svc.scope.GetTenantID(), documentID); err != nil {
|
||||||
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 {
|
|
||||||
return fmt.Errorf("cannot upsert generated documents: %w", err)
|
return fmt.Errorf("cannot upsert generated documents: %w", err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -523,15 +500,11 @@ func (s *GeneratedDocumentService) GetDataListDocumentID(
|
|||||||
var dataDocumentID *gid.GID
|
var dataDocumentID *gid.GID
|
||||||
|
|
||||||
err := s.svc.pg.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
|
err := s.svc.pg.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
|
||||||
return conn.QueryRow(
|
datum := coredata.Datum{}
|
||||||
ctx,
|
var err error
|
||||||
`SELECT data_document_id FROM generated_documents WHERE organization_id = @organization_id`,
|
dataDocumentID, err = datum.GetGeneratedDocumentID(ctx, conn, organizationID)
|
||||||
pgx.NamedArgs{"organization_id": organizationID},
|
return err
|
||||||
).Scan(&dataDocumentID)
|
|
||||||
})
|
})
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("cannot get data list document ID: %w", err)
|
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
|
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(
|
var soaTemplate = template.Must(
|
||||||
template.New("statement_of_applicability.json.tmpl").
|
template.New("statement_of_applicability.json.tmpl").
|
||||||
Funcs(template.FuncMap{
|
Funcs(template.FuncMap{
|
||||||
|
|||||||
83
pkg/probo/templates/asset_list.json.tmpl
Normal file
83
pkg/probo/templates/asset_list.json.tmpl
Normal file
@@ -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." }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -117,12 +117,7 @@ func (r *assetConnectionResolver) TotalCount(ctx context.Context, obj *types.Ass
|
|||||||
|
|
||||||
switch obj.Resolver.(type) {
|
switch obj.Resolver.(type) {
|
||||||
case *organizationResolver:
|
case *organizationResolver:
|
||||||
assetFilter := coredata.NewAssetFilter(nil)
|
count, err := prb.Assets.CountForOrganizationID(ctx, obj.ParentID)
|
||||||
if obj.Filter != nil {
|
|
||||||
assetFilter = coredata.NewAssetFilter(&obj.Filter.SnapshotID)
|
|
||||||
}
|
|
||||||
|
|
||||||
count, err := prb.Assets.CountForOrganizationID(ctx, obj.ParentID, assetFilter)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.logger.ErrorCtx(ctx, "cannot count assets", log.Error(err))
|
r.logger.ErrorCtx(ctx, "cannot count assets", log.Error(err))
|
||||||
return 0, gqlutils.Internal(ctx)
|
return 0, gqlutils.Internal(ctx)
|
||||||
@@ -423,6 +418,29 @@ func (r *mutationResolver) PublishDataList(ctx context.Context, input types.Publ
|
|||||||
}, nil
|
}, 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.
|
// Asset returns schema.AssetResolver implementation.
|
||||||
func (r *Resolver) Asset() schema.AssetResolver { return &assetResolver{r} }
|
func (r *Resolver) Asset() schema.AssetResolver { return &assetResolver{r} }
|
||||||
|
|
||||||
|
|||||||
@@ -62,13 +62,8 @@ input DatumOrder
|
|||||||
field: DatumOrderField!
|
field: DatumOrderField!
|
||||||
}
|
}
|
||||||
|
|
||||||
input AssetFilter {
|
|
||||||
snapshotId: ID
|
|
||||||
}
|
|
||||||
|
|
||||||
type Asset implements Node {
|
type Asset implements Node {
|
||||||
id: ID!
|
id: ID!
|
||||||
snapshotId: ID
|
|
||||||
name: String!
|
name: String!
|
||||||
amount: Int!
|
amount: Int!
|
||||||
owner: Profile! @goField(forceResolver: true)
|
owner: Profile! @goField(forceResolver: true)
|
||||||
@@ -148,6 +143,9 @@ extend type Mutation {
|
|||||||
publishDataList(
|
publishDataList(
|
||||||
input: PublishDataListInput!
|
input: PublishDataListInput!
|
||||||
): PublishDataListPayload!
|
): PublishDataListPayload!
|
||||||
|
publishAssetList(
|
||||||
|
input: PublishAssetListInput!
|
||||||
|
): PublishAssetListPayload!
|
||||||
}
|
}
|
||||||
|
|
||||||
input CreateAssetInput {
|
input CreateAssetInput {
|
||||||
@@ -227,3 +225,13 @@ type PublishDataListPayload {
|
|||||||
documentEdge: DocumentEdge!
|
documentEdge: DocumentEdge!
|
||||||
documentVersionEdge: DocumentVersionEdge!
|
documentVersionEdge: DocumentVersionEdge!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input PublishAssetListInput {
|
||||||
|
organizationId: ID!
|
||||||
|
approverIds: [ID!]
|
||||||
|
}
|
||||||
|
|
||||||
|
type PublishAssetListPayload {
|
||||||
|
documentEdge: DocumentEdge!
|
||||||
|
documentVersionEdge: DocumentVersionEdge!
|
||||||
|
}
|
||||||
|
|||||||
@@ -121,13 +121,14 @@ type Organization implements Node {
|
|||||||
orderBy: AccessReviewCampaignOrder
|
orderBy: AccessReviewCampaignOrder
|
||||||
): AccessReviewCampaignConnection! @goField(forceResolver: true)
|
): AccessReviewCampaignConnection! @goField(forceResolver: true)
|
||||||
|
|
||||||
|
assetListDocument: Document @goField(forceResolver: true)
|
||||||
|
|
||||||
assets(
|
assets(
|
||||||
first: Int
|
first: Int
|
||||||
after: CursorKey
|
after: CursorKey
|
||||||
last: Int
|
last: Int
|
||||||
before: CursorKey
|
before: CursorKey
|
||||||
orderBy: AssetOrder
|
orderBy: AssetOrder
|
||||||
filter: AssetFilter = { snapshotId: null }
|
|
||||||
): AssetConnection! @goField(forceResolver: true)
|
): AssetConnection! @goField(forceResolver: true)
|
||||||
|
|
||||||
dataListDocument: Document @goField(forceResolver: true)
|
dataListDocument: Document @goField(forceResolver: true)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ enum SnapshotsType
|
|||||||
RISKS @goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeRisks")
|
RISKS @goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeRisks")
|
||||||
VENDORS
|
VENDORS
|
||||||
@goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeVendors")
|
@goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeVendors")
|
||||||
ASSETS @goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeAssets")
|
|
||||||
FINDINGS
|
FINDINGS
|
||||||
@goEnum(
|
@goEnum(
|
||||||
value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeFindings"
|
value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeFindings"
|
||||||
|
|||||||
@@ -221,8 +221,32 @@ func (r *organizationResolver) AccessReviewCampaigns(ctx context.Context, obj *t
|
|||||||
return types.NewAccessReviewCampaignConnection(p, r, obj.ID), nil
|
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.
|
// 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 {
|
if err := r.authorize(ctx, obj.ID, probo.ActionAssetList); err != nil {
|
||||||
return nil, err
|
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)
|
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
||||||
|
|
||||||
assetFilter := coredata.NewAssetFilter(nil)
|
page, err := prb.Assets.ListForOrganizationID(ctx, obj.ID, cursor)
|
||||||
if filter != nil {
|
|
||||||
assetFilter = coredata.NewAssetFilter(&filter.SnapshotID)
|
|
||||||
}
|
|
||||||
|
|
||||||
page, err := prb.Assets.ListForOrganizationID(ctx, obj.ID, cursor, assetFilter)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.logger.ErrorCtx(ctx, "cannot list organization assets", log.Error(err))
|
r.logger.ErrorCtx(ctx, "cannot list organization assets", log.Error(err))
|
||||||
return nil, gqlutils.Internal(ctx)
|
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.
|
// DataListDocument is the resolver for the dataListDocument field.
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ type (
|
|||||||
|
|
||||||
Resolver any
|
Resolver any
|
||||||
ParentID gid.GID
|
ParentID gid.GID
|
||||||
Filter *AssetFilter
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,7 +37,6 @@ func NewAssetConnection(
|
|||||||
p *page.Page[*coredata.Asset, coredata.AssetOrderField],
|
p *page.Page[*coredata.Asset, coredata.AssetOrderField],
|
||||||
resolver any,
|
resolver any,
|
||||||
parentID gid.GID,
|
parentID gid.GID,
|
||||||
filter *AssetFilter,
|
|
||||||
) *AssetConnection {
|
) *AssetConnection {
|
||||||
edges := make([]*AssetEdge, len(p.Data))
|
edges := make([]*AssetEdge, len(p.Data))
|
||||||
for i, asset := range p.Data {
|
for i, asset := range p.Data {
|
||||||
@@ -51,7 +49,6 @@ func NewAssetConnection(
|
|||||||
|
|
||||||
Resolver: resolver,
|
Resolver: resolver,
|
||||||
ParentID: parentID,
|
ParentID: parentID,
|
||||||
Filter: filter,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +62,6 @@ func NewAssetEdge(asset *coredata.Asset, orderField coredata.AssetOrderField) *A
|
|||||||
func NewAsset(asset *coredata.Asset) *Asset {
|
func NewAsset(asset *coredata.Asset) *Asset {
|
||||||
return &Asset{
|
return &Asset{
|
||||||
ID: asset.ID,
|
ID: asset.ID,
|
||||||
SnapshotID: asset.SnapshotID,
|
|
||||||
Name: asset.Name,
|
Name: asset.Name,
|
||||||
Amount: asset.Amount,
|
Amount: asset.Amount,
|
||||||
Owner: &Profile{
|
Owner: &Profile{
|
||||||
|
|||||||
@@ -559,13 +559,7 @@ func (r *Resolver) ListAssetsTool(ctx context.Context, req *mcp.CallToolRequest,
|
|||||||
|
|
||||||
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
|
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
|
||||||
|
|
||||||
noSnapshot := (*gid.GID)(nil)
|
page, err := prb.Assets.ListForOrganizationID(ctx, input.OrganizationID, cursor)
|
||||||
assetFilter := coredata.NewAssetFilter(&noSnapshot)
|
|
||||||
if input.Filter != nil {
|
|
||||||
assetFilter = coredata.NewAssetFilter(&input.Filter.SnapshotID)
|
|
||||||
}
|
|
||||||
|
|
||||||
page, err := prb.Assets.ListForOrganizationID(ctx, input.OrganizationID, cursor, assetFilter)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(fmt.Errorf("cannot list organization assets: %w", err))
|
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,
|
DocumentVersionID: documentVersion.ID,
|
||||||
}, nil
|
}, 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -2003,11 +2003,6 @@ components:
|
|||||||
organization_id:
|
organization_id:
|
||||||
$ref: "#/components/schemas/GID"
|
$ref: "#/components/schemas/GID"
|
||||||
description: Organization ID
|
description: Organization ID
|
||||||
snapshot_id:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- "null"
|
|
||||||
description: Snapshot ID
|
|
||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
description: Asset name
|
description: Asset name
|
||||||
@@ -2049,15 +2044,6 @@ components:
|
|||||||
cursor:
|
cursor:
|
||||||
$ref: "#/components/schemas/CursorKey"
|
$ref: "#/components/schemas/CursorKey"
|
||||||
description: Page cursor
|
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:
|
ListAssetsOutput:
|
||||||
type: object
|
type: object
|
||||||
@@ -4999,7 +4985,6 @@ components:
|
|||||||
enum:
|
enum:
|
||||||
- RISKS
|
- RISKS
|
||||||
- VENDORS
|
- VENDORS
|
||||||
- ASSETS
|
|
||||||
- NONCONFORMITIES
|
- NONCONFORMITIES
|
||||||
- OBLIGATIONS
|
- OBLIGATIONS
|
||||||
- CONTINUAL_IMPROVEMENTS
|
- CONTINUAL_IMPROVEMENTS
|
||||||
@@ -6584,6 +6569,33 @@ components:
|
|||||||
$ref: "#/components/schemas/GID"
|
$ref: "#/components/schemas/GID"
|
||||||
description: Created document version ID
|
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:
|
PublishStatementOfApplicabilityInput:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
@@ -8650,7 +8662,7 @@ tools:
|
|||||||
outputSchema:
|
outputSchema:
|
||||||
$ref: "#/components/schemas/GetSnapshotOutput"
|
$ref: "#/components/schemas/GetSnapshotOutput"
|
||||||
- name: takeSnapshot
|
- 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:
|
hints:
|
||||||
readonly: false
|
readonly: false
|
||||||
inputSchema:
|
inputSchema:
|
||||||
@@ -8870,6 +8882,14 @@ tools:
|
|||||||
$ref: "#/components/schemas/PublishDataListInput"
|
$ref: "#/components/schemas/PublishDataListInput"
|
||||||
outputSchema:
|
outputSchema:
|
||||||
$ref: "#/components/schemas/PublishDataListOutput"
|
$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
|
- name: publishStatementOfApplicability
|
||||||
description: Publish a statement of applicability as a document. If a document already exists, a new version is created.
|
description: Publish a statement of applicability as a document. If a document already exists, a new version is created.
|
||||||
hints:
|
hints:
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func NewAsset(a *coredata.Asset) *Asset {
|
func NewAsset(a *coredata.Asset) *Asset {
|
||||||
asset := &Asset{
|
return &Asset{
|
||||||
ID: a.ID,
|
ID: a.ID,
|
||||||
Name: a.Name,
|
Name: a.Name,
|
||||||
Amount: a.Amount,
|
Amount: a.Amount,
|
||||||
@@ -31,13 +31,6 @@ func NewAsset(a *coredata.Asset) *Asset {
|
|||||||
CreatedAt: a.CreatedAt,
|
CreatedAt: a.CreatedAt,
|
||||||
UpdatedAt: a.UpdatedAt,
|
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 {
|
func NewListAssetsOutput(assetPage *page.Page[*coredata.Asset, coredata.AssetOrderField]) ListAssetsOutput {
|
||||||
|
|||||||
Reference in New Issue
Block a user