Add risk publish to document system

Replace the old snapshot-based system for risks with the publish
document system, mirroring the prior vendor / processing activity / DPIA
/ TIA migration. Includes the GraphQL mutation, MCP tool, CLI command,
n8n operation, frontend publish dialog, e2e tests, and a prosemirror
register template covering name, description, category, treatment,
owner, inherent and residual scoring, and notes.

The risk register lives as a generated DocumentTypeRegister document on
the organization, reused across publishes (the major version bumps on
every republish). Approvers can be passed in to create a draft pending
approval; otherwise the version is published immediately. The frontend
Risks page exposes a Publish button and a Document link button when the
document exists, and pre-fills the previous default approvers.

Risks was the last remaining snapshot type, so this commit also removes
the entire snapshot system: drop snapshotId from the Risk GraphQL type
and RiskFilter; remove RiskSnapshotter, Risks.Snapshot,
InsertRiskSnapshots, and the SnapshotID/SourceID fields on Risk; delete
Snapshot, ControlSnapshot, SnapshotsType, SnapshotOrderField,
Snapshottable, the SnapshotService, the Snapshot console resolvers and
GraphQL schema, the Snapshot MCP types and operations
(list/get/take/listControlSnapshots), the snapshot CLI (prb snapshot),
the snapshot frontend pages, routes, banner, LinkedSnapshotsCard,
SnapshotGraph, snapshot helpers, and the snapshot n8n resource and
control link/unlink snapshot operations. The snapshot_id columns remain
in the database but are now filtered out with snapshot_id IS NULL.

Add Get/Upsert/Clear GeneratedDocumentID methods on Risk backed by a new
risks_document_id column on generated_documents, matching the
ProcessingActivity/Finding/Vendor pattern. The migration command
migrate-risk-snapshots-to-documents uses raw SQL queries instead of the
Go snapshot types, since those are gone.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-04-29 18:11:19 +02:00
parent 01bc3ac696
commit 553901e4ad
93 changed files with 2384 additions and 5741 deletions

View File

@@ -1,92 +0,0 @@
// Copyright (c) 2025-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 {
formatDate,
getSnapshotTypeLabel,
getSnapshotTypeUrlPath,
sprintf,
} from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { IconClock } from "@probo/ui";
import { graphql, useLazyLoadQuery } from "react-relay";
import { useLocation } from "react-router";
import type { SnapshotBannerQuery } from "#/__generated__/core/SnapshotBannerQuery.graphql";
const snapshotQuery = graphql`
query SnapshotBannerQuery($snapshotId: ID!) {
node(id: $snapshotId) {
... on Snapshot {
# eslint-disable-next-line relay/unused-fields
id
name
type
createdAt
}
}
}
`;
const isSnapshotTypeValidForUrl = (type: string, pathname: string) => {
const urlPath = getSnapshotTypeUrlPath(type);
return pathname.includes(urlPath);
};
type Props = {
snapshotId: string;
};
export function SnapshotBanner({ snapshotId }: Props) {
const { __ } = useTranslate();
const location = useLocation();
const data = useLazyLoadQuery<SnapshotBannerQuery>(snapshotQuery, {
snapshotId,
});
const snapshot = data.node;
if (!snapshot) {
return null;
}
if (
snapshot.type
&& !isSnapshotTypeValidForUrl(snapshot.type, location.pathname)
) {
throw new Error("PAGE_NOT_FOUND");
}
return (
<div className="bg-warning rounded-lg p-4 flex items-center gap-3">
<IconClock className="text-warning-600 flex-shrink-0" size={20} />
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-warning-800">
{__("Snapshot")}
{" "}
{snapshot.name}
</span>
</div>
<p className="text-sm text-warning-700">
{sprintf(
__("You are viewing a %s snapshot from %s"),
getSnapshotTypeLabel(__, snapshot.type).toLocaleLowerCase(),
formatDate(snapshot.createdAt),
)}
</p>
</div>
</div>
);
}

