Assets as document: replace snapshot with publish workflow

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

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

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

View File

@@ -16,7 +16,6 @@ import { faviconUrl, getAssetTypeVariant } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { Avatar, Badge, Tbody, Td, Th, Thead, Tr } from "@probo/ui";
import type { usePaginationFragmentHookType } from "react-relay/relay-hooks/usePaginationFragment";
import { useParams } from "react-router";
import type { OperationType } from "relay-runtime";
import type {
@@ -66,17 +65,10 @@ export function ReadOnlyAssetsTable(props: Props) {
function AssetRow({ entry }: { entry: AssetEntry }) {
const organizationId = useOrganizationId();
const { __ } = useTranslate();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const vendors = entry.vendors?.edges.map(edge => edge.node) ?? [];
return (
<Tr
to={
snapshotId
? `/organizations/${organizationId}/snapshots/${snapshotId}/assets/${entry.id}`
: `/organizations/${organizationId}/assets/${entry.id}`
}
>
<Tr to={`/organizations/${organizationId}/assets/${entry.id}`}>
<Td>{entry.name}</Td>
<Td>
<Badge variant={getAssetTypeVariant(entry.assetType)}>

View File

@@ -18,16 +18,28 @@ import { useConfirm } from "@probo/ui";
import { useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import { useMutationWithToasts } from "../useMutationWithToasts";
import type {
AssetGraphCreateMutation,
AssetType,
} from "#/__generated__/core/AssetGraphCreateMutation.graphql";
import type { AssetGraphDeleteMutation } from "#/__generated__/core/AssetGraphDeleteMutation.graphql";
import type { AssetGraphUpdateMutation } from "#/__generated__/core/AssetGraphUpdateMutation.graphql";
/* eslint-disable relay/unused-fields, relay/must-colocate-fragment-spreads */
export const assetsQuery = graphql`
query AssetGraphListQuery($organizationId: ID!, $snapshotId: ID) {
query AssetGraphListQuery($organizationId: ID!) {
node(id: $organizationId) {
... on Organization {
canCreateAsset: permission(action: "core:asset:create")
...AssetsPageFragment @arguments(snapshotId: $snapshotId)
canPublishAssets: permission(action: "core:asset:publish")
assetListDocument {
id
defaultApprovers {
id
}
}
...AssetsPageFragment
}
}
}
@@ -38,7 +50,6 @@ export const assetNodeQuery = graphql`
node(id: $assetId) {
... on Asset {
id
snapshotId
name
amount
assetType
@@ -75,7 +86,6 @@ export const createAssetMutation = graphql`
assetEdge @appendEdge(connections: $connections) {
node {
id
snapshotId
name
amount
assetType
@@ -107,7 +117,6 @@ export const updateAssetMutation = graphql`
updateAsset(input: $input) {
asset {
id
snapshotId
name
amount
assetType
@@ -146,8 +155,7 @@ export const useDeleteAsset = (
asset: { id?: string; name?: string },
connectionId: string,
) => {
// eslint-disable-next-line relay/generated-typescript-types
const [mutate] = useMutation(deleteAssetMutation);
const [mutate] = useMutation<AssetGraphDeleteMutation>(deleteAssetMutation);
const confirm = useConfirm();
const { __ } = useTranslate();
@@ -160,7 +168,7 @@ export const useDeleteAsset = (
promisifyMutation(mutate)({
variables: {
input: {
assetId: asset.id,
assetId: asset.id!,
},
connections: [connectionId],
},
@@ -178,15 +186,14 @@ export const useDeleteAsset = (
};
export const useCreateAsset = (connectionId: string) => {
// eslint-disable-next-line relay/generated-typescript-types
const [mutate, isMutating] = useMutation(createAssetMutation);
const [mutate, isMutating] = useMutation<AssetGraphCreateMutation>(createAssetMutation);
const { __ } = useTranslate();
return [
(input: {
name: string;
amount: number;
assetType: string;
assetType: AssetType;
ownerId: string;
organizationId: string;
vendorIds?: string[];
@@ -228,16 +235,13 @@ export const useCreateAsset = (connectionId: string) => {
export const useUpdateAsset = () => {
const { __ } = useTranslate();
const [mutate] = useMutationWithToasts(updateAssetMutation, {
successMessage: __("Asset updated successfully"),
errorMessage: __("Failed to update asset"),
});
const [mutate] = useMutation<AssetGraphUpdateMutation>(updateAssetMutation);
return (input: {
id: string;
name?: string;
amount?: number;
assetType?: string;
assetType?: AssetType;
dataTypesStored?: string;
ownerId?: string;
vendorIds?: string[];
@@ -246,7 +250,7 @@ export const useUpdateAsset = () => {
return alert(__("Failed to update asset: asset ID is required"));
}
return mutate({
return promisifyMutation(mutate)({
variables: {
input,
},

View File

@@ -12,10 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import {
getAssetTypeVariant,
validateSnapshotConsistency,
} from "@probo/helpers";
import { getAssetTypeVariant } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
ActionDropdown,
@@ -32,14 +29,12 @@ import {
type PreloadedQuery,
usePreloadedQuery,
} from "react-relay";
import { useParams } from "react-router";
import { z } from "zod";
import type { AssetGraphNodeQuery } from "#/__generated__/core/AssetGraphNodeQuery.graphql";
import { ControlledField } from "#/components/form/ControlledField";
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
import { VendorsMultiSelectField } from "#/components/form/VendorsMultiSelectField";
import { SnapshotBanner } from "#/components/SnapshotBanner";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useOrganizationId } from "#/hooks/useOrganizationId";
@@ -70,15 +65,10 @@ export default function AssetDetailsPage(props: Props) {
const assetEntry = asset.node;
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
validateSnapshotConsistency(assetEntry, snapshotId);
const connectionId = ConnectionHandler.getConnectionID(
organizationId,
"AssetsPage_assets",
{ filter: { snapshotId: snapshotId || null } },
);
const deleteAsset = useDeleteAsset(assetEntry, connectionId);
@@ -107,25 +97,19 @@ export default function AssetDetailsPage(props: Props) {
reset(formData);
});
const breadcrumbAssetsUrl
= isSnapshotMode && snapshotId
? `/organizations/${organizationId}/snapshots/${snapshotId}/assets`
: `/organizations/${organizationId}/assets`;
const breadcrumbItems = [
{
label: __("Assets"),
to: `/organizations/${organizationId}/assets`,
},
{
label: assetEntry?.name ?? "",
},
];
return (
<div className="space-y-6">
{snapshotId && <SnapshotBanner snapshotId={snapshotId} />}
<Breadcrumb
items={[
{
label: __("Assets"),
to: breadcrumbAssetsUrl,
},
{
label: assetEntry?.name ?? "",
},
]}
/>
<Breadcrumb items={breadcrumbItems} />
<div className="flex justify-between items-start">
<div className="flex items-center gap-4">
@@ -138,7 +122,7 @@ export default function AssetDetailsPage(props: Props) {
: __("Virtual")}
</Badge>
</div>
{!isSnapshotMode && asset.node.canDelete && (
{asset.node.canDelete && (
<ActionDropdown variant="secondary">
<DropdownItem
variant="danger"
@@ -156,14 +140,14 @@ export default function AssetDetailsPage(props: Props) {
label={__("Name")}
{...register("name")}
type="text"
disabled={isSnapshotMode}
disabled={!assetEntry.canUpdate}
/>
<Field
label={__("Amount")}
{...register("amount", { valueAsNumber: true })}
type="number"
disabled={isSnapshotMode}
disabled={!assetEntry.canUpdate}
/>
<ControlledField
@@ -171,7 +155,7 @@ export default function AssetDetailsPage(props: Props) {
name="assetType"
type="select"
label={__("Asset Type")}
disabled={isSnapshotMode}
disabled={!assetEntry.canUpdate}
>
<Option value="VIRTUAL">{__("Virtual")}</Option>
<Option value="PHYSICAL">{__("Physical")}</Option>
@@ -181,7 +165,7 @@ export default function AssetDetailsPage(props: Props) {
label={__("Data Types Stored")}
{...register("dataTypesStored")}
type="text"
disabled={isSnapshotMode}
disabled={!assetEntry.canUpdate}
/>
<PeopleSelectField
@@ -189,7 +173,7 @@ export default function AssetDetailsPage(props: Props) {
control={control}
name="ownerId"
label={__("Owner")}
disabled={isSnapshotMode}
disabled={!assetEntry.canUpdate}
/>
<VendorsMultiSelectField
@@ -198,11 +182,11 @@ export default function AssetDetailsPage(props: Props) {
name="vendorIds"
selectedVendors={vendors}
label={__("Vendors")}
disabled={isSnapshotMode}
disabled={!assetEntry.canUpdate}
/>
<div className="flex justify-end">
{formState.isDirty && !isSnapshotMode && asset.node.canUpdate && (
{formState.isDirty && assetEntry.canUpdate && (
<Button type="submit" disabled={formState.isSubmitting}>
{formState.isSubmitting ? __("Updating...") : __("Update")}
</Button>

View File

@@ -14,19 +14,24 @@
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import { Button, IconPlusLarge, PageHeader } from "@probo/ui";
import {
Button,
IconPageTextLine,
IconPlusLarge,
IconUpload,
PageHeader,
} from "@probo/ui";
import {
graphql,
type PreloadedQuery,
usePaginationFragment,
usePreloadedQuery,
} from "react-relay";
import { useParams } from "react-router";
import { Link, useNavigate } from "react-router";
import type { AssetGraphListQuery } from "#/__generated__/core/AssetGraphListQuery.graphql";
import type { AssetsListQuery } from "#/__generated__/core/AssetsListQuery.graphql";
import type { AssetsPageFragment$key } from "#/__generated__/core/AssetsPageFragment.graphql";
import { SnapshotBanner } from "#/components/SnapshotBanner";
import { assetsQuery } from "#/hooks/graph/AssetGraph";
import { useOrganizationId } from "#/hooks/useOrganizationId";
@@ -34,6 +39,7 @@ import { AssetsTable } from "../../../components/assets/AssetsTable";
import { ReadOnlyAssetsTable } from "../../../components/assets/ReadOnlyAssetsTable";
import { CreateAssetDialog } from "./dialogs/CreateAssetDialog";
import { PublishAssetListDialog } from "./dialogs/PublishAssetListDialog";
const paginatedAssetsFragment = graphql`
fragment AssetsPageFragment on Organization
@@ -44,7 +50,6 @@ const paginatedAssetsFragment = graphql`
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
snapshotId: { type: "ID", defaultValue: null }
) {
assets(
first: $first
@@ -52,14 +57,12 @@ const paginatedAssetsFragment = graphql`
last: $last
before: $before
orderBy: $orderBy
filter: { snapshotId: $snapshotId }
) @connection(key: "AssetsPage_assets", filters: ["filter"]) {
) @connection(key: "AssetsPage_assets") {
__id
edges {
node {
# eslint-disable-next-line relay/unused-fields
id
snapshotId
# eslint-disable-next-line relay/unused-fields
name
# eslint-disable-next-line relay/unused-fields
@@ -102,8 +105,7 @@ type Props = {
export default function AssetsPage(props: Props) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
const navigate = useNavigate();
const data = usePreloadedQuery<AssetGraphListQuery>(
assetsQuery,
@@ -115,29 +117,56 @@ export default function AssetsPage(props: Props) {
);
const assets = pagination.data.assets?.edges.map(edge => edge.node);
const connectionId = pagination.data.assets.__id;
const defaultApproverIds = (data.node.assetListDocument?.defaultApprovers ?? []).map(a => a.id);
const canWrite = assets.some(asset => asset.canDelete || asset.canUpdate);
usePageTitle(__("Assets"));
return (
<div className="space-y-6">
{snapshotId && <SnapshotBanner snapshotId={snapshotId} />}
<PageHeader
title={__("Assets")}
description={__(
"Manage your organization's assets and their classifications.",
)}
>
{!isSnapshotMode && data.node.canCreateAsset && (
<CreateAssetDialog
connection={connectionId}
organizationId={organizationId}
>
<Button icon={IconPlusLarge}>{__("Add asset")}</Button>
</CreateAssetDialog>
)}
<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
connection={connectionId}
organizationId={organizationId}
>
<Button icon={IconPlusLarge}>{__("Add asset")}</Button>
</CreateAssetDialog>
)}
</div>
</PageHeader>
{isSnapshotMode || !canWrite
{!canWrite
? (
<ReadOnlyAssetsTable pagination={pagination} assets={assets} />
)

View File

@@ -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>
);
}

View File

@@ -33,21 +33,7 @@ export const assetRoutes = [
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery<AssetGraphListQuery>(coreEnvironment, assetsQuery, {
organizationId: organizationId,
snapshotId: null,
}),
),
Component: withQueryRef(
lazy(() => import("#/pages/organizations/assets/AssetsPage")),
),
},
{
path: "snapshots/:snapshotId/assets",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId, snapshotId }) =>
loadQuery<AssetGraphListQuery>(coreEnvironment, assetsQuery, {
organizationId: organizationId,
snapshotId: snapshotId,
organizationId,
}),
),
Component: withQueryRef(
@@ -66,16 +52,4 @@ export const assetRoutes = [
lazy(() => import("#/pages/organizations/assets/AssetDetailsPage")),
),
},
{
path: "snapshots/:snapshotId/assets/:assetId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ assetId }) =>
loadQuery<AssetGraphNodeQuery>(coreEnvironment, assetNodeQuery, {
assetId,
}),
),
Component: withQueryRef(
lazy(() => import("#/pages/organizations/assets/AssetDetailsPage")),
),
},
] satisfies AppRoute[];