View File

@@ -1,31 +0,0 @@
// Copyright (c) 2025-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 { getSnapshotTypeLabel, snapshotTypes } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { Option } from "@probo/ui";
export function SnapshotTypeOptions() {
const { __ } = useTranslate();
return (
<>
{snapshotTypes.map(type => (
<Option key={type} value={type}>
{getSnapshotTypeLabel(__, type)}
</Option>
))}
</>
);
}

View File

@@ -1,232 +0,0 @@
// Copyright (c) 2025-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 {
formatDate,
getSnapshotTypeLabel,
getSnapshotTypeUrlPath,
sprintf,
} from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Badge,
Button,
Card,
IconChevronDown,
IconPlusLarge,
IconTrashCan,
Table,
Tbody,
Td,
Th,
Thead,
Tr,
TrButton,
} from "@probo/ui";
import { clsx } from "clsx";
import { useMemo, useState } from "react";
import { useFragment } from "react-relay";
import { graphql } from "relay-runtime";
import type { LinkedSnapshotsCardFragment$key } from "#/__generated__/core/LinkedSnapshotsCardFragment.graphql";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { LinkedSnapshotsDialog } from "./LinkedSnapshotsDialog";
const linkedSnapshotFragment = graphql`
fragment LinkedSnapshotsCardFragment on Snapshot {
id
name
description
type
createdAt
}
`;
type Mutation<Params> = (p: {
variables: {
input: {
snapshotId: string;
} & Params;
connections: string[];
};
}) => void;
type Props<Params> = {
snapshots: (LinkedSnapshotsCardFragment$key & { id: string })[];
params: Params;
disabled?: boolean;
connectionId: string;
onAttach: Mutation<Params>;
onDetach: Mutation<Params>;
variant?: "card" | "table";
readOnly?: boolean;
};
export function LinkedSnapshotsCard<Params>(props: Props<Params>) {
const { __ } = useTranslate();
const [limit, setLimit] = useState<number | null>(4);
const snapshots = useMemo(() => {
return limit ? props.snapshots.slice(0, limit) : props.snapshots;
}, [props.snapshots, limit]);
const showMoreButton = limit !== null && props.snapshots.length > limit;
const variant = props.variant ?? "table";
const onAttach = (snapshotId: string) => {
props.onAttach({
variables: {
input: {
snapshotId,
...props.params,
},
connections: [props.connectionId],
},
});
};
const onDetach = (snapshotId: string) => {
props.onDetach({
variables: {
input: {
snapshotId,
...props.params,
},
connections: [props.connectionId],
},
});
};
const Wrapper = variant === "card" ? Card : "div";
const colSpanTable = props.readOnly ? 4 : 5;
const colSpanCard = props.readOnly ? 3 : 4;
return (
<Wrapper padded className="space-y-[10px]">
{variant === "card" && (
<div className="flex justify-between">
<div className="text-lg font-semibold">{__("Snapshots")}</div>
{!props.readOnly && (
<LinkedSnapshotsDialog
disabled={props.disabled}
linkedSnapshots={props.snapshots}
onLink={onAttach}
onUnlink={onDetach}
>
<Button variant="tertiary" icon={IconPlusLarge}>
{__("Link snapshot")}
</Button>
</LinkedSnapshotsDialog>
)}
</div>
)}
<Table className={clsx(variant === "card" && "bg-invert")}>
<Thead>
<Tr>
<Th>{__("Name")}</Th>
<Th>{__("Type")}</Th>
{variant === "table" && <Th>{__("Description")}</Th>}
<Th>{__("Created")}</Th>
{!props.readOnly && <Th></Th>}
</Tr>
</Thead>
<Tbody>
{snapshots.length === 0 && (
<Tr>
<Td
colSpan={variant === "table" ? colSpanTable : colSpanCard}
className="text-center text-txt-secondary"
>
{__("No snapshots linked")}
</Td>
</Tr>
)}
{snapshots.map(snapshot => (
<SnapshotRow
key={snapshot.id}
snapshot={snapshot}
onClick={onDetach}
variant={variant}
readOnly={props.readOnly}
/>
))}
{variant === "table" && !props.readOnly && (
<LinkedSnapshotsDialog
disabled={props.disabled}
linkedSnapshots={props.snapshots}
onLink={onAttach}
onUnlink={onDetach}
>
<TrButton colspan={colSpanTable} icon={IconPlusLarge}>
{__("Link snapshot")}
</TrButton>
</LinkedSnapshotsDialog>
)}
</Tbody>
</Table>
{showMoreButton && (
<Button
variant="tertiary"
onClick={() => setLimit(null)}
className="mt-3 mx-auto"
icon={IconChevronDown}
>
{sprintf(__("Show %s more"), props.snapshots.length - limit)}
</Button>
)}
</Wrapper>
);
}
function SnapshotRow(props: {
snapshot: LinkedSnapshotsCardFragment$key & { id: string };
onClick: (snapshotId: string) => void;
variant: "card" | "table";
readOnly?: boolean;
}) {
const snapshot = useFragment(linkedSnapshotFragment, props.snapshot);
const organizationId = useOrganizationId();
const { __ } = useTranslate();
const urlPath = getSnapshotTypeUrlPath(snapshot.type);
const snapshotUrl = `/organizations/${organizationId}/snapshots/${snapshot.id}${urlPath}`;
return (
<Tr to={snapshotUrl}>
<Td className="font-medium">{snapshot.name}</Td>
<Td>
<Badge variant="neutral">
{getSnapshotTypeLabel(__, snapshot.type)}
</Badge>
</Td>
{props.variant === "table" && (
<Td className="text-txt-secondary">
{snapshot.description || __("No description")}
</Td>
)}
<Td className="text-txt-tertiary">{formatDate(snapshot.createdAt)}</Td>
{!props.readOnly && (
<Td noLink width={50} className="text-end">
<Button
variant="secondary"
onClick={() => props.onClick(snapshot.id)}
icon={IconTrashCan}
>
{__("Unlink")}
</Button>
</Td>
)}
</Tr>
);
}

View File

@@ -1,212 +0,0 @@
// Copyright (c) 2025-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 { formatDate, getSnapshotTypeLabel } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Badge,
Button,
Dialog,
DialogContent,
DialogFooter,
IconMagnifyingGlass,
IconPlusLarge,
IconTrashCan,
InfiniteScrollTrigger,
Input,
Spinner,
} from "@probo/ui";
import { type ReactNode, Suspense, useMemo, useState } from "react";
import { useLazyLoadQuery, usePaginationFragment } from "react-relay";
import { graphql } from "relay-runtime";
import type {
LinkedSnapshotsDialogFragment$data,
LinkedSnapshotsDialogFragment$key,
} from "#/__generated__/core/LinkedSnapshotsDialogFragment.graphql";
import type { LinkedSnapshotsDialogQuery } from "#/__generated__/core/LinkedSnapshotsDialogQuery.graphql";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import type { NodeOf } from "#/types";
const snapshotsQuery = graphql`
query LinkedSnapshotsDialogQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
id
... on Organization {
...LinkedSnapshotsDialogFragment
}
}
}
`;
const snapshotsFragment = graphql`
fragment LinkedSnapshotsDialogFragment on Organization
@refetchable(queryName: "LinkedSnapshotsDialogQuery_fragment")
@argumentDefinitions(
first: { type: "Int", defaultValue: 20 }
order: { type: "SnapshotOrder", defaultValue: null }
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
) {
snapshots(
first: $first
after: $after
last: $last
before: $before
orderBy: $order
) @connection(key: "LinkedSnapshotsDialogQuery_snapshots") {
edges {
node {
id
name
description
type
createdAt
}
}
}
}
`;
type Props = {
children: ReactNode;
disabled?: boolean;
linkedSnapshots?: { id: string }[];
onLink: (snapshotId: string) => void;
onUnlink: (snapshotId: string) => void;
};
export function LinkedSnapshotsDialog({ children, ...props }: Props) {
const { __ } = useTranslate();
return (
<Dialog trigger={children} title={__("Link snapshots")}>
<DialogContent>
<Suspense fallback={<Spinner centered />}>
<LinkedSnapshotsDialogContent {...props} />
</Suspense>
</DialogContent>
<DialogFooter exitLabel={__("Close")} />
</Dialog>
);
}
function LinkedSnapshotsDialogContent(props: Omit<Props, "children">) {
const organizationId = useOrganizationId();
const query = useLazyLoadQuery<LinkedSnapshotsDialogQuery>(snapshotsQuery, {
organizationId,
});
const { data, loadNext, hasNext, isLoadingNext } = usePaginationFragment<
LinkedSnapshotsDialogQuery,
LinkedSnapshotsDialogFragment$key
>(snapshotsFragment, query.organization);
const { __ } = useTranslate();
const [search, setSearch] = useState("");
const snapshots = useMemo(
() => data.snapshots?.edges?.map(edge => edge.node) ?? [],
[data.snapshots],
);
const linkedIds = useMemo(() => {
return new Set(props.linkedSnapshots?.map(s => s.id) ?? []);
}, [props.linkedSnapshots]);
const filteredSnapshots = useMemo(() => {
return snapshots.filter(snapshot =>
snapshot.name.toLowerCase().includes(search.toLowerCase()),
);
}, [snapshots, search]);
return (
<>
<div className="flex items-center gap-2 sticky top-0 relative py-4 bg-linear-to-b from-50% from-level-2 to-level-2/0 px-6">
<Input
icon={IconMagnifyingGlass}
placeholder={__("Search snapshots...")}
onValueChange={setSearch}
/>
</div>
<div className="divide-y divide-border-low">
{filteredSnapshots.map(snapshot => (
<SnapshotRow
key={snapshot.id}
snapshot={snapshot}
linkedSnapshots={linkedIds}
onLink={props.onLink}
onUnlink={props.onUnlink}
disabled={props.disabled}
/>
))}
{hasNext && (
<InfiniteScrollTrigger
loading={isLoadingNext}
onView={() => loadNext(20)}
/>
)}
</div>
</>
);
}
type Snapshot = NodeOf<LinkedSnapshotsDialogFragment$data["snapshots"]>;
type RowProps = {
snapshot: Snapshot;
linkedSnapshots: Set<string>;
disabled?: boolean;
onLink: (snapshotId: string) => void;
onUnlink: (snapshotId: string) => void;
};
function SnapshotRow(props: RowProps) {
const { __ } = useTranslate();
const isLinked = props.linkedSnapshots.has(props.snapshot.id);
const onClick = isLinked ? props.onUnlink : props.onLink;
const IconComponent = isLinked ? IconTrashCan : IconPlusLarge;
return (
<button
className="py-4 flex items-center gap-4 hover:bg-subtle cursor-pointer px-6 w-full"
onClick={() => onClick(props.snapshot.id)}
>
<div className="flex-1 flex items-center gap-4">
<div className="font-medium min-w-0 flex-shrink-0">
{props.snapshot.name}
</div>
<Badge variant="neutral" className="flex-shrink-0 ml-6">
{getSnapshotTypeLabel(__, props.snapshot.type)}
</Badge>
<div className="text-sm text-txt-secondary min-w-0 flex-1 text-left">
{props.snapshot.description || __("No description")}
</div>
<div className="text-sm text-txt-tertiary flex-shrink-0">
{formatDate(props.snapshot.createdAt)}
</div>
</div>
<Button
disabled={props.disabled}
variant={isLinked ? "secondary" : "primary"}
asChild
>
<span>
<IconComponent size={16} />
{" "}
{isLinked ? __("Unlink") : __("Link")}
</span>
</Button>
</button>
);
}