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

View File

@@ -140,12 +140,6 @@ export const frameworkControlNodeQuery = graphql`
canDeleteAuditMapping: permission(
action: "core:control:delete-audit-mapping"
)
canCreateSnapshotMapping: permission(
action: "core:control:create-snapshot-mapping"
)
canDeleteSnapshotMapping: permission(
action: "core:control:delete-snapshot-mapping"
)
canCreateObligationMapping: permission(
action: "core:control:create-obligation-mapping"
)
@@ -192,16 +186,6 @@ export const frameworkControlNodeQuery = graphql`
}
}
}
snapshots(first: 100)
@connection(key: "FrameworkGraphControl_snapshots") {
__id
edges {
node {
id
...LinkedSnapshotsCardFragment
}
}
}
}
}
}

View File

@@ -50,10 +50,10 @@ export function useDeleteRiskMutation() {
}
export const risksQuery = graphql`
query RiskGraphListQuery($organizationId: ID!, $snapshotId: ID) {
query RiskGraphListQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
id
...RiskGraphFragment @arguments(snapshotId: $snapshotId)
...RiskGraphFragment
}
}
`;
@@ -70,22 +70,28 @@ const risksFragment = graphql`
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
snapshotId: { type: "ID", defaultValue: null }
) {
canCreateRisk: permission(action: "core:risk:create")
canPublishRisk: permission(action: "core:risk:publish")
risksDocument {
id
currentPublishedMajor
currentPublishedMinor
defaultApprovers {
id
}
}
risks(
first: $first
after: $after
last: $last
before: $before
orderBy: $order
filter: { snapshotId: $snapshotId }
) @connection(key: "RisksListQuery_risks", filters: ["filter"]) {
) @connection(key: "RisksListQuery_risks", filters: []) {
__id
edges {
node {
id
snapshotId
name
category
treatment
@@ -130,7 +136,6 @@ export const riskNodeQuery = graphql`
node(id: $riskId) {
... on Risk {
id
snapshotId
name
description
treatment

View File

@@ -1,148 +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 { promisifyMutation, sprintf } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { useConfirm } from "@probo/ui";
import { useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import { useMutationWithToasts } from "../useMutationWithToasts";
/* eslint-disable relay/unused-fields, relay/must-colocate-fragment-spreads */
export const SnapshotsConnectionKey = "SnapshotsPage_snapshots";
export const snapshotsQuery = graphql`
query SnapshotGraphListQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
... on Organization {
canCreateSnapshot: permission(action: "core:snapshot:create")
...SnapshotsPageFragment
}
}
}
`;
export const snapshotNodeQuery = graphql`
query SnapshotGraphNodeQuery($snapshotId: ID!) {
node(id: $snapshotId) {
... on Snapshot {
id
name
description
type
organization {
id
name
}
createdAt
}
}
}
`;
export const createSnapshotMutation = graphql`
mutation SnapshotGraphCreateMutation(
$input: CreateSnapshotInput!
$connections: [ID!]!
) {
createSnapshot(input: $input) {
snapshotEdge @prependEdge(connections: $connections) {
node {
id
name
description
type
createdAt
}
}
}
}
`;
export const deleteSnapshotMutation = graphql`
mutation SnapshotGraphDeleteMutation(
$input: DeleteSnapshotInput!
$connections: [ID!]!
) {
deleteSnapshot(input: $input) {
deletedSnapshotId @deleteEdge(connections: $connections)
}
}
`;
export const useDeleteSnapshot = (
snapshot: { id: string; name: string },
connectionId: string,
) => {
const { __ } = useTranslate();
const [mutate] = useMutationWithToasts(deleteSnapshotMutation, {
successMessage: __("Snapshot deleted successfully"),
errorMessage: __("Failed to delete snapshot"),
});
const confirm = useConfirm();
return () => {
confirm(
() =>
mutate({
variables: {
input: {
snapshotId: snapshot.id,
},
connections: [connectionId],
},
}),
{
message: sprintf(
__(
"This will permanently delete the snapshot %s. This action cannot be undone.",
),
snapshot.name,
),
},
);
};
};
export const useCreateSnapshot = (connectionId: string) => {
// eslint-disable-next-line relay/generated-typescript-types
const [mutate] = useMutation(createSnapshotMutation);
const { __ } = useTranslate();
return (input: {
organizationId: string;
name: string;
description?: string;
}) => {
if (!input.organizationId) {
return alert(__("Failed to create snapshot: organization is required"));
}
if (!input.name) {
return alert(__("Failed to create snapshot: name is required"));
}
return promisifyMutation(mutate)({
variables: {
input: {
organizationId: input.organizationId,
name: input.name,
description: input.description,
},
connections: [connectionId],
},
});
};
};

View File

@@ -19,7 +19,6 @@ import {
IconBook,
IconBox,
IconCircleProgress,
IconClock,
IconFire3,
IconGroup1,
IconInboxEmpty,
@@ -62,7 +61,6 @@ const fragment = graphql`
action: "core:processing-activity:list"
)
canListRightsRequests: permission(action: "core:rights-request:list")
canListSnapshots: permission(action: "core:snapshot:list")
canGetTrustCenter: permission(action: "core:trust-center:get")
canListCookieBanners: permission(action: "core:cookie-banner:list")
canUpdateOrganization: permission(action: "iam:organization:update")
@@ -199,13 +197,6 @@ export function Sidebar(props: { fKey: SidebarFragment$key }) {
to={`${prefix}/rights-requests`}
/>
)}
{organization.canListSnapshots && (
<SidebarItem
label={__("Snapshots")}
icon={IconClock}
to={`${prefix}/snapshots`}
/>
)}
{organization.canListAccessReviewCampaigns && (
<SidebarItem
label={__("Access Reviews")}

View File

@@ -45,7 +45,6 @@ import { LinkedAuditsCard } from "#/components/audits/LinkedAuditsCard";
import { LinkedDocumentsCard } from "#/components/documents/LinkedDocumentsCard";
import { LinkedMeasuresCard } from "#/components/measures/LinkedMeasuresCard";
import { LinkedObligationsCard } from "#/components/obligations/LinkedObligationsCard";
import { LinkedSnapshotsCard } from "#/components/snapshots/LinkedSnapshotsCard";
import { frameworkControlNodeQuery } from "#/hooks/graph/FrameworkGraph";
import { useOrganizationId } from "#/hooks/useOrganizationId";
@@ -159,33 +158,6 @@ const detachObligationMutation = graphql`
}
`;
const attachSnapshotMutation = graphql`
mutation FrameworkControlPageAttachSnapshotMutation(
$input: CreateControlSnapshotMappingInput!
$connections: [ID!]!
) {
createControlSnapshotMapping(input: $input) {
snapshotEdge @prependEdge(connections: $connections) {
node {
id
...LinkedSnapshotsCardFragment
}
}
}
}
`;
const detachSnapshotMutation = graphql`
mutation FrameworkControlPageDetachSnapshotMutation(
$input: DeleteControlSnapshotMappingInput!
$connections: [ID!]!
) {
deleteControlSnapshotMapping(input: $input) {
deletedSnapshotId @deleteEdge(connections: $connections)
}
}
`;
const deleteControlMutation = graphql`
mutation FrameworkControlPageDeleteControlMutation(
$input: DeleteControlInput!
@@ -236,14 +208,6 @@ export default function FrameworkControlPage({ queryRef }: Props) {
// eslint-disable-next-line relay/generated-typescript-types
const [attachAudit, isAttachingAudit] = useMutation(attachAuditMutation);
// eslint-disable-next-line relay/generated-typescript-types
const [detachSnapshot, isDetachingSnapshot] = useMutation(
detachSnapshotMutation,
);
// eslint-disable-next-line relay/generated-typescript-types
const [attachSnapshot, isAttachingSnapshot] = useMutation(
attachSnapshotMutation,
);
// eslint-disable-next-line relay/generated-typescript-types
const [deleteControl] = useMutation(deleteControlMutation);
// eslint-disable-next-line relay/generated-typescript-types
@@ -267,10 +231,6 @@ export default function FrameworkControlPage({ queryRef }: Props) {
const canUnlinkAudit = control.canDeleteAuditMapping;
const auditsReadOnly = !canLinkAudit && !canUnlinkAudit;
const canLinkSnapshot = control.canCreateSnapshotMapping;
const canUnlinkSnapshot = control.canDeleteSnapshotMapping;
const snapshotsReadOnly = !canLinkSnapshot && !canUnlinkSnapshot;
const canLinkObligation = control.canCreateObligationMapping;
const canUnlinkObligation = control.canDeleteObligationMapping;
const obligationsReadOnly = !canLinkObligation && !canUnlinkObligation;
@@ -482,27 +442,6 @@ export default function FrameworkControlPage({ queryRef }: Props) {
readOnly={obligationsReadOnly}
/>
</div>
<div className="mb-4">
<LinkedSnapshotsCard
variant="card"
snapshots={
control.snapshots?.edges.map(edge => edge.node)
?? []
}
params={{ controlId: control.id }}
connectionId={control.snapshots?.__id ?? ""}
onAttach={withErrorHandling(
attachSnapshot,
__("Failed to link snapshot"),
)}
onDetach={withErrorHandling(
detachSnapshot,
__("Failed to unlink snapshot"),
)}
disabled={isAttachingSnapshot || isDetachingSnapshot}
readOnly={snapshotsReadOnly}
/>
</div>
</div>
</div>
);

View File

@@ -114,10 +114,9 @@ export default function MeasureEvidencesTab() {
const { measure } = useOutletContext<{
measure: MeasureEvidencesTabFragment$key;
}>();
const { measureId, evidenceId, snapshotId } = useParams<{
const { measureId, evidenceId } = useParams<{
measureId: string;
evidenceId: string;
snapshotId?: string;
}>();
if (!measureId) {
throw new Error("Missing :measureId param in route");
@@ -132,7 +131,6 @@ export default function MeasureEvidencesTab() {
const evidence = evidences.find(e => e.id === evidenceId);
const organizationId = useOrganizationId();
const dialogRef = useDialogRef();
const isSnapshotMode = Boolean(snapshotId);
usePageTitle(pagination.data.name + " - " + __("Evidences"));
@@ -156,11 +154,9 @@ export default function MeasureEvidencesTab() {
measureId={measureId}
organizationId={organizationId}
connectionId={connectionId}
hideActions={isSnapshotMode}
snapshotId={snapshotId}
/>
))}
{!isSnapshotMode && pagination.data.canUploadEvidence && (
{pagination.data.canUploadEvidence && (
<TrButton
colspan={5}
onClick={() => dialogRef.current?.open()}
@@ -175,16 +171,13 @@ export default function MeasureEvidencesTab() {
<EvidencePreviewDialog
key={evidence.id}
onClose={() => {
const baseUrl = isSnapshotMode
? `/organizations/${organizationId}/snapshots/${snapshotId}/risks/measures/${measureId}/evidences`
: `/organizations/${organizationId}/measures/${measureId}/evidences`;
void navigate(baseUrl);
void navigate(`/organizations/${organizationId}/measures/${measureId}/evidences`);
}}
evidenceId={evidence.id}
filename={evidence.file?.fileName || ""}
/>
)}
{!isSnapshotMode && pagination.data.canUploadEvidence && (
{pagination.data.canUploadEvidence && (
<CreateEvidenceDialog
ref={dialogRef}
measureId={measureId}
@@ -200,8 +193,6 @@ function EvidenceRow(props: {
measureId: string;
organizationId: string;
connectionId: string;
hideActions?: boolean;
snapshotId?: string;
}) {
const evidence = useFragment(evidenceFragment, props.evidenceKey);
const { __ } = useTranslate();
@@ -253,9 +244,7 @@ function EvidenceRow(props: {
);
};
const evidenceUrl = props.snapshotId
? `/organizations/${props.organizationId}/snapshots/${props.snapshotId}/risks/measures/${props.measureId}/evidences/${evidence.id}`
: `/organizations/${props.organizationId}/measures/${props.measureId}/evidences/${evidence.id}`;
const evidenceUrl = `/organizations/${props.organizationId}/measures/${props.measureId}/evidences/${evidence.id}`;
return (
<>
@@ -275,26 +264,24 @@ function EvidenceRow(props: {
<Td>{fileSize(__, evidence.file?.size || 0)}</Td>
<Td>{formatDate(evidence.createdAt)}</Td>
<Td noLink>
{!props.hideActions && (
<div className="flex gap-2">
<ActionDropdown>
<DropdownItem onClick={() => setIsDownloading(true)}>
<IconArrowInbox size={16} />
{__("Download")}
<div className="flex gap-2">
<ActionDropdown>
<DropdownItem onClick={() => setIsDownloading(true)}>
<IconArrowInbox size={16} />
{__("Download")}
</DropdownItem>
{evidence.canDelete && (
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={handleDelete}
disabled={isDeleting}
>
{__("Delete")}
</DropdownItem>
{evidence.canDelete && (
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={handleDelete}
disabled={isDeleting}
>
{__("Delete")}
</DropdownItem>
)}
</ActionDropdown>
</div>
)}
)}
</ActionDropdown>
</div>
</Td>
</Tr>
</>

View File

@@ -12,11 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import {
getTreatment,
sprintf,
validateSnapshotConsistency,
} from "@probo/helpers";
import { getTreatment, sprintf } from "@probo/helpers";
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import {
@@ -41,7 +37,6 @@ import { Outlet, useNavigate, useParams } from "react-router";
import { ConnectionHandler } from "relay-runtime";
import type { RiskGraphNodeQuery } from "#/__generated__/core/RiskGraphNodeQuery.graphql";
import { SnapshotBanner } from "#/components/SnapshotBanner";
import {
riskNodeQuery,
RisksConnectionKey,
@@ -56,13 +51,11 @@ type Props = {
};
export default function RiskDetailPage(props: Props) {
const { riskId, snapshotId } = useParams<{
const { riskId } = useParams<{
riskId: string;
snapshotId?: string;
}>();
const organizationId = useOrganizationId();
const navigate = useNavigate();
const isSnapshotMode = Boolean(snapshotId);
if (!riskId) {
throw new Error("Cannot load risk detail page without riskId parameter");
@@ -74,7 +67,6 @@ export default function RiskDetailPage(props: Props) {
props.queryRef,
);
validateSnapshotConsistency(risk, snapshotId);
const [deleteRisk] = useDeleteRiskMutation();
usePageTitle(risk.name ?? "Risk detail");
@@ -84,7 +76,6 @@ export default function RiskDetailPage(props: Props) {
const connectionId = ConnectionHandler.getConnectionID(
organizationId,
RisksConnectionKey,
{ filter: { snapshotId: snapshotId || null } },
);
confirm(
() =>
@@ -95,11 +86,7 @@ export default function RiskDetailPage(props: Props) {
connections: [connectionId],
},
onSuccess() {
const risksUrl
= isSnapshotMode && snapshotId
? `/organizations/${organizationId}/snapshots/${snapshotId}/risks`
: `/organizations/${organizationId}/risks`;
void navigate(risksUrl);
void navigate(`/organizations/${organizationId}/risks`);
resolve();
},
});
@@ -120,19 +107,11 @@ export default function RiskDetailPage(props: Props) {
const controlsCount = risk.controlsInfo?.totalCount ?? 0;
const obligationsCount = risk.obligationsInfo?.totalCount ?? 0;
const risksUrl
= isSnapshotMode && snapshotId
? `/organizations/${organizationId}/snapshots/${snapshotId}/risks`
: `/organizations/${organizationId}/risks`;
const baseTabUrl
= isSnapshotMode && snapshotId
? `/organizations/${organizationId}/snapshots/${snapshotId}/risks/${riskId}`
: `/organizations/${organizationId}/risks/${riskId}`;
const risksUrl = `/organizations/${organizationId}/risks`;
const baseTabUrl = `/organizations/${organizationId}/risks/${riskId}`;
return (
<div className="space-y-6">
{snapshotId && <SnapshotBanner snapshotId={snapshotId} />}
{/* Header */}
<div className="flex justify-between items-center mb-4">
<Breadcrumb
@@ -146,56 +125,50 @@ export default function RiskDetailPage(props: Props) {
},
]}
/>
{!isSnapshotMode && (
<div className="flex gap-2">
{risk.canUpdate && (
<FormRiskDialog
trigger={(
<Button icon={IconPencil} variant="secondary">
{__("Edit")}
</Button>
)}
risk={{ id: riskId, ...risk }}
/>
)}
{risk.canDelete && (
<ActionDropdown variant="secondary">
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={onDelete}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
)}
</div>
)}
<div className="flex gap-2">
{risk.canUpdate && (
<FormRiskDialog
trigger={(
<Button icon={IconPencil} variant="secondary">
{__("Edit")}
</Button>
)}
risk={{ id: riskId, ...risk }}
/>
)}
{risk.canDelete && (
<ActionDropdown variant="secondary">
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={onDelete}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
)}
</div>
</div>
<PageHeader title={risk.name} description={risk.description} />
<Tabs>
<TabLink to={`${baseTabUrl}/overview`}>{__("Overview")}</TabLink>
{!isSnapshotMode && (
<>
<TabLink to={`${baseTabUrl}/measures`}>
{__("Measures")}
<TabBadge>{measuresCount}</TabBadge>
</TabLink>
<TabLink to={`${baseTabUrl}/documents`}>
{__("Documents")}
<TabBadge>{documentsCount}</TabBadge>
</TabLink>
<TabLink to={`${baseTabUrl}/controls`}>
{__("Controls")}
<TabBadge>{controlsCount}</TabBadge>
</TabLink>
<TabLink to={`${baseTabUrl}/obligations`}>
{__("Obligations")}
<TabBadge>{obligationsCount}</TabBadge>
</TabLink>
</>
)}
<TabLink to={`${baseTabUrl}/measures`}>
{__("Measures")}
<TabBadge>{measuresCount}</TabBadge>
</TabLink>
<TabLink to={`${baseTabUrl}/documents`}>
{__("Documents")}
<TabBadge>{documentsCount}</TabBadge>
</TabLink>
<TabLink to={`${baseTabUrl}/controls`}>
{__("Controls")}
<TabBadge>{controlsCount}</TabBadge>
</TabLink>
<TabLink to={`${baseTabUrl}/obligations`}>
{__("Obligations")}
<TabBadge>{obligationsCount}</TabBadge>
</TabLink>
</Tabs>
<Outlet context={{ risk }} />

View File

@@ -19,9 +19,11 @@ import {
ActionDropdown,
Button,
DropdownItem,
IconPageTextLine,
IconPencil,
IconPlusLarge,
IconTrashCan,
IconUpload,
PageHeader,
RisksChart,
SeverityBadge,
@@ -34,16 +36,16 @@ import {
useDialogRef,
} from "@probo/ui";
import type { PreloadedQuery } from "react-relay";
import { useParams } from "react-router";
import { useNavigate } from "react-router";
import type { RiskGraphFragment$data } from "#/__generated__/core/RiskGraphFragment.graphql";
import type { RiskGraphListQuery } from "#/__generated__/core/RiskGraphListQuery.graphql";
import { SnapshotBanner } from "#/components/SnapshotBanner";
import { SortableTable, SortableTh } from "#/components/SortableTable";
import { useDeleteRiskMutation, useRisksQuery } from "#/hooks/graph/RiskGraph";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import type { NodeOf } from "#/types";
import { PublishRiskListDialog } from "./dialogs/PublishRiskListDialog";
import FormRiskDialog from "./FormRiskDialog";
type Props = {
@@ -53,11 +55,10 @@ type Props = {
export default function RisksPage(props: Props) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
const navigate = useNavigate();
const {
data: { canCreateRisk },
data: { canCreateRisk, canPublishRisk, risksDocument },
connectionId,
risks,
...pagination
@@ -70,7 +71,6 @@ export default function RisksPage(props: Props) {
}) => {
pagination.refetch(
{
snapshotId,
order: {
direction: order.direction as "ASC" | "DESC",
field: order.field as
@@ -90,27 +90,54 @@ export default function RisksPage(props: Props) {
usePageTitle(__("Risks"));
const hasAnyAction
= !isSnapshotMode
&& risks.some(({ canDelete, canUpdate }) => canUpdate || canDelete);
= risks.some(({ canDelete, canUpdate }) => canUpdate || canDelete);
const defaultApproverIds
= risksDocument?.defaultApprovers?.map(a => a.id) ?? [];
return (
<div className="space-y-6">
{snapshotId && <SnapshotBanner snapshotId={snapshotId} />}
<PageHeader
title={__("Risks")}
description={__(
"Risks are potential threats to your organization. Manage them by identifying, assessing, and implementing mitigation measures.",
)}
>
{!isSnapshotMode && canCreateRisk && (
<FormRiskDialog
connection={connectionId}
onSuccess={() => {
pagination.refetch({ snapshotId });
}}
trigger={<Button icon={IconPlusLarge}>{__("New Risk")}</Button>}
/>
)}
<div className="flex gap-2">
{risksDocument && (
<Button
variant="secondary"
icon={IconPageTextLine}
onClick={() => void navigate(
`/organizations/${organizationId}/documents/${risksDocument.id}`,
)}
>
{__("Document")}
</Button>
)}
{canPublishRisk && (
<PublishRiskListDialog
organizationId={organizationId}
defaultApproverIds={defaultApproverIds}
onPublished={documentId => void navigate(
`/organizations/${organizationId}/documents/${documentId}`,
)}
>
<Button variant="secondary" icon={IconUpload}>
{__("Publish")}
</Button>
</PublishRiskListDialog>
)}
{canCreateRisk && (
<FormRiskDialog
connection={connectionId}
onSuccess={() => {
pagination.refetch({});
}}
trigger={<Button icon={IconPlusLarge}>{__("New Risk")}</Button>}
/>
)}
</div>
</PageHeader>
<div className="grid grid-cols-2 gap-4">
@@ -167,8 +194,6 @@ type RowProps = {
function RiskRow(props: RowProps) {
const { __ } = useTranslate();
const { risk, connectionId, organizationId } = props;
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
const [deleteRisk] = useDeleteRiskMutation();
const confirm = useConfirm();
const onDelete = () => {
@@ -195,20 +220,15 @@ function RiskRow(props: RowProps) {
};
const formDialogRef = useDialogRef();
const riskUrl
= isSnapshotMode && snapshotId
? `/organizations/${organizationId}/snapshots/${snapshotId}/risks/${risk.id}/overview`
: `/organizations/${organizationId}/risks/${risk.id}/overview`;
const riskUrl = `/organizations/${organizationId}/risks/${risk.id}/overview`;
return (
<>
{!isSnapshotMode && (
<FormRiskDialog
ref={formDialogRef}
risk={risk}
connection={connectionId}
/>
)}
<FormRiskDialog
ref={formDialogRef}
risk={risk}
connection={connectionId}
/>
<Tr to={riskUrl}>
<Td>{risk.name}</Td>
<Td>{risk.category}</Td>

View File

@@ -0,0 +1,160 @@
// 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 { PublishRiskListDialogMutation } from "#/__generated__/core/PublishRiskListDialogMutation.graphql";
import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
const publishMutation = graphql`
mutation PublishRiskListDialogMutation(
$input: PublishRiskListInput!
) {
publishRiskList(input: $input) {
documentEdge {
node {
id
}
}
}
}
`;
type Props = {
children: ReactNode;
organizationId: string;
defaultApproverIds?: string[];
onPublished?: (documentId: string) => void;
};
export function PublishRiskListDialog({
children,
organizationId,
defaultApproverIds,
onPublished,
}: Props) {
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<PublishRiskListDialogMutation>(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.publishRiskList?.documentEdge?.node?.id;
if (documentId) {
toast({
title: __("Success"),
description: hasApprovers
? __("Approval requested successfully.")
: __("Risks published successfully."),
variant: "success",
});
dialogRef.current?.close();
reset();
onPublished?.(documentId);
}
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to publish risks"),
error as GraphQLError,
),
variant: "error",
});
},
});
};
return (
<Dialog
className="max-w-xl"
ref={dialogRef}
trigger={children}
title={__("Publish Risks")}
>
<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

@@ -1,57 +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 { getSnapshotTypeUrlPath } from "@probo/helpers";
import { useEffect } from "react";
import { type PreloadedQuery, usePreloadedQuery } from "react-relay";
import { useNavigate, useParams } from "react-router";
import type { SnapshotGraphNodeQuery } from "#/__generated__/core/SnapshotGraphNodeQuery.graphql";
import { PageError } from "#/components/PageError";
import { snapshotNodeQuery } from "#/hooks/graph/SnapshotGraph";
import { useOrganizationId } from "#/hooks/useOrganizationId";
type Props = {
queryRef: PreloadedQuery<SnapshotGraphNodeQuery>;
};
export default function SnapshotDetailPage({ queryRef }: Props) {
const navigate = useNavigate();
const organizationId = useOrganizationId();
const { snapshotId } = useParams();
const data = usePreloadedQuery(snapshotNodeQuery, queryRef);
useEffect(() => {
if (!data.node || !data.node.type) {
return;
}
const snapshot = data.node;
const snapshotType = snapshot.type;
const urlPath = getSnapshotTypeUrlPath(snapshotType);
void navigate(
`/organizations/${organizationId}/snapshots/${snapshotId}${urlPath}`,
{
replace: true,
},
);
}, [data.node, navigate, organizationId, snapshotId]);
if (!data.node || !data.node.type) {
return <PageError />;
}
return null;
}

View File

@@ -1,196 +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,
} from "@probo/helpers";
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import {
ActionDropdown,
Badge,
Button,
DropdownItem,
IconPlusLarge,
IconTrashCan,
PageHeader,
Table,
Tbody,
Td,
Th,
Thead,
Tr,
} from "@probo/ui";
import {
type PreloadedQuery,
useFragment,
usePreloadedQuery,
} from "react-relay";
import { graphql } from "relay-runtime";
import type { SnapshotGraphListQuery } from "#/__generated__/core/SnapshotGraphListQuery.graphql";
import type {
SnapshotsPageFragment$data,
SnapshotsPageFragment$key,
} from "#/__generated__/core/SnapshotsPageFragment.graphql";
import { snapshotsQuery, useDeleteSnapshot } from "#/hooks/graph/SnapshotGraph";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import type { NodeOf } from "#/types";
import SnapshotFormDialog from "./dialog/SnapshotFormDialog";
type Props = {
queryRef: PreloadedQuery<SnapshotGraphListQuery>;
};
const snapshotsFragment = graphql`
fragment SnapshotsPageFragment on Organization {
snapshots(first: 100)
@connection(key: "SnapshotsGraphListQuery__snapshots") {
__id
edges {
node {
id
name
description
type
createdAt
canDelete: permission(action: "core:snapshot:delete")
}
}
}
}
`;
export default function SnapshotsPage(props: Props) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const organization = usePreloadedQuery(
snapshotsQuery,
props.queryRef,
).organization;
const data = useFragment<SnapshotsPageFragment$key>(
snapshotsFragment,
organization,
);
const connectionId = data.snapshots.__id;
const snapshots = data.snapshots.edges.map(edge => edge.node);
usePageTitle(__("Snapshots"));
const hasAnyAction = snapshots.some(({ canDelete }) => canDelete);
return (
<div className="space-y-6">
<PageHeader
title={__("Snapshots")}
description={__(
"Snapshots capture point-in-time views of your organization's compliance state. Create snapshots to track progress over time.",
)}
>
{organization.canCreateSnapshot && (
<SnapshotFormDialog connection={connectionId}>
<Button variant="primary" icon={IconPlusLarge}>
{__("New snapshot")}
</Button>
</SnapshotFormDialog>
)}
</PageHeader>
{snapshots.length > 0
? (
<Table>
<Thead>
<Tr>
<Th>{__("Name")}</Th>
<Th>{__("Type")}</Th>
<Th>{__("Description")}</Th>
<Th>{__("Created")}</Th>
{hasAnyAction && <Th></Th>}
</Tr>
</Thead>
<Tbody>
{snapshots.map(snapshot => (
<SnapshotRow
key={snapshot.id}
snapshot={snapshot}
connectionId={connectionId}
organizationId={organizationId}
hasAnyAction={hasAnyAction}
/>
))}
</Tbody>
</Table>
)
: (
<div className="text-center py-12">
<h3 className="text-lg font-medium text-txt-secondary mb-2">
{__("No snapshots yet")}
</h3>
<p className="text-txt-tertiary mb-6">
{__("Create your first snapshot to get started")}
</p>
</div>
)}
</div>
);
}
type SnapshotRowProps = {
snapshot: NodeOf<SnapshotsPageFragment$data["snapshots"]>;
connectionId: string;
organizationId: string;
hasAnyAction: boolean;
};
function SnapshotRow(props: SnapshotRowProps) {
const { __ } = useTranslate();
const deleteSnapshot = useDeleteSnapshot(props.snapshot, props.connectionId);
const typePath = getSnapshotTypeUrlPath(props.snapshot.type);
return (
<Tr
to={`/organizations/${props.organizationId}/snapshots/${props.snapshot.id}${typePath}`}
>
<Td className="font-medium">{props.snapshot.name}</Td>
<Td>
<Badge variant="neutral">
{getSnapshotTypeLabel(__, props.snapshot.type)}
</Badge>
</Td>
<Td className="text-txt-secondary">
{props.snapshot.description || __("No description")}
</Td>
<Td className="text-txt-tertiary">
{formatDate(props.snapshot.createdAt)}
</Td>
{props.hasAnyAction && (
<Td noLink width={50} className="text-end">
<ActionDropdown>
{props.snapshot.canDelete && (
<DropdownItem
onClick={deleteSnapshot}
variant="danger"
icon={IconTrashCan}
>
{__("Delete")}
</DropdownItem>
)}
</ActionDropdown>
</Td>
)}
</Tr>
);
}

View File

@@ -1,153 +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 { snapshotTypes } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { Breadcrumb,
Button,
Dialog,
DialogContent,
DialogFooter,
Input,
Label,
PropertyRow,
Textarea,
useDialogRef,
} from "@probo/ui";
import type { ReactNode } from "react";
import { graphql } from "relay-runtime";
import { z } from "zod";
import { ControlledField } from "#/components/form/ControlledField";
import { SnapshotTypeOptions } from "#/components/form/SnapshotTypeOptions";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import { useOrganizationId } from "#/hooks/useOrganizationId";
const snapshotCreateMutation = graphql`
mutation SnapshotFormDialogCreateMutation(
$input: CreateSnapshotInput!
$connections: [ID!]!
) {
createSnapshot(input: $input) {
snapshotEdge @prependEdge(connections: $connections) {
node {
id
name
description
type
createdAt
canDelete: permission(action: "core:snapshot:delete")
}
}
}
}
`;
const snapshotSchema = z.object({
name: z.string().min(2, { message: "Name is required" }),
description: z.string().optional(),
type: z.enum(snapshotTypes),
});
type Props = {
children?: ReactNode;
connection?: string;
};
export default function SnapshotFormDialog(props: Props) {
const { __ } = useTranslate();
const dialogRef = useDialogRef();
const organizationId = useOrganizationId();
const [mutate] = useMutationWithToasts(snapshotCreateMutation, {
successMessage: __("Snapshot created successfully."),
errorMessage: __("Failed to create snapshot"),
});
const {
handleSubmit,
register,
reset,
control,
formState: { errors },
} = useFormWithSchema(snapshotSchema, {
defaultValues: {
name: "",
description: "",
type: "RISKS",
},
});
const onSubmit = async (data: z.infer<typeof snapshotSchema>) => {
await mutate({
variables: {
input: {
organizationId,
name: data.name,
description: data.description || undefined,
type: data.type,
},
connections: props.connection ? [props.connection] : [],
},
});
reset();
dialogRef.current?.close();
};
return (
<Dialog
ref={dialogRef}
trigger={props.children}
title={<Breadcrumb items={[__("Snapshots"), __("New Snapshot")]} />}
>
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
<DialogContent className="grid grid-cols-[1fr_420px]">
<div className="py-8 px-10 space-y-4">
<Input
id="name"
required
variant="title"
placeholder={__("Snapshot name")}
{...register("name")}
/>
<Textarea
id="description"
variant="ghost"
autogrow
placeholder={__("Add description (optional)")}
{...register("description")}
/>
</div>
{/* Properties form */}
<div className="py-5 px-6 bg-subtle">
<Label>{__("Properties")}</Label>
<PropertyRow
id="type"
label={__("Type")}
error={errors.type?.message}
>
<ControlledField control={control} name="type" type="select">
<SnapshotTypeOptions />
</ControlledField>
</PropertyRow>
</div>
</DialogContent>
<DialogFooter>
<Button type="submit">{__("Create snapshot")}</Button>
</DialogFooter>
</form>
</Dialog>
);
}

View File

@@ -30,7 +30,7 @@ import {
import { clsx } from "clsx";
import { useState } from "react";
import { Controller } from "react-hook-form";
import { useOutletContext, useParams } from "react-router";
import { useOutletContext } from "react-router";
import type { VendorGraphNodeQuery$data } from "#/__generated__/core/VendorGraphNodeQuery.graphql";
import { useVendorForm } from "#/hooks/forms/useVendorForm";
@@ -44,13 +44,11 @@ export default function VendorCertificationsTab() {
}>();
const { __ } = useTranslate();
const { control, handleSubmit } = useVendorForm(vendor);
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
return (
<form
className="space-y-4"
onSubmit={!isSnapshotMode && vendor.canUpdate
onSubmit={vendor.canUpdate
? e => void handleSubmit(e)
: undefined}
>
@@ -62,12 +60,12 @@ export default function VendorCertificationsTab() {
<Certifications
onValueChange={field.onChange}
value={field.value ?? []}
readOnly={isSnapshotMode || !vendor.canUpdate}
readOnly={!vendor.canUpdate}
/>
)}
/>
</Card>
{!isSnapshotMode && vendor.canUpdate && (
{vendor.canUpdate && (
<div className="flex justify-end">
<Button type="submit">{__("Update vendor")}</Button>
</div>

View File

@@ -32,7 +32,7 @@ import {
} from "@probo/ui";
import type { ComponentProps } from "react";
import { useFragment, useRefetchableFragment } from "react-relay";
import { useOutletContext, useParams } from "react-router";
import { useOutletContext } from "react-router";
import { graphql } from "relay-runtime";
import type { ComplianceReportListQuery } from "#/__generated__/core/ComplianceReportListQuery.graphql";
@@ -110,8 +110,6 @@ export default function VendorComplianceTab() {
const connectionId = data.complianceReports.__id;
const reports = data.complianceReports.edges.map(edge => edge.node);
const { __ } = useTranslate();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
usePageTitle(vendor.name + " - " + __("Compliance reports"));
return (
@@ -120,7 +118,7 @@ export default function VendorComplianceTab() {
title={__("Compliance reports")}
description={__("Track vendor compliance certifications and reports.")}
>
{!isSnapshotMode && vendor.canUploadComplianceReport && (
{vendor.canUploadComplianceReport && (
<UploadComplianceReportDialog
vendorId={vendor.id}
connectionId={connectionId}
@@ -139,7 +137,7 @@ export default function VendorComplianceTab() {
<SortableTh field="REPORT_DATE">{__("Report date")}</SortableTh>
<Th>{__("Valid until")}</Th>
<Th>{__("File size")}</Th>
{!isSnapshotMode && reports.length > 0 && <Th>{__("Actions")}</Th>}
{reports.length > 0 && <Th>{__("Actions")}</Th>}
</Tr>
</Thead>
<Tbody>
@@ -148,7 +146,6 @@ export default function VendorComplianceTab() {
key={report.id}
reportKey={report}
connectionId={connectionId}
isSnapshotMode={isSnapshotMode}
/>
))}
</Tbody>
@@ -160,7 +157,6 @@ export default function VendorComplianceTab() {
type ReportRowProps = {
reportKey: VendorComplianceTabFragment_report$key;
connectionId: string;
isSnapshotMode: boolean;
};
function ReportRow(props: ReportRowProps) {
@@ -203,33 +199,31 @@ function ReportRow(props: ReportRowProps) {
<Td>{formatDate(report.reportDate)}</Td>
<Td>{formatDate(report.validUntil)}</Td>
<Td>{fileSize(__, report.file?.size ?? 0)}</Td>
{!props.isSnapshotMode && (
<Td width={50} className="text-end">
<ActionDropdown>
{report.file?.downloadUrl && (
<DropdownItem
icon={IconArrowDown}
onClick={() =>
downloadFile(
report.file!.downloadUrl,
report.file!.fileName,
)}
>
{__("Download")}
</DropdownItem>
)}
{report.canDelete && (
<DropdownItem
icon={IconTrashCan}
onClick={handleDelete}
variant="danger"
>
{__("Delete")}
</DropdownItem>
)}
</ActionDropdown>
</Td>
)}
<Td width={50} className="text-end">
<ActionDropdown>
{report.file?.downloadUrl && (
<DropdownItem
icon={IconArrowDown}
onClick={() =>
downloadFile(
report.file!.downloadUrl,
report.file!.fileName,
)}
>
{__("Download")}
</DropdownItem>
)}
{report.canDelete && (
<DropdownItem
icon={IconTrashCan}
onClick={handleDelete}
variant="danger"
>
{__("Delete")}
</DropdownItem>
)}
</ActionDropdown>
</Td>
</Tr>
);
}

View File

@@ -32,7 +32,7 @@ import {
} from "@probo/ui";
import { type ComponentProps, useState } from "react";
import { useFragment, useRefetchableFragment } from "react-relay";
import { useOutletContext, useParams } from "react-router";
import { useOutletContext } from "react-router";
import { graphql } from "relay-runtime";
import type { VendorContactsListQuery } from "#/__generated__/core/VendorContactsListQuery.graphql";
@@ -112,8 +112,6 @@ export default function VendorContactsTab() {
const connectionId = data.contacts.__id;
const contacts = data.contacts.edges.map(edge => edge.node);
const { __ } = useTranslate();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
const [editingContact, setEditingContact]
= useState<VendorContactsTabFragment_contact$data | null>(null);
const hasAnyAction = contacts.some(
@@ -128,7 +126,7 @@ export default function VendorContactsTab() {
title={__("Contacts")}
description={__("Manage vendor contacts and their information.")}
>
{!isSnapshotMode && vendor.canCreateContact && (
{vendor.canCreateContact && (
<CreateContactDialog vendorId={vendor.id} connectionId={connectionId}>
<Button icon={IconPlusLarge}>{__("Add contact")}</Button>
</CreateContactDialog>
@@ -144,7 +142,7 @@ export default function VendorContactsTab() {
<SortableTh field="EMAIL">{__("Email")}</SortableTh>
<Th>{__("Phone")}</Th>
<Th>{__("Role")}</Th>
{!isSnapshotMode && hasAnyAction && <Th>{__("Actions")}</Th>}
{hasAnyAction && <Th>{__("Actions")}</Th>}
</Tr>
</Thead>
<Tbody>
@@ -154,13 +152,12 @@ export default function VendorContactsTab() {
contactKey={contact}
connectionId={connectionId}
onEdit={setEditingContact}
isSnapshotMode={isSnapshotMode}
/>
))}
</Tbody>
</SortableTable>
{editingContact && !isSnapshotMode && editingContact.canUpdate && (
{editingContact && editingContact.canUpdate && (
<EditContactDialog
contactId={editingContact.id}
contact={editingContact}
@@ -175,7 +172,6 @@ type ContactRowProps = {
contactKey: VendorContactsTabFragment_contact$key;
connectionId: string;
onEdit: (contact: VendorContactsTabFragment_contact$data) => void;
isSnapshotMode: boolean;
};
function ContactRow(props: ContactRowProps) {
@@ -245,7 +241,7 @@ function ContactRow(props: ContactRowProps) {
)}
</Td>
<Td>{contact.role || __("—")}</Td>
{!props.isSnapshotMode && hasAnyAction && (
{hasAnyAction && (
<Td width={50} className="text-end">
<ActionDropdown>
{contact.canUpdate && (

View File

@@ -28,7 +28,7 @@ import {
import type { VendorCategory } from "@probo/vendors";
import { useMemo } from "react";
import { graphql, useFragment } from "react-relay";
import { useOutletContext, useParams } from "react-router";
import { useOutletContext } from "react-router";
import type { VendorGraphNodeQuery$data } from "#/__generated__/core/VendorGraphNodeQuery.graphql";
import type { VendorOverviewTabBusinessAssociateAgreementFragment$key } from "#/__generated__/core/VendorOverviewTabBusinessAssociateAgreementFragment.graphql";
@@ -112,8 +112,6 @@ export default function VendorOverviewTab() {
{ value: "VERSION_CONTROL", label: __("Version Control") },
];
const organizationId = useOrganizationId();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
const {
control,
@@ -158,11 +156,11 @@ export default function VendorOverviewTab() {
usePageTitle(vendor.name + " - " + __("Overview"));
const isFormDisabled = isSubmitting || isSnapshotMode || !vendor.canUpdate;
const isFormDisabled = isSubmitting || !vendor.canUpdate;
return (
<form
onSubmit={isSnapshotMode || !vendor.canUpdate
onSubmit={!vendor.canUpdate
? undefined
: e => void handleSubmit(e)}
className="space-y-12"
@@ -330,7 +328,7 @@ export default function VendorOverviewTab() {
>
{__("Download PDF")}
</Button>
{!isSnapshotMode && businessAssociateAgreement.canUpdate && (
{businessAssociateAgreement.canUpdate && (
<EditBusinessAssociateAgreementDialog
vendorId={vendor.id}
agreement={{
@@ -342,7 +340,7 @@ export default function VendorOverviewTab() {
<Button variant="quaternary" icon={IconPencil} />
</EditBusinessAssociateAgreementDialog>
)}
{!isSnapshotMode && businessAssociateAgreement.canDelete && (
{businessAssociateAgreement.canDelete && (
<DeleteBusinessAssociateAgreementDialog
vendorId={vendor.id}
fileName={businessAssociateAgreement.fileName}
@@ -354,8 +352,7 @@ export default function VendorOverviewTab() {
</>
)
: (
!isSnapshotMode
&& vendor.canUploadBAA && (
vendor.canUploadBAA && (
<UploadBusinessAssociateAgreementDialog
vendorId={vendor.id}
onSuccess={() => window.location.reload()}
@@ -405,7 +402,7 @@ export default function VendorOverviewTab() {
>
{__("Download PDF")}
</Button>
{!isSnapshotMode && dataPrivacyAgreement.canUpdate && (
{dataPrivacyAgreement.canUpdate && (
<EditDataPrivacyAgreementDialog
vendorId={vendor.id}
agreement={{
@@ -417,7 +414,7 @@ export default function VendorOverviewTab() {
<Button variant="quaternary" icon={IconPencil} />
</EditDataPrivacyAgreementDialog>
)}
{!isSnapshotMode && dataPrivacyAgreement.canDelete && (
{dataPrivacyAgreement.canDelete && (
<DeleteDataPrivacyAgreementDialog
vendorId={vendor.id}
fileName={dataPrivacyAgreement.fileName}
@@ -429,8 +426,7 @@ export default function VendorOverviewTab() {
</>
)
: (
!isSnapshotMode
&& vendor.canUploadDPA && (
vendor.canUploadDPA && (
<UploadDataPrivacyAgreementDialog
vendorId={vendor.id}
onSuccess={() => window.location.reload()}
@@ -447,15 +443,13 @@ export default function VendorOverviewTab() {
</div>
{/* Submit */}
{!isSnapshotMode && (
<div className="flex justify-end">
{vendor.canUpdate && (
<Button type="submit" disabled={isSubmitting}>
{__("Update vendor")}
</Button>
)}
</div>
)}
<div className="flex justify-end">
{vendor.canUpdate && (
<Button type="submit" disabled={isSubmitting}>
{__("Update vendor")}
</Button>
)}
</div>
</form>
);
}

View File

@@ -30,7 +30,7 @@ import {
import { clsx } from "clsx";
import { type ComponentProps, useState } from "react";
import { useFragment, useRefetchableFragment } from "react-relay";
import { useOutletContext, useParams } from "react-router";
import { useOutletContext } from "react-router";
import { graphql } from "relay-runtime";
import type { VendorGraphNodeQuery$data } from "#/__generated__/core/VendorGraphNodeQuery.graphql";
@@ -96,8 +96,6 @@ export default function VendorRiskAssessmentTab() {
>(riskAssessmentsFragment, vendor);
const assessments = data.riskAssessments.edges.map(edge => edge.node);
const { __ } = useTranslate();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
const [expanded, setExpanded] = useState<string | null>(null);
usePageTitle(vendor.name + " - " + __("Risk Assessments"));
@@ -106,7 +104,7 @@ export default function VendorRiskAssessmentTab() {
return (
<div className="text-center text-sm py-6 text-txt-secondary flex flex-col items-center gap-2">
{__("No risk assessments found")}
{!isSnapshotMode && vendor.canCreateRiskAssessment && (
{vendor.canCreateRiskAssessment && (
<CreateRiskAssessmentDialog
vendorId={vendor.id}
connection={data.riskAssessments.__id}
@@ -136,7 +134,7 @@ export default function VendorRiskAssessmentTab() {
</Tr>
</Thead>
<Tbody>
{!isSnapshotMode && vendor.canCreateRiskAssessment && (
{vendor.canCreateRiskAssessment && (
<CreateRiskAssessmentDialog
vendorId={vendor.id}
connection={data.riskAssessments.__id}

View File

@@ -32,7 +32,7 @@ import {
} from "@probo/ui";
import { type ComponentProps, useState } from "react";
import { useFragment, useRefetchableFragment } from "react-relay";
import { useOutletContext, useParams } from "react-router";
import { useOutletContext } from "react-router";
import { graphql } from "relay-runtime";
import type { VendorGraphNodeQuery$data } from "#/__generated__/core/VendorGraphNodeQuery.graphql";
@@ -110,8 +110,6 @@ export default function VendorServicesTab() {
const connectionId = data.services.__id;
const services = data.services.edges.map(edge => edge.node);
const { __ } = useTranslate();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
const [editingService, setEditingService]
= useState<VendorServicesTabFragment_service$data | null>(null);
const hasAnyAction = services.some(
@@ -126,7 +124,7 @@ export default function VendorServicesTab() {
title={__("Services")}
description={__("Manage services provided by this vendor.")}
>
{!isSnapshotMode && vendor.canCreateService && (
{vendor.canCreateService && (
<CreateServiceDialog vendorId={vendor.id} connectionId={connectionId}>
<Button icon={IconPlusLarge}>{__("Add service")}</Button>
</CreateServiceDialog>
@@ -140,7 +138,7 @@ export default function VendorServicesTab() {
<Tr>
<SortableTh field="NAME">{__("Name")}</SortableTh>
<Th>{__("Description")}</Th>
{!isSnapshotMode && hasAnyAction && <Th>{__("Actions")}</Th>}
{hasAnyAction && <Th>{__("Actions")}</Th>}
</Tr>
</Thead>
<Tbody>
@@ -150,13 +148,12 @@ export default function VendorServicesTab() {
serviceKey={service}
connectionId={connectionId}
onEdit={setEditingService}
isSnapshotMode={isSnapshotMode}
/>
))}
</Tbody>
</SortableTable>
{editingService && !isSnapshotMode && editingService.canUpdate && (
{editingService && editingService.canUpdate && (
<EditServiceDialog
serviceId={editingService.id}
service={editingService}
@@ -171,7 +168,6 @@ type ServiceRowProps = {
serviceKey: VendorServicesTabFragment_service$key;
connectionId: string;
onEdit: (service: VendorServicesTabFragment_service$data) => void;
isSnapshotMode: boolean;
};
function ServiceRow(props: ServiceRowProps) {
@@ -213,7 +209,7 @@ function ServiceRow(props: ServiceRowProps) {
<Tr>
<Td>{service.name}</Td>
<Td>{service.description || __("—")}</Td>
{!props.isSnapshotMode && hasAnyAction && (
{hasAnyAction && (
<Td width={50} className="text-end">
<ActionDropdown>
{service.canUpdate && (

View File

@@ -45,7 +45,6 @@ import { obligationRoutes } from "./routes/obligationRoutes";
import { processingActivityRoutes } from "./routes/processingActivityRoutes";
import { rightsRequestRoutes } from "./routes/rightsRequestRoutes";
import { riskRoutes } from "./routes/riskRoutes";
import { snapshotsRoutes } from "./routes/snapshotsRoutes";
import { statementsOfApplicabilityRoutes } from "./routes/statementsOfApplicabilityRoutes";
import { taskRoutes } from "./routes/taskRoutes";
import { vendorRoutes } from "./routes/vendorRoutes";
@@ -307,7 +306,6 @@ const routes = [
...accessReviewRoutes,
...compliancePageRoutes,
...cookieBannerRoutes,
...snapshotsRoutes,
{
path: "*",
Component: PageError,

View File

@@ -37,20 +37,6 @@ export const riskRoutes = [
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery<RiskGraphListQuery>(coreEnvironment, risksQuery, {
organizationId: organizationId,
snapshotId: null,
}),
),
Component: withQueryRef(
lazy(() => import("#/pages/organizations/risks/RisksPage")),
),
},
{
path: "snapshots/:snapshotId/risks",
Fallback: RisksPageSkeleton,
loader: loaderFromQueryLoader(({ organizationId, snapshotId }) =>
loadQuery<RiskGraphListQuery>(coreEnvironment, risksQuery, {
organizationId: organizationId,
snapshotId: snapshotId,
}),
),
Component: withQueryRef(
@@ -115,33 +101,4 @@ export const riskRoutes = [
},
],
},
{
path: "snapshots/:snapshotId/risks/:riskId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ riskId }) =>
loadQuery<RiskGraphNodeQuery>(coreEnvironment, riskNodeQuery, {
riskId: riskId,
}),
),
Component: withQueryRef(
lazy(() => import("#/pages/organizations/risks/RiskDetailPage")),
),
children: [
{
path: "",
loader: () => {
// eslint-disable-next-line
throw redirect("overview");
},
Component: Fragment,
},
{
path: "overview",
Fallback: LinkCardSkeleton,
Component: lazy(
() => import("#/pages/organizations/risks/tabs/RiskOverviewTab"),
),
},
],
},
] satisfies AppRoute[];

View File

@@ -1,54 +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 { lazy } from "@probo/react-lazy";
import {
type AppRoute,
loaderFromQueryLoader,
withQueryRef,
} from "@probo/routes";
import { loadQuery } from "react-relay";
import type { SnapshotGraphListQuery } from "#/__generated__/core/SnapshotGraphListQuery.graphql";
import type { SnapshotGraphNodeQuery } from "#/__generated__/core/SnapshotGraphNodeQuery.graphql";
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
import { coreEnvironment } from "#/environments";
import { snapshotNodeQuery, snapshotsQuery } from "#/hooks/graph/SnapshotGraph";
export const snapshotsRoutes = [
{
path: "snapshots",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery<SnapshotGraphListQuery>(coreEnvironment, snapshotsQuery, {
organizationId,
}),
),
Component: withQueryRef(
lazy(() => import("#/pages/organizations/snapshots/SnapshotsPage")),
),
},
{
path: "snapshots/:snapshotId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ snapshotId }) =>
loadQuery<SnapshotGraphNodeQuery>(coreEnvironment, snapshotNodeQuery, {
snapshotId,
}),
),
Component: withQueryRef(
lazy(() => import("#/pages/organizations/snapshots/SnapshotDetailPage")),
),
},
] satisfies AppRoute[];

View File

@@ -192,7 +192,7 @@ INSERT INTO document_versions (
"tenant_id": org.tenantID,
"organization_id": org.organizationID,
"document_id": documentID,
"title": "Asset List",
"title": "Assets",
"major": major + 1,
"content": content,
"published_at": snap.publishedAt,
@@ -386,7 +386,7 @@ ORDER BY v.name ASC;
}
docData := docgen.AssetListData{
Title: "Asset List",
Title: "Assets",
OrganizationName: orgName,
CreatedAt: publishedAt,
TotalAssets: len(assetRows),

View File

@@ -192,7 +192,7 @@ INSERT INTO document_versions (
"tenant_id": org.tenantID,
"organization_id": org.organizationID,
"document_id": documentID,
"title": "Data List",
"title": "Data",
"major": major + 1,
"content": content,
"published_at": snap.publishedAt,
@@ -381,7 +381,7 @@ ORDER BY v.name ASC;
}
docData := docgen.DataListData{
Title: "Data List",
Title: "Data",
OrganizationName: orgName,
CreatedAt: publishedAt,
TotalData: len(dataRows),

View File

@@ -191,7 +191,7 @@ INSERT INTO document_versions (
"tenant_id": org.tenantID,
"organization_id": org.organizationID,
"document_id": documentID,
"title": "Finding List",
"title": "Findings",
"major": major + 1,
"content": content,
"published_at": snap.publishedAt,
@@ -403,7 +403,7 @@ ORDER BY f.reference_id ASC;
}
docData := docgen.FindingListData{
Title: "Finding List",
Title: "Findings",
OrganizationName: orgName,
CreatedAt: publishedAt,
TotalFindings: len(findingRows),

View File

@@ -191,7 +191,7 @@ INSERT INTO document_versions (
"tenant_id": org.tenantID,
"organization_id": org.organizationID,
"document_id": documentID,
"title": "Obligation List",
"title": "Obligations",
"major": major + 1,
"content": content,
"published_at": snap.publishedAt,
@@ -389,7 +389,7 @@ ORDER BY ob.created_at ASC;
}
docData := docgen.ObligationListData{
Title: "Obligation List",
Title: "Obligations",
OrganizationName: orgName,
CreatedAt: publishedAt,
TotalObligations: len(obligationRows),

View File

@@ -0,0 +1,525 @@
// 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-risk-snapshots-to-documents creates documents and document
// versions from existing risk snapshots. For each organization that has risk
// snapshots, it generates a risk list document using the same ProseMirror
// builder as the publish flow.
package main
import (
"context"
"flag"
"fmt"
"net/url"
"os"
"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 migrate(ctx, pgClient, dryRun)
}
type orgWithRiskSnapshots struct {
organizationID gid.GID
tenantID gid.TenantID
organizationName string
}
type riskSnapshot struct {
snapshotID string
publishedAt time.Time
}
func migrate(ctx context.Context, pgClient *pg.Client, dryRun bool) error {
var orgs []orgWithRiskSnapshots
err := pgClient.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
var err error
orgs, err = loadOrgsWithRiskSnapshots(ctx, conn)
return err
})
if err != nil {
return err
}
if len(orgs) == 0 {
fmt.Println("no organizations with risk snapshots to migrate")
return nil
}
var stats struct {
documents, versions, failed int
}
for _, org := range orgs {
if dryRun {
var count int
err := pgClient.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
snapshots, err := loadRiskSnapshots(ctx, conn, org.organizationID)
count = len(snapshots)
return err
})
if err != nil {
return err
}
fmt.Printf("would migrate org %s (%s) — %d risk snapshot(s)\n",
org.organizationID, org.organizationName, count)
continue
}
err := pgClient.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
return migrateOrg(ctx, tx, org)
})
if err != nil {
fmt.Fprintf(os.Stderr, "FAIL org %s (%s): %v\n",
org.organizationID, org.organizationName, err)
stats.failed++
continue
}
stats.documents++
}
if dryRun {
fmt.Printf("\n%d organization(s) would be migrated\n", len(orgs))
return nil
}
fmt.Printf("\nmigrated %d organization(s), %d failed\n",
stats.documents, stats.failed)
return nil
}
func migrateOrg(ctx context.Context, tx pg.Tx, org orgWithRiskSnapshots) error {
snapshots, err := loadRiskSnapshots(ctx, tx, org.organizationID)
if err != nil {
return err
}
if len(snapshots) == 0 {
return nil
}
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: %w", err)
}
_, err = tx.Exec(
ctx,
`INSERT INTO generated_documents (organization_id, tenant_id, risks_document_id, created_at, updated_at)
VALUES (@organization_id, @tenant_id, @risks_document_id, @created_at, @updated_at)
ON CONFLICT (organization_id) DO UPDATE SET risks_document_id = @risks_document_id, updated_at = @updated_at`,
pgx.NamedArgs{
"organization_id": org.organizationID,
"tenant_id": org.tenantID,
"risks_document_id": documentID,
"created_at": now,
"updated_at": now,
},
)
if err != nil {
return fmt.Errorf("cannot link document: %w", 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: %w", snap.snapshotID, 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,
pdf_attempt_count,
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,
0,
@published_at, @published_at, @published_at
)`,
pgx.NamedArgs{
"id": versionID,
"tenant_id": org.tenantID,
"organization_id": org.organizationID,
"document_id": documentID,
"title": "Risks",
"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)
}
}
fmt.Printf("OK org %s (%s) — %d version(s)\n",
org.organizationID, org.organizationName, len(snapshots))
return nil
}
func loadOrgsWithRiskSnapshots(ctx context.Context, conn pg.Querier) ([]orgWithRiskSnapshots, error) {
rows, err := conn.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.risks_document_id IS NOT NULL
)
AND EXISTS (
SELECT 1 FROM snapshots s
WHERE s.organization_id = o.id AND s.type = 'RISKS'
)
ORDER BY o.created_at;
`,
)
if err != nil {
return nil, fmt.Errorf("cannot query organizations with risk snapshots: %w", err)
}
defer rows.Close()
var result []orgWithRiskSnapshots
for rows.Next() {
var o orgWithRiskSnapshots
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 loadRiskSnapshots(ctx context.Context, conn pg.Querier, organizationID gid.GID) ([]riskSnapshot, error) {
rows, err := conn.Query(
ctx,
`
SELECT DISTINCT
s.id,
s.created_at
FROM snapshots s
WHERE s.organization_id = @organization_id
AND s.type = 'RISKS'
ORDER BY s.created_at ASC;
`,
pgx.NamedArgs{"organization_id": organizationID},
)
if err != nil {
return nil, fmt.Errorf("cannot query risk snapshots for org %s: %w", organizationID, err)
}
defer rows.Close()
var result []riskSnapshot
for rows.Next() {
var s riskSnapshot
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()
}
type riskInfo struct {
id string
name string
description *string
category string
treatment string
note string
ownerName string
inherentLikelihood int
inherentImpact int
inherentRiskScore int
residualLikelihood int
residualImpact int
residualRiskScore int
}
func buildSnapshotContent(
ctx context.Context,
tx pg.Tx,
snapshotID string,
orgName string,
publishedAt time.Time,
) (string, error) {
riskRows, err := tx.Query(
ctx,
`
SELECT
r.id,
r.name,
r.description,
r.category,
r.treatment::text,
r.note,
COALESCE(NULLIF(p.full_name, ''), 'Not assigned'),
r.inherent_likelihood,
r.inherent_impact,
r.inherent_risk_score,
r.residual_likelihood,
r.residual_impact,
r.residual_risk_score
FROM risks r
LEFT JOIN iam_membership_profiles p ON p.id = r.owner_profile_id
WHERE r.snapshot_id = @snapshot_id
ORDER BY r.name ASC;
`,
pgx.NamedArgs{"snapshot_id": snapshotID},
)
if err != nil {
return "", fmt.Errorf("cannot load snapshot risks: %w", err)
}
defer riskRows.Close()
var risks []riskInfo
for riskRows.Next() {
var r riskInfo
if err := riskRows.Scan(
&r.id, &r.name, &r.description, &r.category, &r.treatment, &r.note,
&r.ownerName,
&r.inherentLikelihood, &r.inherentImpact, &r.inherentRiskScore,
&r.residualLikelihood, &r.residualImpact, &r.residualRiskScore,
); err != nil {
return "", fmt.Errorf("cannot scan risk: %w", err)
}
risks = append(risks, r)
}
if err := riskRows.Err(); err != nil {
return "", err
}
rows := make([]docgen.RiskListRow, 0, len(risks))
for _, r := range risks {
row := docgen.RiskListRow{
Name: r.name,
Description: derefOrNotSpecified(r.description),
Category: stringOrNotSpecified(r.category),
Treatment: formatTreatment(r.treatment),
Owner: r.ownerName,
InherentLikelihood: r.inherentLikelihood,
InherentLikelihoodLabel: riskLikelihoodLabel(r.inherentLikelihood),
InherentImpact: r.inherentImpact,
InherentImpactLabel: riskImpactLabel(r.inherentImpact),
InherentRiskScore: r.inherentRiskScore,
InherentSeverity: riskSeverityLabel(r.inherentRiskScore),
ResidualLikelihood: r.residualLikelihood,
ResidualLikelihoodLabel: riskLikelihoodLabel(r.residualLikelihood),
ResidualImpact: r.residualImpact,
ResidualImpactLabel: riskImpactLabel(r.residualImpact),
ResidualRiskScore: r.residualRiskScore,
ResidualSeverity: riskSeverityLabel(r.residualRiskScore),
Note: stringOrNotSpecified(r.note),
}
rows = append(rows, row)
}
docData := docgen.RiskListData{
Title: "Risks",
OrganizationName: orgName,
CreatedAt: publishedAt,
TotalRisks: len(rows),
Rows: rows,
}
return probo.BuildRiskListDocument(docData)
}
func derefOrNotSpecified(s *string) string {
if s == nil || *s == "" {
return "Not specified"
}
return *s
}
func stringOrNotSpecified(s string) string {
if s == "" {
return "Not specified"
}
return s
}
func formatTreatment(t string) string {
switch t {
case "MITIGATED":
return "Mitigated"
case "ACCEPTED":
return "Accepted"
case "AVOIDED":
return "Avoided"
case "TRANSFERRED":
return "Transferred"
default:
return stringOrNotSpecified(t)
}
}
func riskLikelihoodLabel(v int) string {
switch v {
case 1:
return "Improbable"
case 2:
return "Remote"
case 3:
return "Occasional"
case 4:
return "Probable"
case 5:
return "Frequent"
default:
return "Unknown"
}
}
func riskImpactLabel(v int) string {
switch v {
case 1:
return "Negligible"
case 2:
return "Low"
case 3:
return "Moderate"
case 4:
return "Significant"
case 5:
return "Catastrophic"
default:
return "Unknown"
}
}
func riskSeverityLabel(score int) string {
switch {
case score >= 15:
return "Critical"
case score >= 5:
return "High"
default:
return "Low"
}
}
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...)
}

View File

@@ -582,144 +582,6 @@ func TestControlAuditMapping_CreateDelete(t *testing.T) {
})
}
func TestControlSnapshotMapping_CreateDelete(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
// Create a framework and control
var createFrameworkResult struct {
CreateFramework struct {
FrameworkEdge struct {
Node struct {
ID string `json:"id"`
} `json:"node"`
} `json:"frameworkEdge"`
} `json:"createFramework"`
}
err := owner.Execute(`
mutation($input: CreateFrameworkInput!) {
createFramework(input: $input) {
frameworkEdge {
node {
id
}
}
}
}
`, map[string]any{
"input": map[string]any{
"organizationId": owner.GetOrganizationID().String(),
"name": "Framework for ControlSnapshot Mapping",
},
}, &createFrameworkResult)
require.NoError(t, err)
frameworkID := createFrameworkResult.CreateFramework.FrameworkEdge.Node.ID
var createControlResult struct {
CreateControl struct {
ControlEdge struct {
Node struct {
ID string `json:"id"`
} `json:"node"`
} `json:"controlEdge"`
} `json:"createControl"`
}
err = owner.Execute(`
mutation($input: CreateControlInput!) {
createControl(input: $input) {
controlEdge {
node {
id
}
}
}
}
`, map[string]any{
"input": map[string]any{
"frameworkId": frameworkID,
"name": "Control for Snapshot Mapping",
"description": "Test control",
"sectionTitle": "Section 1",
"bestPractice": true,
"maturityLevel": "INITIAL",
},
}, &createControlResult)
require.NoError(t, err)
controlID := createControlResult.CreateControl.ControlEdge.Node.ID
// Create a snapshot
var createSnapshotResult struct {
CreateSnapshot struct {
SnapshotEdge struct {
Node struct {
ID string `json:"id"`
} `json:"node"`
} `json:"snapshotEdge"`
} `json:"createSnapshot"`
}
err = owner.Execute(`
mutation($input: CreateSnapshotInput!) {
createSnapshot(input: $input) {
snapshotEdge {
node {
id
}
}
}
}
`, map[string]any{
"input": map[string]any{
"organizationId": owner.GetOrganizationID().String(),
"name": "Snapshot for Control Mapping",
"type": "RISKS",
},
}, &createSnapshotResult)
require.NoError(t, err)
snapshotID := createSnapshotResult.CreateSnapshot.SnapshotEdge.Node.ID
t.Run("create mapping", func(t *testing.T) {
_, err := owner.Do(`
mutation($input: CreateControlSnapshotMappingInput!) {
createControlSnapshotMapping(input: $input) {
controlEdge {
node {
id
}
}
snapshotEdge {
node {
id
}
}
}
}
`, map[string]any{
"input": map[string]any{
"controlId": controlID,
"snapshotId": snapshotID,
},
})
require.NoError(t, err)
})
t.Run("delete mapping", func(t *testing.T) {
_, err := owner.Do(`
mutation($input: DeleteControlSnapshotMappingInput!) {
deleteControlSnapshotMapping(input: $input) {
deletedControlId
deletedSnapshotId
}
}
`, map[string]any{
"input": map[string]any{
"controlId": controlID,
"snapshotId": snapshotID,
},
})
require.NoError(t, err)
})
}
func TestRiskDocumentMapping_CreateDelete(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)

View File

@@ -0,0 +1,336 @@
// 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 TestRisk_PublishRiskList(t *testing.T) {
t.Parallel()
t.Run(
"publish without approvers publishes immediately",
func(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
factory.CreateRisk(owner, factory.Attrs{"name": "Test Risk"})
const query = `
mutation($input: PublishRiskListInput!) {
publishRiskList(input: $input) {
documentEdge {
node {
id
writeMode
status
}
}
documentVersionEdge {
node {
id
title
documentType
status
major
minor
content
}
}
}
}
`
var result struct {
PublishRiskList 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:"publishRiskList"`
}
err := owner.Execute(
query,
map[string]any{
"input": map[string]any{
"organizationId": owner.GetOrganizationID(),
},
},
&result,
)
require.NoError(t, err)
doc := result.PublishRiskList.DocumentEdge.Node
assert.NotEmpty(t, doc.ID)
assert.Equal(t, "GENERATED", doc.WriteMode)
assert.Equal(t, "ACTIVE", doc.Status)
ver := result.PublishRiskList.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")
},
)
t.Run(
"publish with approvers creates draft pending approval",
func(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
const query = `
mutation($input: PublishRiskListInput!) {
publishRiskList(input: $input) {
documentEdge {
node { id writeMode }
}
documentVersionEdge {
node { id status major }
}
}
}
`
var result struct {
PublishRiskList 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:"publishRiskList"`
}
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.PublishRiskList.DocumentEdge.Node
assert.NotEmpty(t, doc.ID)
assert.Equal(t, "GENERATED", doc.WriteMode)
ver := result.PublishRiskList.DocumentVersionEdge.Node
assert.NotEmpty(t, ver.ID)
assert.Equal(t, "PENDING_APPROVAL", ver.Status)
},
)
t.Run(
"second publish reuses document and bumps major version",
func(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
factory.CreateRisk(owner, factory.Attrs{"name": "Reuse Risk"})
const query = `
mutation($input: PublishRiskListInput!) {
publishRiskList(input: $input) {
documentEdge { node { id } }
documentVersionEdge { node { id major } }
}
}
`
var r1, r2 struct {
PublishRiskList 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:"publishRiskList"`
}
input := map[string]any{
"input": map[string]any{
"organizationId": owner.GetOrganizationID(),
},
}
err := owner.Execute(query, input, &r1)
require.NoError(t, err)
err = owner.Execute(query, input, &r2)
require.NoError(t, err)
assert.Equal(t,
r1.PublishRiskList.DocumentEdge.Node.ID,
r2.PublishRiskList.DocumentEdge.Node.ID,
"should reuse same document",
)
assert.Equal(t, 1, r1.PublishRiskList.DocumentVersionEdge.Node.Major)
assert.Equal(t, 2, r2.PublishRiskList.DocumentVersionEdge.Node.Major)
},
)
t.Run(
"organization risksDocument links to published document",
func(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
factory.CreateRisk(owner, factory.Attrs{"name": "Linked Risk"})
const publishQuery = `
mutation($input: PublishRiskListInput!) {
publishRiskList(input: $input) {
documentEdge { node { id } }
documentVersionEdge { node { id } }
}
}
`
var publishResult struct {
PublishRiskList 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:"publishRiskList"`
}
err := owner.Execute(
publishQuery,
map[string]any{
"input": map[string]any{
"organizationId": owner.GetOrganizationID(),
},
},
&publishResult,
)
require.NoError(t, err)
docID := publishResult.PublishRiskList.DocumentEdge.Node.ID
const orgQuery = `
query($id: ID!) {
node(id: $id) {
... on Organization {
id
risksDocument { id }
}
}
}
`
var orgResult struct {
Node struct {
ID string `json:"id"`
RisksDocument *struct {
ID string `json:"id"`
} `json:"risksDocument"`
} `json:"node"`
}
err = owner.Execute(
orgQuery,
map[string]any{"id": owner.GetOrganizationID()},
&orgResult,
)
require.NoError(t, err)
require.NotNil(t, orgResult.Node.RisksDocument)
assert.Equal(t, docID, orgResult.Node.RisksDocument.ID)
},
)
}
func TestRisk_PublishRiskList_RBAC(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
factory.CreateRisk(owner, factory.Attrs{"name": "RBAC Risk"})
const query = `
mutation($input: PublishRiskListInput!) {
publishRiskList(input: $input) {
documentEdge { node { id } }
documentVersionEdge { node { id } }
}
}
`
t.Run(
"viewer cannot publish risk 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)
},
)
}

View File

@@ -1,261 +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.
package console_test
import (
"fmt"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.probo.inc/probo/e2e/internal/testutil"
)
func TestSnapshot_Create(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
query := `
mutation CreateSnapshot($input: CreateSnapshotInput!) {
createSnapshot(input: $input) {
snapshotEdge {
node {
id
name
description
type
}
}
}
}
`
var result struct {
CreateSnapshot struct {
SnapshotEdge struct {
Node struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Type string `json:"type"`
} `json:"node"`
} `json:"snapshotEdge"`
} `json:"createSnapshot"`
}
err := owner.Execute(query, map[string]any{
"input": map[string]any{
"organizationId": owner.GetOrganizationID().String(),
"name": "Q4 2024 Risk Snapshot",
"description": "Quarterly risk assessment snapshot",
"type": "RISKS",
},
}, &result)
require.NoError(t, err)
snapshot := result.CreateSnapshot.SnapshotEdge.Node
assert.NotEmpty(t, snapshot.ID)
assert.Equal(t, "Q4 2024 Risk Snapshot", snapshot.Name)
assert.Equal(t, "Quarterly risk assessment snapshot", snapshot.Description)
assert.Equal(t, "RISKS", snapshot.Type)
}
func TestSnapshot_Delete(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
// Create a snapshot to delete
createQuery := `
mutation CreateSnapshot($input: CreateSnapshotInput!) {
createSnapshot(input: $input) {
snapshotEdge {
node {
id
}
}
}
}
`
var createResult struct {
CreateSnapshot struct {
SnapshotEdge struct {
Node struct {
ID string `json:"id"`
} `json:"node"`
} `json:"snapshotEdge"`
} `json:"createSnapshot"`
}
err := owner.Execute(createQuery, map[string]any{
"input": map[string]any{
"organizationId": owner.GetOrganizationID().String(),
"name": fmt.Sprintf("Snapshot to Delete %d", time.Now().UnixNano()),
"type": "RISKS",
},
}, &createResult)
require.NoError(t, err)
snapshotID := createResult.CreateSnapshot.SnapshotEdge.Node.ID
deleteQuery := `
mutation DeleteSnapshot($input: DeleteSnapshotInput!) {
deleteSnapshot(input: $input) {
deletedSnapshotId
}
}
`
var deleteResult struct {
DeleteSnapshot struct {
DeletedSnapshotID string `json:"deletedSnapshotId"`
} `json:"deleteSnapshot"`
}
err = owner.Execute(deleteQuery, map[string]any{
"input": map[string]any{
"snapshotId": snapshotID,
},
}, &deleteResult)
require.NoError(t, err)
assert.Equal(t, snapshotID, deleteResult.DeleteSnapshot.DeletedSnapshotID)
}
func TestSnapshot_List(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
// Create multiple snapshots
snapshotTypes := []string{"RISKS"}
for i, snapshotType := range snapshotTypes {
query := `
mutation CreateSnapshot($input: CreateSnapshotInput!) {
createSnapshot(input: $input) {
snapshotEdge {
node {
id
}
}
}
}
`
var result struct {
CreateSnapshot struct {
SnapshotEdge struct {
Node struct {
ID string `json:"id"`
} `json:"node"`
} `json:"snapshotEdge"`
} `json:"createSnapshot"`
}
err := owner.Execute(query, map[string]any{
"input": map[string]any{
"organizationId": owner.GetOrganizationID().String(),
"name": fmt.Sprintf("Snapshot %d %d", i, time.Now().UnixNano()),
"type": snapshotType,
},
}, &result)
require.NoError(t, err)
}
query := `
query GetSnapshots($id: ID!) {
node(id: $id) {
... on Organization {
snapshots(first: 10) {
edges {
node {
id
name
type
}
}
totalCount
}
}
}
}
`
var result struct {
Node struct {
Snapshots struct {
Edges []struct {
Node struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
} `json:"node"`
} `json:"edges"`
TotalCount int `json:"totalCount"`
} `json:"snapshots"`
} `json:"node"`
}
err := owner.Execute(query, map[string]any{
"id": owner.GetOrganizationID().String(),
}, &result)
require.NoError(t, err)
assert.GreaterOrEqual(t, result.Node.Snapshots.TotalCount, len(snapshotTypes))
}
func TestSnapshot_Types(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
snapshotTypes := []string{"RISKS"}
for _, snapshotType := range snapshotTypes {
t.Run(snapshotType, func(t *testing.T) {
query := `
mutation CreateSnapshot($input: CreateSnapshotInput!) {
createSnapshot(input: $input) {
snapshotEdge {
node {
id
type
}
}
}
}
`
var result struct {
CreateSnapshot struct {
SnapshotEdge struct {
Node struct {
ID string `json:"id"`
Type string `json:"type"`
} `json:"node"`
} `json:"snapshotEdge"`
} `json:"createSnapshot"`
}
err := owner.Execute(query, map[string]any{
"input": map[string]any{
"organizationId": owner.GetOrganizationID().String(),
"name": fmt.Sprintf("Snapshot Type %s %d", snapshotType, time.Now().UnixNano()),
"type": snapshotType,
},
}, &result)
require.NoError(t, err)
assert.Equal(t, snapshotType, result.CreateSnapshot.SnapshotEdge.Node.Type)
})
}
}

View File

@@ -1,69 +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.
package mcp_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 TestMCP_Snapshot(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
mc := testutil.NewMCPClient(t, owner)
orgID := owner.GetOrganizationID().String()
// Create a risk so the snapshot has data
factory.CreateRisk(owner)
// Take snapshot
var takeResult struct {
Snapshot struct {
ID string `json:"id"`
} `json:"snapshot"`
}
mc.CallToolInto("takeSnapshot", map[string]any{
"organizationId": orgID,
"name": factory.SafeName("Snapshot"),
"snapshotsType": "RISKS",
}, &takeResult)
require.NotEmpty(t, takeResult.Snapshot.ID)
// Get
var getResult struct {
Snapshot struct {
ID string `json:"id"`
} `json:"snapshot"`
}
mc.CallToolInto("getSnapshot", map[string]any{
"id": takeResult.Snapshot.ID,
}, &getResult)
assert.Equal(t, takeResult.Snapshot.ID, getResult.Snapshot.ID)
// List
var listResult struct {
Snapshots []struct {
ID string `json:"id"`
} `json:"snapshots"`
}
mc.CallToolInto("listSnapshots", map[string]any{
"organizationId": orgID,
}, &listResult)
assert.NotEmpty(t, listResult.Snapshots)
}

Binary file not shown.

View File

@@ -57,12 +57,6 @@ export {
type ControlMaturityLevel,
} from "./controls";
export { getAssetTypeVariant } from "./assets";
export {
getSnapshotTypeLabel,
getSnapshotTypeUrlPath,
snapshotTypes,
validateSnapshotConsistency,
} from "./snapshots";
export {
getAuditStateLabel,
getAuditStateVariant,

View File

@@ -1,62 +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.
type Translator = (s: string) => string;
export const snapshotTypes = [
"RISKS",
] as const;
export function getSnapshotTypeLabel(__: Translator, type: string | null | undefined) {
if (!type) {
return __("Unknown");
}
switch (type) {
case "RISKS":
return __("Risks");
case "VENDORS":
return __("Vendors");
case "PROCESSING_ACTIVITIES":
return __("Processing Activities");
default:
return __("Unknown");
}
}
export function getSnapshotTypeUrlPath(type?: string): string {
switch (type) {
case "RISKS":
return "/risks";
case "VENDORS":
return "/vendors";
case "PROCESSING_ACTIVITIES":
return "/processing-activities";
default:
return "";
}
}
export interface SnapshotableResource {
snapshotId?: string | null | undefined;
}
export function validateSnapshotConsistency(
resource: SnapshotableResource | null | undefined,
urlSnapshotId?: string | null | undefined
): void {
if (resource && resource.snapshotId !== (urlSnapshotId ?? null)) {
throw new Error("PAGE_NOT_FOUND");
}
}

View File

@@ -191,11 +191,6 @@ export class Probo implements INodeType {
value: 'risk',
description: 'Manage risks',
},
{
name: 'Snapshot',
value: 'snapshot',
description: 'Manage snapshots',
},
{
name: 'Statement of Applicability',
value: 'statementOfApplicability',

View File

@@ -26,8 +26,6 @@ import * as linkAuditOp from './linkAudit.operation';
import * as unlinkAuditOp from './unlinkAudit.operation';
import * as linkObligationOp from './linkObligation.operation';
import * as unlinkObligationOp from './unlinkObligation.operation';
import * as linkSnapshotOp from './linkSnapshot.operation';
import * as unlinkSnapshotOp from './unlinkSnapshot.operation';
export const description: INodeProperties[] = [
{
@@ -89,12 +87,6 @@ export const description: INodeProperties[] = [
description: 'Link an obligation to a control',
action: 'Link an obligation to a control',
},
{
name: 'Link Snapshot',
value: 'linkSnapshot',
description: 'Link a snapshot to a control',
action: 'Link a snapshot to a control',
},
{
name: 'Unlink Audit',
value: 'unlinkAudit',
@@ -119,12 +111,6 @@ export const description: INodeProperties[] = [
description: 'Unlink an obligation from a control',
action: 'Unlink an obligation from a control',
},
{
name: 'Unlink Snapshot',
value: 'unlinkSnapshot',
description: 'Unlink a snapshot from a control',
action: 'Unlink a snapshot from a control',
},
{
name: 'Update',
value: 'update',
@@ -147,8 +133,6 @@ export const description: INodeProperties[] = [
...unlinkAuditOp.description,
...linkObligationOp.description,
...unlinkObligationOp.description,
...linkSnapshotOp.description,
...unlinkSnapshotOp.description,
];
export {
@@ -165,6 +149,4 @@ export {
unlinkAuditOp as unlinkAudit,
linkObligationOp as linkObligation,
unlinkObligationOp as unlinkObligation,
linkSnapshotOp as linkSnapshot,
unlinkSnapshotOp as unlinkSnapshot,
};

View File

@@ -1,71 +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 type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { proboApiRequest } from '../../GenericFunctions';
export const description: INodeProperties[] = [
{
displayName: 'Control ID',
name: 'controlId',
type: 'string',
displayOptions: {
show: {
resource: ['control'],
operation: ['unlinkSnapshot'],
},
},
default: '',
description: 'The ID of the control',
required: true,
},
{
displayName: 'Snapshot ID',
name: 'snapshotId',
type: 'string',
displayOptions: {
show: {
resource: ['control'],
operation: ['unlinkSnapshot'],
},
},
default: '',
description: 'The ID of the snapshot to unlink',
required: true,
},
];
export async function execute(
this: IExecuteFunctions,
itemIndex: number,
): Promise<INodeExecutionData> {
const controlId = this.getNodeParameter('controlId', itemIndex) as string;
const snapshotId = this.getNodeParameter('snapshotId', itemIndex) as string;
const query = `
mutation DeleteControlSnapshotMapping($input: DeleteControlSnapshotMappingInput!) {
deleteControlSnapshotMapping(input: $input) {
deletedControlId
deletedSnapshotId
}
}
`;
const responseData = await proboApiRequest.call(this, query, { input: { controlId, snapshotId } });
return {
json: responseData,
pairedItem: { item: itemIndex },
};
}

View File

@@ -37,7 +37,6 @@ import * as processingActivity from './processingActivity';
import * as rightsRequest from './rightsRequest';
import * as user from './user';
import * as risk from './risk';
import * as snapshot from './snapshot';
import * as statementOfApplicability from './statementOfApplicability';
import * as task from './task';
import * as tia from './tia';
@@ -80,7 +79,6 @@ export const resources: Record<string, ResourceModule> = {
rightsRequest: rightsRequest as ResourceModule,
user: user as ResourceModule,
risk: risk as ResourceModule,
snapshot: snapshot as ResourceModule,
statementOfApplicability: statementOfApplicability as ResourceModule,
task: task as ResourceModule,
tia: tia as ResourceModule,

View File

@@ -24,6 +24,7 @@ import * as linkDocumentOp from './linkDocument.operation';
import * as unlinkDocumentOp from './unlinkDocument.operation';
import * as linkObligationOp from './linkObligation.operation';
import * as unlinkObligationOp from './unlinkObligation.operation';
import * as publishOp from './publish.operation';
export const description: INodeProperties[] = [
{
@@ -79,6 +80,12 @@ export const description: INodeProperties[] = [
description: 'Link an obligation to a risk',
action: 'Link an obligation to a risk',
},
{
name: 'Publish List',
value: 'publish',
description: 'Publish the risk register as a document version',
action: 'Publish the risk register',
},
{
name: 'Unlink Document',
value: 'unlinkDocument',
@@ -117,6 +124,7 @@ export const description: INodeProperties[] = [
...unlinkDocumentOp.description,
...linkObligationOp.description,
...unlinkObligationOp.description,
...publishOp.description,
];
export {
@@ -131,4 +139,5 @@ export {
unlinkDocumentOp as unlinkDocument,
linkObligationOp as linkObligation,
unlinkObligationOp as unlinkObligation,
publishOp as publish,
};

View File

@@ -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
// purpose with or without fee is hereby granted, provided that the above
@@ -17,32 +17,31 @@ import { proboApiRequest } from '../../GenericFunctions';
export const description: INodeProperties[] = [
{
displayName: 'Control ID',
name: 'controlId',
displayName: 'Organization ID',
name: 'organizationId',
type: 'string',
displayOptions: {
show: {
resource: ['control'],
operation: ['linkSnapshot'],
resource: ['risk'],
operation: ['publish'],
},
},
default: '',
description: 'The ID of the control',
description: 'The ID of the organization whose risk list to publish',
required: true,
},
{
displayName: 'Snapshot ID',
name: 'snapshotId',
displayName: 'Approver IDs',
name: 'approverIds',
type: 'string',
displayOptions: {
show: {
resource: ['control'],
operation: ['linkSnapshot'],
resource: ['risk'],
operation: ['publish'],
},
},
default: '',
description: 'The ID of the snapshot to link',
required: true,
description: 'Comma-separated list of approver profile IDs',
},
];
@@ -50,29 +49,50 @@ export async function execute(
this: IExecuteFunctions,
itemIndex: number,
): Promise<INodeExecutionData> {
const controlId = this.getNodeParameter('controlId', itemIndex) as string;
const snapshotId = this.getNodeParameter('snapshotId', itemIndex) as string;
const organizationId = this.getNodeParameter('organizationId', itemIndex) as string;
const approverIds = this.getNodeParameter('approverIds', itemIndex, '') as string;
const query = `
mutation CreateControlSnapshotMapping($input: CreateControlSnapshotMappingInput!) {
createControlSnapshotMapping(input: $input) {
controlEdge {
mutation PublishRiskList($input: PublishRiskListInput!) {
publishRiskList(input: $input) {
documentEdge {
node {
id
name
status
currentPublishedMajor
currentPublishedMinor
createdAt
updatedAt
}
}
snapshotEdge {
documentVersionEdge {
node {
id
name
title
major
minor
status
classification
documentType
publishedAt
createdAt
updatedAt
}
}
}
}
`;
const responseData = await proboApiRequest.call(this, query, { input: { controlId, snapshotId } });
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,

View File

@@ -1,146 +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 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: ['snapshot'],
operation: ['create'],
},
},
default: '',
description: 'The ID of the organization',
required: true,
},
{
displayName: 'Name',
name: 'name',
type: 'string',
displayOptions: {
show: {
resource: ['snapshot'],
operation: ['create'],
},
},
default: '',
description: 'The name of the snapshot',
required: true,
},
{
displayName: 'Description',
name: 'description',
type: 'string',
displayOptions: {
show: {
resource: ['snapshot'],
operation: ['create'],
},
},
default: '',
description: 'The description of the snapshot',
},
{
displayName: 'Type',
name: 'type',
type: 'options',
displayOptions: {
show: {
resource: ['snapshot'],
operation: ['create'],
},
},
options: [
{
name: 'Assets',
value: 'ASSETS',
},
{
name: 'Findings',
value: 'FINDINGS',
},
{
name: 'Obligations',
value: 'OBLIGATIONS',
},
{
name: 'Processing Activities',
value: 'PROCESSING_ACTIVITIES',
},
{
name: 'Risks',
value: 'RISKS',
},
{
name: 'Statements of Applicability',
value: 'STATEMENTS_OF_APPLICABILITY',
},
{
name: 'Vendors',
value: 'VENDORS',
},
],
default: 'RISKS',
description: 'The type of snapshot',
required: true,
},
];
export async function execute(
this: IExecuteFunctions,
itemIndex: number,
): Promise<INodeExecutionData> {
const organizationId = this.getNodeParameter('organizationId', itemIndex) as string;
const name = this.getNodeParameter('name', itemIndex) as string;
const description = this.getNodeParameter('description', itemIndex, '') as string;
const type = this.getNodeParameter('type', itemIndex) as string;
const query = `
mutation CreateSnapshot($input: CreateSnapshotInput!) {
createSnapshot(input: $input) {
snapshotEdge {
node {
id
name
description
type
createdAt
}
}
}
}
`;
const variables = {
input: {
organizationId,
name,
...(description && { description }),
type,
},
};
const responseData = await proboApiRequest.call(this, query, variables);
return {
json: responseData,
pairedItem: { item: itemIndex },
};
}

View File

@@ -1,55 +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 type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { proboApiRequest } from '../../GenericFunctions';
export const description: INodeProperties[] = [
{
displayName: 'Snapshot ID',
name: 'snapshotId',
type: 'string',
displayOptions: {
show: {
resource: ['snapshot'],
operation: ['delete'],
},
},
default: '',
description: 'The ID of the snapshot to delete',
required: true,
},
];
export async function execute(
this: IExecuteFunctions,
itemIndex: number,
): Promise<INodeExecutionData> {
const snapshotId = this.getNodeParameter('snapshotId', itemIndex) as string;
const query = `
mutation DeleteSnapshot($input: DeleteSnapshotInput!) {
deleteSnapshot(input: $input) {
deletedSnapshotId
}
}
`;
const responseData = await proboApiRequest.call(this, query, { input: { snapshotId } });
return {
json: responseData,
pairedItem: { item: itemIndex },
};
}

View File

@@ -1,65 +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 type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { proboApiRequest } from '../../GenericFunctions';
export const description: INodeProperties[] = [
{
displayName: 'Snapshot ID',
name: 'snapshotId',
type: 'string',
displayOptions: {
show: {
resource: ['snapshot'],
operation: ['get'],
},
},
default: '',
description: 'The ID of the snapshot',
required: true,
},
];
export async function execute(
this: IExecuteFunctions,
itemIndex: number,
): Promise<INodeExecutionData> {
const snapshotId = this.getNodeParameter('snapshotId', itemIndex) as string;
const query = `
query GetSnapshot($snapshotId: ID!) {
node(id: $snapshotId) {
... on Snapshot {
id
name
description
type
createdAt
}
}
}
`;
const variables = {
snapshotId,
};
const responseData = await proboApiRequest.call(this, query, variables);
return {
json: responseData,
pairedItem: { item: itemIndex },
};
}

View File

@@ -1,114 +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 type { INodeProperties, IExecuteFunctions, INodeExecutionData, IDataObject } from 'n8n-workflow';
import { proboApiRequestAllItems } from '../../GenericFunctions';
export const description: INodeProperties[] = [
{
displayName: 'Organization ID',
name: 'organizationId',
type: 'string',
displayOptions: {
show: {
resource: ['snapshot'],
operation: ['getAll'],
},
},
default: '',
description: 'The ID of the organization',
required: true,
},
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
displayOptions: {
show: {
resource: ['snapshot'],
operation: ['getAll'],
},
},
default: false,
description: 'Whether to return all results or only up to a given limit',
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
displayOptions: {
show: {
resource: ['snapshot'],
operation: ['getAll'],
returnAll: [false],
},
},
typeOptions: {
minValue: 1,
},
default: 50,
description: 'Max number of results to return',
},
];
export async function execute(
this: IExecuteFunctions,
itemIndex: number,
): Promise<INodeExecutionData> {
const organizationId = this.getNodeParameter('organizationId', itemIndex) as string;
const returnAll = this.getNodeParameter('returnAll', itemIndex) as boolean;
const limit = this.getNodeParameter('limit', itemIndex, 50) as number;
const query = `
query GetSnapshots($organizationId: ID!, $first: Int, $after: CursorKey) {
node(id: $organizationId) {
... on Organization {
snapshots(first: $first, after: $after) {
edges {
node {
id
name
description
type
createdAt
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
}
`;
const snapshots = await proboApiRequestAllItems.call(
this,
query,
{ organizationId },
(response) => {
const data = response?.data as IDataObject | undefined;
const node = data?.node as IDataObject | undefined;
return node?.snapshots as IDataObject | undefined;
},
returnAll,
limit,
);
return {
json: { snapshots },
pairedItem: { item: itemIndex },
};
}

View File

@@ -1,66 +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 type { INodeProperties } from 'n8n-workflow';
import * as createOp from './create.operation';
import * as deleteOp from './delete.operation';
import * as getOp from './get.operation';
import * as getAllOp from './getAll.operation';
export const description: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['snapshot'],
},
},
options: [
{
name: 'Create',
value: 'create',
description: 'Create a new snapshot',
action: 'Create a snapshot',
},
{
name: 'Delete',
value: 'delete',
description: 'Delete a snapshot',
action: 'Delete a snapshot',
},
{
name: 'Get',
value: 'get',
description: 'Get a snapshot',
action: 'Get a snapshot',
},
{
name: 'Get Many',
value: 'getAll',
description: 'Get many snapshots',
action: 'Get many snapshots',
},
],
default: 'create',
},
...createOp.description,
...deleteOp.description,
...getOp.description,
...getAllOp.description,
];
export { createOp as create, deleteOp as delete, getOp as get, getAllOp as getAll };

View File

@@ -0,0 +1,148 @@
// 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: PublishRiskListInput!) {
publishRiskList(input: $input) {
documentEdge {
node {
id
status
createdAt
}
}
documentVersionEdge {
node {
id
title
major
minor
status
}
}
}
}
`
type publishResponse struct {
PublishRiskList 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:"publishRiskList"`
}
func NewCmdPublish(f *cmdutil.Factory) *cobra.Command {
var (
flagOrg string
flagApprover []string
)
cmd := &cobra.Command{
Use: "publish",
Short: "Publish the risk register as a document version",
Example: ` # Publish the risk register
prb risk publish --org ORG_ID
# Publish with approvers
prb risk 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(),
cmdutil.TokenRefreshOption(cfg, host, hc),
)
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.PublishRiskList.DocumentVersionEdge.Node
_, _ = fmt.Fprintf(
f.IOStreams.Out,
"Published risk register %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
}

View File

@@ -20,6 +20,7 @@ import (
"go.probo.inc/probo/pkg/cmd/risk/create"
"go.probo.inc/probo/pkg/cmd/risk/delete"
"go.probo.inc/probo/pkg/cmd/risk/list"
"go.probo.inc/probo/pkg/cmd/risk/publish"
"go.probo.inc/probo/pkg/cmd/risk/update"
"go.probo.inc/probo/pkg/cmd/risk/view"
)
@@ -35,6 +36,7 @@ func NewCmdRisk(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(view.NewCmdView(f))
cmd.AddCommand(update.NewCmdUpdate(f))
cmd.AddCommand(delete.NewCmdDelete(f))
cmd.AddCommand(publish.NewCmdPublish(f))
return cmd
}

View File

@@ -44,7 +44,6 @@ import (
processingactivity "go.probo.inc/probo/pkg/cmd/processing-activity"
rightsrequest "go.probo.inc/probo/pkg/cmd/rights-request"
"go.probo.inc/probo/pkg/cmd/risk"
"go.probo.inc/probo/pkg/cmd/snapshot"
"go.probo.inc/probo/pkg/cmd/soa"
"go.probo.inc/probo/pkg/cmd/task"
"go.probo.inc/probo/pkg/cmd/tia"
@@ -112,7 +111,6 @@ func NewCmdRoot(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(processingactivity.NewCmdProcessingActivity(f))
cmd.AddCommand(rightsrequest.NewCmdRightsRequest(f))
cmd.AddCommand(risk.NewCmdRisk(f))
cmd.AddCommand(snapshot.NewCmdSnapshot(f))
cmd.AddCommand(soa.NewCmdSoa(f))
cmd.AddCommand(task.NewCmdTask(f))
cmd.AddCommand(tia.NewCmdTIA(f))

View File

@@ -1,175 +0,0 @@
// 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 create
import (
"encoding/json"
"fmt"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra"
"go.probo.inc/probo/pkg/cli/api"
"go.probo.inc/probo/pkg/cmd/cmdutil"
)
const createMutation = `
mutation($input: CreateSnapshotInput!) {
createSnapshot(input: $input) {
snapshotEdge {
node {
id
name
type
}
}
}
}
`
type createResponse struct {
CreateSnapshot struct {
SnapshotEdge struct {
Node struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
} `json:"node"`
} `json:"snapshotEdge"`
} `json:"createSnapshot"`
}
func NewCmdCreate(f *cmdutil.Factory) *cobra.Command {
var (
flagOrg string
flagName string
flagType string
flagDescription string
)
cmd := &cobra.Command{
Use: "create",
Short: "Create a new snapshot",
Example: ` # Create a snapshot interactively
prb snapshot create
# Create a snapshot non-interactively
prb snapshot create --name "Q1 2026 Risks" --type RISKS`,
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
}
client := api.NewClient(
host,
hc.Token,
"/api/console/v1/graphql",
cfg.HTTPTimeoutDuration(),
cmdutil.TokenRefreshOption(cfg, host, hc),
)
if flagOrg == "" {
flagOrg = hc.Organization
}
if flagOrg == "" {
return fmt.Errorf("organization is required; pass --org or set a default with 'prb auth login'")
}
if f.IOStreams.IsInteractive() {
if flagName == "" {
err := huh.NewInput().
Title("Snapshot name").
Value(&flagName).
Run()
if err != nil {
return err
}
}
if flagType == "" {
err := huh.NewSelect[string]().
Title("Snapshot type").
Options(
huh.NewOption("Risks", "RISKS"),
huh.NewOption("Vendors", "VENDORS"),
huh.NewOption("Assets", "ASSETS"),
huh.NewOption("Findings", "FINDINGS"),
huh.NewOption("Obligations", "OBLIGATIONS"),
huh.NewOption("Processing Activities", "PROCESSING_ACTIVITIES"),
huh.NewOption("Statements of Applicability", "STATEMENTS_OF_APPLICABILITY"),
).
Value(&flagType).
Run()
if err != nil {
return err
}
}
}
if flagName == "" {
return fmt.Errorf("name is required; pass --name or run interactively")
}
if flagType == "" {
return fmt.Errorf("type is required; pass --type or run interactively")
}
input := map[string]any{
"organizationId": flagOrg,
"name": flagName,
"type": flagType,
}
if flagDescription != "" {
input["description"] = flagDescription
}
data, err := client.Do(
createMutation,
map[string]any{"input": input},
)
if err != nil {
return err
}
var resp createResponse
if err := json.Unmarshal(data, &resp); err != nil {
return fmt.Errorf("cannot parse response: %w", err)
}
s := resp.CreateSnapshot.SnapshotEdge.Node
_, _ = fmt.Fprintf(
f.IOStreams.Out,
"Created snapshot %s (%s)\n",
s.ID,
s.Name,
)
return nil
},
}
cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID")
cmd.Flags().StringVar(&flagName, "name", "", "Snapshot name (required)")
cmd.Flags().StringVar(&flagType, "type", "", "Snapshot type: RISKS, VENDORS, ASSETS, FINDINGS, OBLIGATIONS, PROCESSING_ACTIVITIES, STATEMENTS_OF_APPLICABILITY (required)")
cmd.Flags().StringVar(&flagDescription, "description", "", "Snapshot description")
return cmd
}

View File

@@ -1,103 +0,0 @@
// 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 delete
import (
"fmt"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra"
"go.probo.inc/probo/pkg/cli/api"
"go.probo.inc/probo/pkg/cmd/cmdutil"
)
const deleteMutation = `
mutation($input: DeleteSnapshotInput!) {
deleteSnapshot(input: $input) {
deletedSnapshotId
}
}
`
func NewCmdDelete(f *cmdutil.Factory) *cobra.Command {
var flagYes bool
cmd := &cobra.Command{
Use: "delete <id>",
Short: "Delete a snapshot",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if !flagYes {
if !f.IOStreams.IsInteractive() {
return fmt.Errorf("cannot delete snapshot: confirmation required, use --yes to confirm")
}
var confirmed bool
err := huh.NewConfirm().
Title(fmt.Sprintf("Delete snapshot %s?", args[0])).
Value(&confirmed).
Run()
if err != nil {
return err
}
if !confirmed {
return nil
}
}
cfg, err := f.Config()
if err != nil {
return err
}
host, hc, err := cfg.DefaultHost()
if err != nil {
return err
}
client := api.NewClient(
host,
hc.Token,
"/api/console/v1/graphql",
cfg.HTTPTimeoutDuration(),
cmdutil.TokenRefreshOption(cfg, host, hc),
)
_, err = client.Do(
deleteMutation,
map[string]any{
"input": map[string]any{
"snapshotId": args[0],
},
},
)
if err != nil {
return err
}
_, _ = fmt.Fprintf(
f.IOStreams.Out,
"Deleted snapshot %s\n",
args[0],
)
return nil
},
}
cmd.Flags().BoolVarP(&flagYes, "yes", "y", false, "Skip confirmation prompt")
return cmd
}

View File

@@ -1,193 +0,0 @@
// 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 list
import (
"encoding/json"
"fmt"
"github.com/spf13/cobra"
"go.probo.inc/probo/pkg/cli/api"
"go.probo.inc/probo/pkg/cmd/cmdutil"
)
const listQuery = `
query($id: ID!, $first: Int, $after: CursorKey, $orderBy: SnapshotOrder) {
node(id: $id) {
__typename
... on Organization {
snapshots(first: $first, after: $after, orderBy: $orderBy) {
totalCount
edges {
node {
id
name
type
createdAt
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
}
`
type snapshot struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
CreatedAt string `json:"createdAt"`
}
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
var (
flagOrg string
flagLimit int
flagOrderBy string
flagOrderDir string
flagOutput *string
)
cmd := &cobra.Command{
Use: "list",
Short: "List snapshots in an organization",
Aliases: []string{"ls"},
Example: ` # List snapshots in the default organization
prb snapshot list
# List snapshots sorted by name
prb snapshot ls --order-by NAME --json`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if err := cmdutil.ValidateOutputFlag(flagOutput); err != nil {
return err
}
cfg, err := f.Config()
if err != nil {
return err
}
host, hc, err := cfg.DefaultHost()
if err != nil {
return err
}
client := api.NewClient(
host,
hc.Token,
"/api/console/v1/graphql",
cfg.HTTPTimeoutDuration(),
cmdutil.TokenRefreshOption(cfg, host, hc),
)
if flagOrg == "" {
flagOrg = hc.Organization
}
if flagOrg == "" {
return fmt.Errorf("organization is required; pass --org or set a default with 'prb auth login'")
}
variables := map[string]any{
"id": flagOrg,
}
if flagOrderBy != "" {
if err := cmdutil.ValidateEnum("order-by", flagOrderBy, []string{"CREATED_AT", "NAME", "TYPE"}); err != nil {
return err
}
variables["orderBy"] = map[string]any{
"field": flagOrderBy,
"direction": flagOrderDir,
}
}
snapshots, totalCount, err := api.Paginate(
client,
listQuery,
variables,
flagLimit,
func(data json.RawMessage) (*api.Connection[snapshot], error) {
var resp struct {
Node *struct {
Typename string `json:"__typename"`
Snapshots api.Connection[snapshot] `json:"snapshots"`
} `json:"node"`
}
if err := json.Unmarshal(data, &resp); err != nil {
return nil, err
}
if resp.Node == nil {
return nil, fmt.Errorf("organization %s not found", flagOrg)
}
if resp.Node.Typename != "Organization" {
return nil, fmt.Errorf("expected Organization node, got %s", resp.Node.Typename)
}
return &resp.Node.Snapshots, nil
},
)
if err != nil {
return err
}
if *flagOutput == cmdutil.OutputJSON {
return cmdutil.PrintJSON(f.IOStreams.Out, snapshots)
}
if len(snapshots) == 0 {
_, _ = fmt.Fprintln(f.IOStreams.Out, "No snapshots found.")
return nil
}
rows := make([][]string, 0, len(snapshots))
for _, s := range snapshots {
rows = append(rows, []string{
s.ID,
s.Name,
s.Type,
cmdutil.FormatTime(s.CreatedAt),
})
}
t := cmdutil.NewTable("ID", "NAME", "TYPE", "CREATED AT").Rows(rows...)
_, _ = fmt.Fprintln(f.IOStreams.Out, t)
if totalCount > len(snapshots) {
_, _ = fmt.Fprintf(
f.IOStreams.ErrOut,
"\nShowing %d of %d snapshots\n",
len(snapshots),
totalCount,
)
}
return nil
},
}
cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID")
cmd.Flags().IntVarP(&flagLimit, "limit", "L", 30, "Maximum number of snapshots to list")
cmd.Flags().StringVar(&flagOrderBy, "order-by", "", "Order by field (CREATED_AT, NAME, TYPE)")
cmd.Flags().StringVar(&flagOrderDir, "order-direction", "DESC", "Sort direction (ASC, DESC)")
flagOutput = cmdutil.AddOutputFlag(cmd)
return cmd
}

View File

@@ -1,38 +0,0 @@
// 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 snapshot
import (
"github.com/spf13/cobra"
"go.probo.inc/probo/pkg/cmd/cmdutil"
"go.probo.inc/probo/pkg/cmd/snapshot/create"
"go.probo.inc/probo/pkg/cmd/snapshot/delete"
"go.probo.inc/probo/pkg/cmd/snapshot/list"
"go.probo.inc/probo/pkg/cmd/snapshot/view"
)
func NewCmdSnapshot(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "snapshot <command>",
Short: "Manage snapshots",
}
cmd.AddCommand(list.NewCmdList(f))
cmd.AddCommand(create.NewCmdCreate(f))
cmd.AddCommand(view.NewCmdView(f))
cmd.AddCommand(delete.NewCmdDelete(f))
return cmd
}

View File

@@ -1,133 +0,0 @@
// 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 view
import (
"encoding/json"
"fmt"
"github.com/charmbracelet/lipgloss"
"github.com/spf13/cobra"
"go.probo.inc/probo/pkg/cli/api"
"go.probo.inc/probo/pkg/cmd/cmdutil"
)
const viewQuery = `
query($id: ID!) {
node(id: $id) {
__typename
... on Snapshot {
id
name
description
type
createdAt
}
}
}
`
type viewResponse struct {
Node *struct {
Typename string `json:"__typename"`
ID string `json:"id"`
Name string `json:"name"`
Description *string `json:"description"`
Type string `json:"type"`
CreatedAt string `json:"createdAt"`
} `json:"node"`
}
func NewCmdView(f *cmdutil.Factory) *cobra.Command {
var flagOutput *string
cmd := &cobra.Command{
Use: "view <id>",
Short: "View a snapshot",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := cmdutil.ValidateOutputFlag(flagOutput); err != nil {
return err
}
cfg, err := f.Config()
if err != nil {
return err
}
host, hc, err := cfg.DefaultHost()
if err != nil {
return err
}
client := api.NewClient(
host,
hc.Token,
"/api/console/v1/graphql",
cfg.HTTPTimeoutDuration(),
cmdutil.TokenRefreshOption(cfg, host, hc),
)
data, err := client.Do(
viewQuery,
map[string]any{"id": args[0]},
)
if err != nil {
return err
}
var resp viewResponse
if err := json.Unmarshal(data, &resp); err != nil {
return fmt.Errorf("cannot parse response: %w", err)
}
if resp.Node == nil {
return fmt.Errorf("snapshot %s not found", args[0])
}
if resp.Node.Typename != "Snapshot" {
return fmt.Errorf("expected Snapshot node, got %s", resp.Node.Typename)
}
if *flagOutput == cmdutil.OutputJSON {
return cmdutil.PrintJSON(f.IOStreams.Out, resp.Node)
}
s := resp.Node
out := f.IOStreams.Out
bold := lipgloss.NewStyle().Bold(true)
label := lipgloss.NewStyle().Foreground(lipgloss.Color("242")).Width(22)
_, _ = fmt.Fprintf(out, "%s\n\n", bold.Render(s.Name))
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("ID:"), s.ID)
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Type:"), s.Type)
if s.Description != nil && *s.Description != "" {
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Description:"), *s.Description)
}
_, _ = fmt.Fprintln(out)
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Created:"), cmdutil.FormatTime(s.CreatedAt))
return nil
},
}
flagOutput = cmdutil.AddOutputFlag(cmd)
return cmd
}

View File

@@ -73,8 +73,6 @@ func ResourceTypeName(entityType uint16) string {
return "Obligation"
case VendorServiceEntityType:
return "VendorService"
case SnapshotEntityType:
return "Snapshot"
case ProcessingActivityEntityType:
return "ProcessingActivity"
case TrustCenterReferenceEntityType:

View File

@@ -970,77 +970,6 @@ WHERE %s
return nil
}
func (c *Controls) LoadBySnapshotID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
snapshotID gid.GID,
cursor *page.Cursor[ControlOrderField],
filter *ControlFilter,
) error {
q := `
WITH ctrl AS (
SELECT
c.id,
c.section_title,
c.framework_id,
c.organization_id,
c.tenant_id,
c.name,
c.description,
c.best_practice,
c.not_implemented_justification,
c.maturity_level,
c.created_at,
c.updated_at,
c.search_vector
FROM
controls c
INNER JOIN
controls_snapshots cs ON c.id = cs.control_id
WHERE
cs.snapshot_id = @snapshot_id
)
SELECT
id,
section_title,
framework_id,
organization_id,
name,
description,
best_practice,
not_implemented_justification,
maturity_level,
created_at,
updated_at
FROM
ctrl
WHERE %s
AND %s
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment())
args := pgx.NamedArgs{"snapshot_id": snapshotID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query controls: %w", err)
}
controls, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Control])
if err != nil {
return fmt.Errorf("cannot collect controls: %w", err)
}
*c = controls
return nil
}
func (c *Controls) CountByStatementOfApplicabilityID(
ctx context.Context,
conn pg.Querier,

View File

@@ -1,100 +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.
package coredata
import (
"context"
"fmt"
"maps"
"time"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
)
type (
ControlSnapshot struct {
ControlID gid.GID `db:"control_id"`
SnapshotID gid.GID `db:"snapshot_id"`
OrganizationID gid.GID `db:"organization_id"`
CreatedAt time.Time `db:"created_at"`
}
ControlSnapshots []*ControlSnapshot
)
func (cs ControlSnapshot) Upsert(
ctx context.Context,
conn pg.Querier,
scope Scoper,
) error {
q := `
INSERT INTO
controls_snapshots (
control_id,
snapshot_id,
organization_id,
tenant_id,
created_at
)
VALUES (
@control_id,
@snapshot_id,
@organization_id,
@tenant_id,
@created_at
)
ON CONFLICT (control_id, snapshot_id) DO NOTHING;
`
args := pgx.StrictNamedArgs{
"control_id": cs.ControlID,
"snapshot_id": cs.SnapshotID,
"organization_id": cs.OrganizationID,
"tenant_id": scope.GetTenantID(),
"created_at": cs.CreatedAt,
}
_, err := conn.Exec(ctx, q, args)
return err
}
func (cs ControlSnapshot) Delete(
ctx context.Context,
conn pg.Tx,
scope Scoper,
controlID gid.GID,
snapshotID gid.GID,
) error {
q := `
DELETE
FROM
controls_snapshots
WHERE
%s
AND control_id = @control_id
AND snapshot_id = @snapshot_id;
`
args := pgx.StrictNamedArgs{
"control_id": controlID,
"snapshot_id": snapshotID,
}
maps.Copy(args, scope.SQLArguments())
q = fmt.Sprintf(q, scope.SQLFragment())
_, err := conn.Exec(ctx, q, args)
return err
}

View File

@@ -54,7 +54,7 @@ const (
_ uint16 = 28 // NonconformityEntityType - removed
ObligationEntityType uint16 = 29
VendorServiceEntityType uint16 = 30
SnapshotEntityType uint16 = 31
_ uint16 = 31 // SnapshotEntityType - removed
_ uint16 = 32 // ContinualImprovementEntityType - removed
ProcessingActivityEntityType uint16 = 33
ExportJobEntityType uint16 = 34
@@ -176,8 +176,6 @@ func NewEntityFromID(id gid.GID) (any, bool) {
return &Obligation{ID: id}, true
case VendorServiceEntityType:
return &VendorService{ID: id}, true
case SnapshotEntityType:
return &Snapshot{ID: id}, true
case ProcessingActivityEntityType:
return &ProcessingActivity{ID: id}, true
case ExportJobEntityType:

View File

@@ -0,0 +1,90 @@
-- 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 risks_document_id TEXT REFERENCES documents(id) ON DELETE SET NULL;
-- Backfill controls_documents from the legacy controls_snapshots links.
-- For every snapshot type whose register is now an org-level generated
-- document, link each control that was attached to a snapshot to the
-- corresponding generated document. Best effort: skips rows whose target
-- document hasn't been created yet (the matching `cmd/migrate-*` data
-- migration must have already run). ON CONFLICT keeps the migration
-- idempotent and tolerant of pre-existing mappings.
INSERT INTO controls_documents (control_id, document_id, organization_id, tenant_id, created_at)
SELECT DISTINCT
cs.control_id,
CASE s.type
WHEN 'RISKS' THEN gd.risks_document_id
WHEN 'VENDORS' THEN gd.vendors_document_id
WHEN 'ASSETS' THEN gd.asset_list_document_id
WHEN 'DATA' THEN gd.data_document_id
WHEN 'FINDINGS' THEN gd.findings_document_id
WHEN 'OBLIGATIONS' THEN gd.obligations_document_id
WHEN 'PROCESSING_ACTIVITIES' THEN gd.processing_activities_document_id
END AS document_id,
s.organization_id,
s.tenant_id,
NOW()
FROM controls_snapshots cs
INNER JOIN snapshots s ON s.id = cs.snapshot_id
LEFT JOIN generated_documents gd ON gd.organization_id = s.organization_id
WHERE s.type IN (
'RISKS',
'VENDORS',
'ASSETS',
'DATA',
'FINDINGS',
'OBLIGATIONS',
'PROCESSING_ACTIVITIES'
)
AND CASE s.type
WHEN 'RISKS' THEN gd.risks_document_id
WHEN 'VENDORS' THEN gd.vendors_document_id
WHEN 'ASSETS' THEN gd.asset_list_document_id
WHEN 'DATA' THEN gd.data_document_id
WHEN 'FINDINGS' THEN gd.findings_document_id
WHEN 'OBLIGATIONS' THEN gd.obligations_document_id
WHEN 'PROCESSING_ACTIVITIES' THEN gd.processing_activities_document_id
END IS NOT NULL
ON CONFLICT DO NOTHING;
-- For STATEMENTS_OF_APPLICABILITY snapshots, the published document lives on
-- the source SOA (the live row, snapshot_id IS NULL). Link controls that
-- were attached to a SOA snapshot to that source SOA's document.
INSERT INTO controls_documents (control_id, document_id, organization_id, tenant_id, created_at)
SELECT DISTINCT
cs.control_id,
live_soa.document_id,
s.organization_id,
s.tenant_id,
NOW()
FROM controls_snapshots cs
INNER JOIN snapshots s ON s.id = cs.snapshot_id
INNER JOIN statements_of_applicability snap_soa ON snap_soa.snapshot_id = s.id
INNER JOIN statements_of_applicability live_soa
ON live_soa.id = snap_soa.source_id
AND live_soa.snapshot_id IS NULL
WHERE s.type = 'STATEMENTS_OF_APPLICABILITY'
AND live_soa.document_id IS NOT NULL
ON CONFLICT DO NOTHING;
-- Drop the trailing " List" suffix from previously published register
-- documents so the version title matches the new naming convention used by
-- the publish flow. Restricted to REGISTER document types so unrelated
-- documents that happen to share a title aren't touched.
UPDATE document_versions SET title = 'Assets' WHERE title = 'Asset List' AND document_type = 'REGISTER';
UPDATE document_versions SET title = 'Data' WHERE title = 'Data List' AND document_type = 'REGISTER';
UPDATE document_versions SET title = 'Findings' WHERE title = 'Finding List' AND document_type = 'REGISTER';
UPDATE document_versions SET title = 'Obligations' WHERE title = 'Obligation List' AND document_type = 'REGISTER';

View File

@@ -27,6 +27,113 @@ import (
"go.probo.inc/probo/pkg/page"
)
func (r Risk) GetGeneratedDocumentID(
ctx context.Context,
conn pg.Querier,
organizationID gid.GID,
) (*gid.GID, error) {
var documentID *gid.GID
err := conn.QueryRow(
ctx,
`
SELECT
risks_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 risk list document ID: %w", err)
}
return documentID, nil
}
func (r Risk) 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,
risks_document_id,
created_at,
updated_at
) VALUES (
@organization_id,
@tenant_id,
@risks_document_id,
@created_at,
@updated_at
)
ON CONFLICT (organization_id) DO UPDATE
SET
risks_document_id = @risks_document_id,
updated_at = @updated_at
`,
pgx.NamedArgs{
"organization_id": organizationID,
"tenant_id": tenantID,
"risks_document_id": documentID,
"created_at": now,
"updated_at": now,
},
)
if err != nil {
return fmt.Errorf("cannot upsert risk list document ID: %w", err)
}
return nil
}
func (r Risk) 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
risks_document_id = NULL,
updated_at = @now
WHERE
risks_document_id = ANY(@ids)
`,
pgx.NamedArgs{
"ids": ids,
"now": time.Now(),
},
)
if err != nil {
return fmt.Errorf("cannot clear risk list document references: %w", err)
}
return nil
}
type (
Risk struct {
ID gid.GID `db:"id"`
@@ -43,8 +150,6 @@ type (
ResidualLikelihood int `db:"residual_likelihood"`
ResidualImpact int `db:"residual_impact"`
ResidualRiskScore int `db:"residual_risk_score"`
SnapshotID *gid.GID `db:"snapshot_id"`
SourceID *gid.GID `db:"source_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
@@ -53,10 +158,6 @@ type (
}
Risks []*Risk
RiskSnapshotter interface {
InsertRiskSnapshots(ctx context.Context, conn pg.Tx, scope Scoper, organizationID, snapshotID gid.GID) error
}
)
func (r *Risk) CursorKey(orderBy RiskOrderField) page.CursorKey {
@@ -106,14 +207,14 @@ WITH rsks AS (
SELECT
r.id,
r.tenant_id,
r.search_vector,
r.snapshot_id
r.search_vector
FROM
risks r
INNER JOIN
risks_measures rm ON r.id = rm.risk_id
WHERE
rm.measure_id = @measure_id
AND r.snapshot_id IS NULL
)
SELECT
COUNT(id)
@@ -165,8 +266,6 @@ WITH rsks AS (
r.residual_likelihood,
r.residual_impact,
r.residual_risk_score,
r.snapshot_id,
r.source_id,
r.search_vector,
r.created_at,
r.updated_at
@@ -178,6 +277,7 @@ WITH rsks AS (
iam_membership_profiles p ON r.owner_profile_id = p.id
WHERE
rm.measure_id = @measure_id
AND r.snapshot_id IS NULL
)
SELECT
id,
@@ -195,8 +295,6 @@ SELECT
residual_likelihood,
residual_impact,
residual_risk_score,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -240,6 +338,7 @@ SELECT
FROM risks
WHERE %s
AND organization_id = @organization_id
AND snapshot_id IS NULL
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
@@ -285,8 +384,6 @@ WITH rsks AS (
r.residual_impact,
r.residual_risk_score,
r.category,
r.snapshot_id,
r.source_id,
r.search_vector,
r.created_at,
r.updated_at
@@ -296,6 +393,7 @@ WITH rsks AS (
iam_membership_profiles p ON r.owner_profile_id = p.id
WHERE
r.organization_id = @organization_id
AND r.snapshot_id IS NULL
)
SELECT
id,
@@ -313,8 +411,6 @@ SELECT
residual_impact,
residual_risk_score,
category,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -345,6 +441,58 @@ WHERE %s
return nil
}
func (r *Risks) LoadAllByOrganizationID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
organizationID gid.GID,
) error {
q := `
SELECT
r.id,
r.organization_id,
r.name,
r.description,
r.category,
r.owner_profile_id,
NULL as owner_full_name,
r.treatment,
r.note,
r.inherent_likelihood,
r.inherent_impact,
r.inherent_risk_score,
r.residual_likelihood,
r.residual_impact,
r.residual_risk_score,
r.created_at,
r.updated_at
FROM
risks r
WHERE %s
AND r.organization_id = @organization_id
AND r.snapshot_id IS NULL
ORDER BY r.name ASC, r.id 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 risks: %w", err)
}
risks, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Risk])
if err != nil {
return fmt.Errorf("cannot collect risks: %w", err)
}
*r = risks
return nil
}
func (r *Risk) LoadByID(
ctx context.Context,
conn pg.Querier,
@@ -368,8 +516,6 @@ SELECT
residual_likelihood,
residual_impact,
residual_risk_score,
snapshot_id,
source_id,
created_at,
updated_at
FROM risks
@@ -424,8 +570,6 @@ SELECT
residual_likelihood,
residual_impact,
residual_risk_score,
snapshot_id,
source_id,
created_at,
updated_at
FROM risks
@@ -567,14 +711,14 @@ WITH rsks AS (
SELECT
r.id,
r.tenant_id,
r.search_vector,
r.snapshot_id
r.search_vector
FROM
risks r
INNER JOIN
risks_documents rd ON r.id = rd.risk_id
WHERE
rd.document_id = @document_id
AND r.snapshot_id IS NULL
)
SELECT
COUNT(id)
@@ -598,78 +742,3 @@ WHERE %s
return count, nil
}
func (r Risks) Snapshot(ctx context.Context, conn pg.Tx, scope Scoper, organizationID, snapshotID gid.GID) error {
if err := r.InsertRiskSnapshots(ctx, conn, scope, organizationID, snapshotID); err != nil {
return fmt.Errorf("cannot create risk snapshots: %w", err)
}
return nil
}
func (r Risks) InsertRiskSnapshots(
ctx context.Context,
conn pg.Tx,
scope Scoper,
organizationID gid.GID,
snapshotID gid.GID,
) error {
query := `
INSERT INTO risks (
tenant_id,
id,
snapshot_id,
source_id,
organization_id,
name,
description,
category,
treatment,
note,
owner_profile_id,
inherent_likelihood,
inherent_impact,
residual_likelihood,
residual_impact,
created_at,
updated_at
)
SELECT
@tenant_id,
generate_gid(decode_base64_unpadded(@tenant_id), @risk_entity_type),
@snapshot_id,
r.id,
r.organization_id,
r.name,
r.description,
r.category,
r.treatment,
r.note,
r.owner_profile_id,
r.inherent_likelihood,
r.inherent_impact,
r.residual_likelihood,
r.residual_impact,
r.created_at,
r.updated_at
FROM risks r
WHERE %s AND organization_id = @organization_id AND snapshot_id IS NULL
`
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"snapshot_id": snapshotID,
"organization_id": organizationID,
"risk_entity_type": RiskEntityType,
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, query, args)
if err != nil {
return fmt.Errorf("cannot insert risk snapshots: %w", err)
}
return nil
}

View File

@@ -16,40 +16,24 @@ package coredata
import (
"github.com/jackc/pgx/v5"
"go.probo.inc/probo/pkg/gid"
)
type (
RiskFilter struct {
query *string
snapshotID **gid.GID
query *string
}
)
func NewRiskFilter(query *string, snapshotID **gid.GID) *RiskFilter {
func NewRiskFilter(query *string) *RiskFilter {
return &RiskFilter{
query: query,
snapshotID: snapshotID,
query: query,
}
}
func (f *RiskFilter) SQLArguments() pgx.StrictNamedArgs {
args := pgx.StrictNamedArgs{
return pgx.StrictNamedArgs{
"query": f.query,
}
if f.snapshotID == nil {
args["has_snapshot_filter"] = false
args["filter_snapshot_id"] = nil
} else if *f.snapshotID == nil {
args["has_snapshot_filter"] = true
args["filter_snapshot_id"] = nil
} else {
args["has_snapshot_filter"] = true
args["filter_snapshot_id"] = **f.snapshotID
}
return args
}
func (f *RiskFilter) SQLFragment() string {
@@ -63,14 +47,5 @@ func (f *RiskFilter) SQLFragment() string {
)
ELSE TRUE
END
AND
CASE
WHEN @has_snapshot_filter::boolean = false THEN TRUE
WHEN @has_snapshot_filter::boolean = true AND @filter_snapshot_id::text IS NOT NULL THEN
snapshot_id = @filter_snapshot_id::text
WHEN @has_snapshot_filter::boolean = true AND @filter_snapshot_id::text IS NULL THEN
snapshot_id IS NULL
ELSE TRUE
END
)`
}

View File

@@ -1,316 +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.
package coredata
import (
"context"
"errors"
"fmt"
"maps"
"time"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
Snapshot struct {
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
Name string `db:"name"`
Description *string `db:"description"`
Type SnapshotsType `db:"type"`
CreatedAt time.Time `db:"created_at"`
}
Snapshots []*Snapshot
)
func (s *Snapshot) CursorKey(field SnapshotOrderField) page.CursorKey {
switch field {
case SnapshotOrderFieldCreatedAt:
return page.NewCursorKey(s.ID, s.CreatedAt)
case SnapshotOrderFieldName:
return page.NewCursorKey(s.ID, s.Name)
case SnapshotOrderFieldType:
return page.NewCursorKey(s.ID, s.Type)
}
panic(fmt.Sprintf("unsupported order by: %s", field))
}
func (s *Snapshot) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) {
q := `SELECT organization_id FROM snapshots WHERE id = $1 LIMIT 1;`
var organizationID gid.GID
if err := conn.QueryRow(ctx, q, s.ID).Scan(&organizationID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrResourceNotFound
}
return nil, fmt.Errorf("cannot query snapshot authorization attributes: %w", err)
}
return map[string]string{"organization_id": organizationID.String()}, nil
}
func (s *Snapshot) LoadByID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
snapshotID gid.GID,
) error {
q := `
SELECT
id,
organization_id,
name,
description,
type,
created_at
FROM
snapshots
WHERE
%s
AND id = @snapshot_id
LIMIT 1;
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"snapshot_id": snapshotID}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query snapshots: %w", err)
}
snapshot, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Snapshot])
if err != nil {
return fmt.Errorf("cannot collect snapshot: %w", err)
}
*s = snapshot
return nil
}
func (s *Snapshots) CountByOrganizationID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
organizationID gid.GID,
filter *SnapshotFilter,
) (int, error) {
q := `
SELECT
COUNT(id)
FROM
snapshots
WHERE
%s
AND organization_id = @organization_id
AND type = 'RISKS'
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
args := pgx.StrictNamedArgs{"organization_id": organizationID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments())
row := conn.QueryRow(ctx, q, args)
var count int
if err := row.Scan(&count); err != nil {
return 0, fmt.Errorf("cannot scan count: %w", err)
}
return count, nil
}
func (s *Snapshots) LoadByOrganizationID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
organizationID gid.GID,
cursor *page.Cursor[SnapshotOrderField],
) error {
q := `
SELECT
id,
organization_id,
name,
description,
type,
created_at
FROM
snapshots
WHERE
%s
AND organization_id = @organization_id
AND type = 'RISKS'
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
args := pgx.StrictNamedArgs{"organization_id": organizationID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query snapshots: %w", err)
}
snapshots, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Snapshot])
if err != nil {
return fmt.Errorf("cannot collect snapshots: %w", err)
}
*s = snapshots
return nil
}
func (s *Snapshot) Insert(
ctx context.Context,
conn pg.Tx,
scope Scoper,
) error {
q := `
INSERT INTO snapshots (
id,
tenant_id,
organization_id,
name,
description,
type,
created_at
) VALUES (
@id,
@tenant_id,
@organization_id,
@name,
@description,
@type,
@created_at
)
`
args := pgx.StrictNamedArgs{
"id": s.ID,
"tenant_id": scope.GetTenantID(),
"organization_id": s.OrganizationID,
"name": s.Name,
"description": s.Description,
"type": s.Type,
"created_at": s.CreatedAt,
}
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot insert snapshot: %w", err)
}
return nil
}
func (s *Snapshot) Delete(
ctx context.Context,
conn pg.Tx,
scope Scoper,
) error {
q := `
DELETE FROM snapshots
WHERE
%s
AND organization_id = @organization_id
AND id = @id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"id": s.ID, "organization_id": s.OrganizationID}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot delete snapshot: %w", err)
}
return nil
}
func (s *Snapshots) LoadByControlID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
controlID gid.GID,
cursor *page.Cursor[SnapshotOrderField],
) error {
q := `
WITH snapshots_by_control AS (
SELECT
s.id,
s.tenant_id,
s.organization_id,
s.name,
s.description,
s.type,
s.created_at
FROM
snapshots s
INNER JOIN
controls_snapshots cs ON s.id = cs.snapshot_id
WHERE
cs.control_id = @control_id
)
SELECT
id,
organization_id,
name,
description,
type,
created_at
FROM
snapshots_by_control
WHERE %s
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
args := pgx.StrictNamedArgs{"control_id": controlID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query snapshots: %w", err)
}
snapshots, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Snapshot])
if err != nil {
return fmt.Errorf("cannot collect snapshots: %w", err)
}
*s = snapshots
return nil
}

View File

@@ -1,65 +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.
package coredata
import (
"time"
"github.com/jackc/pgx/v5"
)
type (
SnapshotFilter struct {
snapshotType *SnapshotsType
beforeDate *time.Time
}
)
func NewSnapshotFilter(snapshotType *SnapshotsType) *SnapshotFilter {
return &SnapshotFilter{
snapshotType: snapshotType,
}
}
func (f *SnapshotFilter) WithBeforeDate(beforeDate *time.Time) *SnapshotFilter {
f.beforeDate = beforeDate
return f
}
func (f *SnapshotFilter) SQLArguments() pgx.NamedArgs {
args := pgx.NamedArgs{
"filter_snapshot_type": f.snapshotType,
"filter_before_date": f.beforeDate,
}
return args
}
func (f *SnapshotFilter) SQLFragment() string {
return `
(
CASE
WHEN @filter_snapshot_type::snapshots_type IS NOT NULL THEN
type = @filter_snapshot_type::snapshots_type
ELSE TRUE
END
AND
CASE
WHEN @filter_before_date::timestamptz IS NOT NULL THEN
created_at <= @filter_before_date::timestamptz
ELSE TRUE
END
)`
}

View File

@@ -1,51 +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.
package coredata
import (
"fmt"
)
type SnapshotOrderField string
const (
SnapshotOrderFieldCreatedAt SnapshotOrderField = "CREATED_AT"
SnapshotOrderFieldName SnapshotOrderField = "NAME"
SnapshotOrderFieldType SnapshotOrderField = "TYPE"
)
func (p SnapshotOrderField) Column() string {
return string(p)
}
func (p SnapshotOrderField) String() string {
return string(p)
}
func (p SnapshotOrderField) MarshalText() ([]byte, error) {
return []byte(p.String()), nil
}
func (p *SnapshotOrderField) UnmarshalText(text []byte) error {
val := string(text)
switch val {
case string(SnapshotOrderFieldCreatedAt),
string(SnapshotOrderFieldName),
string(SnapshotOrderFieldType):
*p = SnapshotOrderField(val)
return nil
}
return fmt.Errorf("invalid SnapshotOrderField value: %q", val)
}

View File

@@ -1,80 +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.
package coredata
import (
"database/sql/driver"
"fmt"
)
type (
SnapshotsType string
)
const (
SnapshotsTypeRisks SnapshotsType = "RISKS"
SnapshotsTypeAssets SnapshotsType = "ASSETS"
SnapshotsTypeData SnapshotsType = "DATA"
SnapshotsTypeFindings SnapshotsType = "FINDINGS"
SnapshotsTypeObligations SnapshotsType = "OBLIGATIONS"
SnapshotsTypeProcessingActivities SnapshotsType = "PROCESSING_ACTIVITIES"
SnapshotsTypeStatementsOfApplicability SnapshotsType = "STATEMENTS_OF_APPLICABILITY"
)
func SnapshotsTypes() []SnapshotsType {
return []SnapshotsType{
SnapshotsTypeRisks,
}
}
func (st SnapshotsType) String() string {
return string(st)
}
func (st *SnapshotsType) Scan(value any) error {
var s string
switch v := value.(type) {
case string:
s = v
case []byte:
s = string(v)
default:
return fmt.Errorf("unsupported type for SnapshotsType: %T", value)
}
switch s {
case SnapshotsTypeRisks.String():
*st = SnapshotsTypeRisks
case SnapshotsTypeAssets.String():
*st = SnapshotsTypeAssets
case SnapshotsTypeData.String():
*st = SnapshotsTypeData
case SnapshotsTypeFindings.String(), "NONCONFORMITIES", "CONTINUAL_IMPROVEMENTS":
*st = SnapshotsTypeFindings
case SnapshotsTypeObligations.String():
*st = SnapshotsTypeObligations
case SnapshotsTypeProcessingActivities.String():
*st = SnapshotsTypeProcessingActivities
case SnapshotsTypeStatementsOfApplicability.String():
*st = SnapshotsTypeStatementsOfApplicability
default:
return fmt.Errorf("invalid SnapshotsType value: %q", s)
}
return nil
}
func (st SnapshotsType) Value() (driver.Value, error) {
return st.String(), nil
}

View File

@@ -1,36 +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.
package coredata
import (
"context"
"fmt"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
)
type Snapshottable interface {
Snapshot(ctx context.Context, conn pg.Tx, scope Scoper, organizationID, snapshotID gid.GID) error
}
func GetSnapshottable(snapshotType SnapshotsType) (Snapshottable, error) {
switch snapshotType {
case SnapshotsTypeRisks:
return Risks{}, nil
default:
return nil, fmt.Errorf("unsupported snapshot type: %s", snapshotType)
}
}

View File

@@ -259,6 +259,35 @@ type (
Vendors string
}
RiskListData struct {
Title string
OrganizationName string
CreatedAt time.Time
TotalRisks int
Rows []RiskListRow
}
RiskListRow struct {
Name string
Description string
Category string
Treatment string
Owner string
InherentLikelihood int
InherentLikelihoodLabel string
InherentImpact int
InherentImpactLabel string
InherentRiskScore int
InherentSeverity string
ResidualLikelihood int
ResidualLikelihoodLabel string
ResidualImpact int
ResidualImpactLabel string
ResidualRiskScore int
ResidualSeverity string
Note string
}
FindingListData struct {
Title string
OrganizationName string

View File

@@ -149,8 +149,6 @@ const (
ActionControlDocumentMappingDelete = "core:control:delete-document-mapping"
ActionControlAuditMappingCreate = "core:control:create-audit-mapping"
ActionControlAuditMappingDelete = "core:control:delete-audit-mapping"
ActionControlSnapshotMappingCreate = "core:control:create-snapshot-mapping"
ActionControlSnapshotMappingDelete = "core:control:delete-snapshot-mapping"
ActionControlObligationMappingCreate = "core:control:create-obligation-mapping"
ActionControlObligationMappingDelete = "core:control:delete-obligation-mapping"
@@ -226,6 +224,7 @@ const (
ActionRiskDocumentMappingDelete = "core:risk:delete-document-mapping"
ActionRiskObligationMappingCreate = "core:risk:create-obligation-mapping"
ActionRiskObligationMappingDelete = "core:risk:delete-obligation-mapping"
ActionRiskPublish = "core:risk:publish"
// Asset actions
ActionAssetGet = "core:asset:get"
@@ -283,12 +282,6 @@ const (
ActionProcessingActivityDelete = "core:processing-activity:delete"
ActionProcessingActivityPublish = "core:processing-activity:publish"
// Snapshot actions
ActionSnapshotGet = "core:snapshot:get"
ActionSnapshotList = "core:snapshot:list"
ActionSnapshotCreate = "core:snapshot:create"
ActionSnapshotDelete = "core:snapshot:delete"
// CustomDomain actions
ActionCustomDomainGet = "core:custom-domain:get"
ActionCustomDomainCreate = "core:custom-domain:create"

View File

@@ -707,112 +707,6 @@ func (s ControlService) ListForAuditID(
return page.NewPage([]*coredata.Control(controls), cursor), nil
}
func (s ControlService) CreateSnapshotMapping(
ctx context.Context,
controlID gid.GID,
snapshotID gid.GID,
) (*coredata.Control, *coredata.Snapshot, error) {
control := &coredata.Control{}
snapshot := &coredata.Snapshot{}
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := control.LoadByID(ctx, conn, s.svc.scope, controlID); err != nil {
return fmt.Errorf("cannot load control: %w", err)
}
controlSnapshot := &coredata.ControlSnapshot{
ControlID: controlID,
SnapshotID: snapshotID,
OrganizationID: control.OrganizationID,
CreatedAt: time.Now(),
}
if err := snapshot.LoadByID(ctx, conn, s.svc.scope, snapshotID); err != nil {
return fmt.Errorf("cannot load snapshot: %w", err)
}
if err := controlSnapshot.Upsert(ctx, conn, s.svc.scope); err != nil {
return fmt.Errorf("cannot create control snapshot mapping: %w", err)
}
return nil
},
)
if err != nil {
return nil, nil, err
}
return control, snapshot, nil
}
func (s ControlService) DeleteSnapshotMapping(
ctx context.Context,
controlID gid.GID,
snapshotID gid.GID,
) (*coredata.Control, *coredata.Snapshot, error) {
control := &coredata.Control{}
snapshot := &coredata.Snapshot{}
err := s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
if err := control.LoadByID(ctx, tx, s.svc.scope, controlID); err != nil {
return fmt.Errorf("cannot load control: %w", err)
}
if err := snapshot.LoadByID(ctx, tx, s.svc.scope, snapshotID); err != nil {
return fmt.Errorf("cannot load snapshot: %w", err)
}
controlSnapshot := &coredata.ControlSnapshot{}
if err := controlSnapshot.Delete(ctx, tx, s.svc.scope, control.ID, snapshot.ID); err != nil {
return fmt.Errorf("cannot delete control snapshot mapping: %w", err)
}
return nil
},
)
if err != nil {
return nil, nil, fmt.Errorf("cannot delete control snapshot mapping: %w", err)
}
return control, snapshot, nil
}
func (s ControlService) ListForSnapshotID(
ctx context.Context,
snapshotID gid.GID,
cursor *page.Cursor[coredata.ControlOrderField],
filter *coredata.ControlFilter,
) (*page.Page[*coredata.Control, coredata.ControlOrderField], error) {
var controls coredata.Controls
snapshot := &coredata.Snapshot{}
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := snapshot.LoadByID(ctx, conn, s.svc.scope, snapshotID); err != nil {
return fmt.Errorf("cannot load snapshot: %w", err)
}
if err := controls.LoadBySnapshotID(ctx, conn, s.svc.scope, snapshotID, cursor, filter); err != nil {
return fmt.Errorf("cannot load controls: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage([]*coredata.Control(controls), cursor), nil
}
func (s ControlService) CountForStatementOfApplicabilityID(
ctx context.Context,
statementOfApplicabilityID gid.GID,

View File

@@ -83,8 +83,6 @@ func (s *GeneratedDocumentService) PublishStatementOfApplicability(
}
}
hasApprovers := len(approverIDs) > 0
if existingDoc == nil {
documentID := gid.New(s.svc.scope.GetTenantID(), coredata.DocumentEntityType)
@@ -111,16 +109,11 @@ func (s *GeneratedDocumentService) PublishStatementOfApplicability(
document = existingDoc
}
var newMajor int
if document.CurrentPublishedMajor != nil {
newMajor = *document.CurrentPublishedMajor + 1
} else {
newMajor = 1
}
newMajor := nextDocumentMajor(document)
versionStatus := coredata.DocumentVersionStatusPublished
var publishedAt *time.Time
if hasApprovers {
if len(approverIDs) > 0 {
versionStatus = coredata.DocumentVersionStatusDraft
} else {
publishedAt = &now
@@ -144,41 +137,7 @@ func (s *GeneratedDocumentService) PublishStatementOfApplicability(
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, soa.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
return s.publishOrRequestApproval(ctx, tx, document, documentVersion, soa.OrganizationID, approverIDs, newMajor, now)
},
)
@@ -415,12 +374,7 @@ func (s *GeneratedDocumentService) PublishDataList(
document = existingDoc
}
var newMajor int
if document.CurrentPublishedMajor != nil {
newMajor = *document.CurrentPublishedMajor + 1
} else {
newMajor = 1
}
newMajor := nextDocumentMajor(document)
versionStatus := coredata.DocumentVersionStatusPublished
var publishedAt *time.Time
@@ -435,7 +389,7 @@ func (s *GeneratedDocumentService) PublishDataList(
ID: documentVersionID,
OrganizationID: organizationID,
DocumentID: document.ID,
Title: "Data List",
Title: "Data",
Major: newMajor,
Minor: 0,
Content: prosemirrorJSON,
@@ -448,41 +402,7 @@ func (s *GeneratedDocumentService) PublishDataList(
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
return s.publishOrRequestApproval(ctx, tx, document, documentVersion, organizationID, approverIDs, newMajor, now)
},
)
@@ -524,7 +444,7 @@ func (s *GeneratedDocumentService) buildDataListDocumentData(
if len(data) == 0 {
return docgen.DataListData{
Title: "Data List",
Title: "Data",
OrganizationName: organization.Name,
CreatedAt: time.Now(),
TotalData: 0,
@@ -581,7 +501,7 @@ func (s *GeneratedDocumentService) buildDataListDocumentData(
}
return docgen.DataListData{
Title: "Data List",
Title: "Data",
OrganizationName: organization.Name,
CreatedAt: time.Now(),
TotalData: len(data),
@@ -705,12 +625,7 @@ func (s *GeneratedDocumentService) PublishAssetList(
document = existingDoc
}
var newMajor int
if document.CurrentPublishedMajor != nil {
newMajor = *document.CurrentPublishedMajor + 1
} else {
newMajor = 1
}
newMajor := nextDocumentMajor(document)
versionStatus := coredata.DocumentVersionStatusPublished
var publishedAt *time.Time
@@ -725,7 +640,7 @@ func (s *GeneratedDocumentService) PublishAssetList(
ID: documentVersionID,
OrganizationID: organizationID,
DocumentID: document.ID,
Title: "Asset List",
Title: "Assets",
Major: newMajor,
Minor: 0,
Content: prosemirrorJSON,
@@ -738,41 +653,7 @@ func (s *GeneratedDocumentService) PublishAssetList(
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
return s.publishOrRequestApproval(ctx, tx, document, documentVersion, organizationID, approverIDs, newMajor, now)
},
)
@@ -814,7 +695,7 @@ func (s *GeneratedDocumentService) buildAssetListDocumentData(
if len(assets) == 0 {
return docgen.AssetListData{
Title: "Asset List",
Title: "Assets",
OrganizationName: organization.Name,
CreatedAt: time.Now(),
TotalAssets: 0,
@@ -873,7 +754,7 @@ func (s *GeneratedDocumentService) buildAssetListDocumentData(
}
return docgen.AssetListData{
Title: "Asset List",
Title: "Assets",
OrganizationName: organization.Name,
CreatedAt: time.Now(),
TotalAssets: len(assets),
@@ -1016,12 +897,7 @@ func (s *GeneratedDocumentService) PublishFindingList(
document = existingDoc
}
var newMajor int
if document.CurrentPublishedMajor != nil {
newMajor = *document.CurrentPublishedMajor + 1
} else {
newMajor = 1
}
newMajor := nextDocumentMajor(document)
versionStatus := coredata.DocumentVersionStatusPublished
var publishedAt *time.Time
@@ -1036,7 +912,7 @@ func (s *GeneratedDocumentService) PublishFindingList(
ID: documentVersionID,
OrganizationID: organizationID,
DocumentID: document.ID,
Title: "Finding List",
Title: "Findings",
Major: newMajor,
Minor: 0,
Content: prosemirrorJSON,
@@ -1049,41 +925,7 @@ func (s *GeneratedDocumentService) PublishFindingList(
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
return s.publishOrRequestApproval(ctx, tx, document, documentVersion, organizationID, approverIDs, newMajor, now)
},
)
@@ -1125,7 +967,7 @@ func (s *GeneratedDocumentService) buildFindingListDocumentData(
if len(findings) == 0 {
return docgen.FindingListData{
Title: "Finding List",
Title: "Findings",
OrganizationName: organization.Name,
CreatedAt: time.Now(),
TotalFindings: 0,
@@ -1216,7 +1058,7 @@ func (s *GeneratedDocumentService) buildFindingListDocumentData(
}
return docgen.FindingListData{
Title: "Finding List",
Title: "Findings",
OrganizationName: organization.Name,
CreatedAt: time.Now(),
TotalFindings: len(findings),
@@ -1372,12 +1214,7 @@ func (s *GeneratedDocumentService) PublishObligationList(
document = existingDoc
}
var newMajor int
if document.CurrentPublishedMajor != nil {
newMajor = *document.CurrentPublishedMajor + 1
} else {
newMajor = 1
}
newMajor := nextDocumentMajor(document)
versionStatus := coredata.DocumentVersionStatusPublished
var publishedAt *time.Time
@@ -1392,7 +1229,7 @@ func (s *GeneratedDocumentService) PublishObligationList(
ID: documentVersionID,
OrganizationID: organizationID,
DocumentID: document.ID,
Title: "Obligation List",
Title: "Obligations",
Major: newMajor,
Minor: 0,
Content: prosemirrorJSON,
@@ -1405,41 +1242,7 @@ func (s *GeneratedDocumentService) PublishObligationList(
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
return s.publishOrRequestApproval(ctx, tx, document, documentVersion, organizationID, approverIDs, newMajor, now)
},
)
@@ -1481,7 +1284,7 @@ func (s *GeneratedDocumentService) buildObligationListDocumentData(
if len(obligations) == 0 {
return docgen.ObligationListData{
Title: "Obligation List",
Title: "Obligations",
OrganizationName: organization.Name,
CreatedAt: time.Now(),
TotalObligations: 0,
@@ -1563,7 +1366,7 @@ func (s *GeneratedDocumentService) buildObligationListDocumentData(
}
return docgen.ObligationListData{
Title: "Obligation List",
Title: "Obligations",
OrganizationName: organization.Name,
CreatedAt: time.Now(),
TotalObligations: len(obligations),
@@ -1696,12 +1499,7 @@ func (s *GeneratedDocumentService) PublishProcessingActivityList(
document = existingDoc
}
var newMajor int
if document.CurrentPublishedMajor != nil {
newMajor = *document.CurrentPublishedMajor + 1
} else {
newMajor = 1
}
newMajor := nextDocumentMajor(document)
versionStatus := coredata.DocumentVersionStatusPublished
var publishedAt *time.Time
@@ -1729,41 +1527,7 @@ func (s *GeneratedDocumentService) PublishProcessingActivityList(
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
return s.publishOrRequestApproval(ctx, tx, document, documentVersion, organizationID, approverIDs, newMajor, now)
},
)
@@ -2115,12 +1879,7 @@ func (s *GeneratedDocumentService) PublishDataProtectionImpactAssessmentList(
document = existingDoc
}
var newMajor int
if document.CurrentPublishedMajor != nil {
newMajor = *document.CurrentPublishedMajor + 1
} else {
newMajor = 1
}
newMajor := nextDocumentMajor(document)
versionStatus := coredata.DocumentVersionStatusPublished
var publishedAt *time.Time
@@ -2148,41 +1907,7 @@ func (s *GeneratedDocumentService) PublishDataProtectionImpactAssessmentList(
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
return s.publishOrRequestApproval(ctx, tx, document, documentVersion, organizationID, approverIDs, newMajor, now)
},
)
@@ -2379,12 +2104,7 @@ func (s *GeneratedDocumentService) PublishTransferImpactAssessmentList(
document = existingDoc
}
var newMajor int
if document.CurrentPublishedMajor != nil {
newMajor = *document.CurrentPublishedMajor + 1
} else {
newMajor = 1
}
newMajor := nextDocumentMajor(document)
versionStatus := coredata.DocumentVersionStatusPublished
var publishedAt *time.Time
@@ -2412,41 +2132,7 @@ func (s *GeneratedDocumentService) PublishTransferImpactAssessmentList(
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
return s.publishOrRequestApproval(ctx, tx, document, documentVersion, organizationID, approverIDs, newMajor, now)
},
)
@@ -2656,12 +2342,7 @@ func (s *GeneratedDocumentService) PublishVendorList(
document = existingDoc
}
var newMajor int
if document.CurrentPublishedMajor != nil {
newMajor = *document.CurrentPublishedMajor + 1
} else {
newMajor = 1
}
newMajor := nextDocumentMajor(document)
versionStatus := coredata.DocumentVersionStatusPublished
var publishedAt *time.Time
@@ -2689,42 +2370,7 @@ func (s *GeneratedDocumentService) PublishVendorList(
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 {
zero := 0
document.CurrentPublishedMajor = &newMajor
document.CurrentPublishedMinor = &zero
document.UpdatedAt = now
if err := document.Update(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot update document: %w", err)
}
}
return nil
return s.publishOrRequestApproval(ctx, tx, document, documentVersion, organizationID, approverIDs, newMajor, now)
},
)
@@ -3096,3 +2742,350 @@ func BuildVendorListDocument(data docgen.VendorListData) (string, error) {
}
return buf.String(), nil
}
var riskListTemplate = template.Must(
template.New("risk_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,
"add": func(a, b int) int { return a + b },
}).
ParseFS(Templates, "templates/risk_list.json.tmpl"),
)
func BuildRiskListDocument(data docgen.RiskListData) (string, error) {
var buf bytes.Buffer
if err := riskListTemplate.Execute(&buf, data); err != nil {
return "", fmt.Errorf("cannot execute risk list template: %w", err)
}
return buf.String(), nil
}
func (s *GeneratedDocumentService) PublishRiskList(
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.buildRiskListDocumentData(ctx, tx, organization)
if err != nil {
return fmt.Errorf("cannot build document data: %w", err)
}
prosemirrorJSON, err := BuildRiskListDocument(documentData)
if err != nil {
return fmt.Errorf("cannot build prosemirror document: %w", err)
}
now := time.Now()
risk := coredata.Risk{}
riskDocumentID, err := risk.GetGeneratedDocumentID(ctx, tx, organizationID)
if err != nil {
return fmt.Errorf("cannot query generated documents: %w", err)
}
var existingDoc *coredata.Document
if riskDocumentID != nil {
doc := &coredata.Document{}
err = doc.LoadByID(ctx, tx, s.svc.scope, *riskDocumentID)
if err != nil && !errors.Is(err, coredata.ErrResourceNotFound) {
return fmt.Errorf("cannot load risk list document: %w", err)
}
if err == nil && doc.ArchivedAt == nil {
existingDoc = doc
} else {
if err := risk.ClearGeneratedDocumentID(ctx, tx, []gid.GID{*riskDocumentID}); 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 := risk.UpsertGeneratedDocumentID(ctx, tx, organizationID, s.svc.scope.GetTenantID(), documentID); err != nil {
return fmt.Errorf("cannot upsert generated documents: %w", err)
}
} else {
document = existingDoc
}
newMajor := nextDocumentMajor(document)
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: "Risks",
Major: newMajor,
Minor: 0,
Content: prosemirrorJSON,
Status: versionStatus,
Classification: coredata.DocumentClassificationConfidential,
DocumentType: coredata.DocumentTypeRegister,
Orientation: coredata.DocumentVersionOrientationPortrait,
PublishedAt: publishedAt,
CreatedAt: now,
UpdatedAt: now,
}
return s.publishOrRequestApproval(ctx, tx, document, documentVersion, organizationID, approverIDs, newMajor, now)
},
)
if err != nil {
return nil, nil, err
}
return document, documentVersion, nil
}
func (s *GeneratedDocumentService) GetRisksDocumentID(
ctx context.Context,
organizationID gid.GID,
) (*gid.GID, error) {
var riskDocumentID *gid.GID
err := s.svc.pg.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
risk := coredata.Risk{}
var err error
riskDocumentID, err = risk.GetGeneratedDocumentID(ctx, conn, organizationID)
return err
})
if err != nil {
return nil, fmt.Errorf("cannot get risk list document ID: %w", err)
}
return riskDocumentID, nil
}
func (s *GeneratedDocumentService) buildRiskListDocumentData(
ctx context.Context,
conn pg.Querier,
organization *coredata.Organization,
) (docgen.RiskListData, error) {
var risks coredata.Risks
if err := risks.LoadAllByOrganizationID(ctx, conn, s.svc.scope, organization.ID); err != nil {
return docgen.RiskListData{}, fmt.Errorf("cannot load risks: %w", err)
}
if len(risks) == 0 {
return docgen.RiskListData{
Title: "Risks",
OrganizationName: organization.Name,
CreatedAt: time.Now(),
TotalRisks: 0,
}, nil
}
ownerIDs := make([]gid.GID, 0, len(risks))
ownerIDSet := make(map[gid.GID]struct{})
for _, r := range risks {
if r.OwnerID != nil {
if _, ok := ownerIDSet[*r.OwnerID]; !ok {
ownerIDs = append(ownerIDs, *r.OwnerID)
ownerIDSet[*r.OwnerID] = struct{}{}
}
}
}
profileMap := make(map[gid.GID]*coredata.MembershipProfile)
if len(ownerIDs) > 0 {
var profiles coredata.MembershipProfiles
if err := profiles.LoadByIDs(ctx, conn, s.svc.scope, ownerIDs); err != nil {
return docgen.RiskListData{}, fmt.Errorf("cannot load profiles: %w", err)
}
for _, p := range profiles {
profileMap[p.ID] = p
}
}
rows := make([]docgen.RiskListRow, 0, len(risks))
for _, r := range risks {
rows = append(rows, docgen.RiskListRow{
Name: r.Name,
Description: derefStringOrNotSpecified(r.Description),
Category: stringOrNotSpecified(r.Category),
Treatment: formatRiskTreatment(r.Treatment),
Owner: lookupProfileName(profileMap, r.OwnerID),
InherentLikelihood: r.InherentLikelihood,
InherentLikelihoodLabel: riskLikelihoodLabel(r.InherentLikelihood),
InherentImpact: r.InherentImpact,
InherentImpactLabel: riskImpactLabel(r.InherentImpact),
InherentRiskScore: r.InherentRiskScore,
InherentSeverity: riskSeverityLabel(r.InherentRiskScore),
ResidualLikelihood: r.ResidualLikelihood,
ResidualLikelihoodLabel: riskLikelihoodLabel(r.ResidualLikelihood),
ResidualImpact: r.ResidualImpact,
ResidualImpactLabel: riskImpactLabel(r.ResidualImpact),
ResidualRiskScore: r.ResidualRiskScore,
ResidualSeverity: riskSeverityLabel(r.ResidualRiskScore),
Note: stringOrNotSpecified(r.Note),
})
}
return docgen.RiskListData{
Title: "Risks",
OrganizationName: organization.Name,
CreatedAt: time.Now(),
TotalRisks: len(risks),
Rows: rows,
}, nil
}
func riskLikelihoodLabel(v int) string {
switch v {
case 1:
return "Improbable"
case 2:
return "Remote"
case 3:
return "Occasional"
case 4:
return "Probable"
case 5:
return "Frequent"
default:
return "Unknown"
}
}
func riskImpactLabel(v int) string {
switch v {
case 1:
return "Negligible"
case 2:
return "Low"
case 3:
return "Moderate"
case 4:
return "Significant"
case 5:
return "Catastrophic"
default:
return "Unknown"
}
}
func riskSeverityLabel(score int) string {
switch {
case score >= 15:
return "Critical"
case score >= 5:
return "High"
default:
return "Low"
}
}
func formatRiskTreatment(t coredata.RiskTreatment) string {
switch t {
case coredata.RiskTreatmentMitigated:
return "Mitigated"
case coredata.RiskTreatmentAccepted:
return "Accepted"
case coredata.RiskTreatmentAvoided:
return "Avoided"
case coredata.RiskTreatmentTransferred:
return "Transferred"
default:
return stringOrNotSpecified(string(t))
}
}
// nextDocumentMajor returns the major version to use for a new published
// version of a generated document.
func nextDocumentMajor(doc *coredata.Document) int {
if doc.CurrentPublishedMajor != nil {
return *doc.CurrentPublishedMajor + 1
}
return 1
}
// publishOrRequestApproval inserts a freshly built generated document version
// and either requests approval (if approverIDs is non-empty) or marks the
// document as currently published at newMajor.0. The pending-approval insert
// conflict is mapped to a friendlier error.
func (s *GeneratedDocumentService) publishOrRequestApproval(
ctx context.Context,
tx pg.Tx,
document *coredata.Document,
version *coredata.DocumentVersion,
organizationID gid.GID,
approverIDs []gid.GID,
newMajor int,
now time.Time,
) error {
if err := version.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 len(approverIDs) > 0 {
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)
}
if _, err := s.svc.DocumentApprovals.RequestApprovalInTx(ctx, tx, document, version, approverIDs, nil); err != nil {
return fmt.Errorf("cannot request approval: %w", err)
}
return nil
}
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
}

View File

@@ -74,7 +74,6 @@ var ViewerPolicy = policy.NewPolicy(
ActionProcessingActivityGet, ActionProcessingActivityList,
ActionDataProtectionImpactAssessmentGet, ActionDataProtectionImpactAssessmentList,
ActionTransferImpactAssessmentGet, ActionTransferImpactAssessmentList,
ActionSnapshotGet, ActionSnapshotList,
ActionFileGet, ActionFileDownloadUrl,
ActionSlackConnectionList, ActionConnectorList,
ActionRightsRequestGet, ActionRightsRequestList,
@@ -152,7 +151,6 @@ var AuditorPolicy = policy.NewPolicy(
ActionProcessingActivityGet, ActionProcessingActivityList,
ActionDataProtectionImpactAssessmentGet, ActionDataProtectionImpactAssessmentList,
ActionTransferImpactAssessmentGet, ActionTransferImpactAssessmentList,
ActionSnapshotGet, ActionSnapshotList,
ActionFileGet, ActionFileDownloadUrl,
ActionStatementOfApplicabilityGet, ActionStatementOfApplicabilityList,
ActionApplicabilityStatementGet, ActionApplicabilityStatementList,

View File

@@ -115,7 +115,6 @@ type (
ComplianceExternalURLs *ComplianceExternalURLService
Findings *FindingService
Obligations *ObligationService
Snapshots *SnapshotService
RightsRequests *RightsRequestService
ProcessingActivities *ProcessingActivityService
DataProtectionImpactAssessments *DataProtectionImpactAssessmentService
@@ -278,7 +277,6 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService {
}
tenantService.Findings = &FindingService{svc: tenantService}
tenantService.Obligations = &ObligationService{svc: tenantService}
tenantService.Snapshots = &SnapshotService{svc: tenantService}
tenantService.RightsRequests = &RightsRequestService{svc: tenantService}
tenantService.ProcessingActivities = &ProcessingActivityService{
svc: tenantService,

View File

@@ -1,221 +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.
package probo
import (
"context"
"fmt"
"time"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/validator"
)
type SnapshotService struct {
svc *TenantService
}
type CreateSnapshotRequest struct {
OrganizationID gid.GID
Name string
Description *string
Type coredata.SnapshotsType
}
func (csr *CreateSnapshotRequest) Validate() error {
v := validator.New()
v.Check(csr.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
v.Check(csr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(csr.Description, "description", validator.SafeText(ContentMaxLength))
v.Check(csr.Type, "type", validator.Required(), validator.OneOfSlice(coredata.SnapshotsTypes()))
return v.Error()
}
func (s *SnapshotService) Get(
ctx context.Context,
snapshotID gid.GID,
) (*coredata.Snapshot, error) {
snapshot := &coredata.Snapshot{}
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
return snapshot.LoadByID(ctx, conn, s.svc.scope, snapshotID)
},
)
if err != nil {
return nil, err
}
return snapshot, nil
}
func (s *SnapshotService) Create(
ctx context.Context,
req *CreateSnapshotRequest,
) (*coredata.Snapshot, error) {
now := time.Now()
snapshot := &coredata.Snapshot{
ID: gid.New(s.svc.scope.GetTenantID(), coredata.SnapshotEntityType),
OrganizationID: req.OrganizationID,
Name: req.Name,
Description: req.Description,
Type: req.Type,
CreatedAt: now,
}
err := s.svc.pg.WithTx(
ctx,
func(ctx context.Context, conn pg.Tx) error {
organization := &coredata.Organization{}
if err := organization.LoadByID(ctx, conn, s.svc.scope, req.OrganizationID); err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
if err := snapshot.Insert(ctx, conn, s.svc.scope); err != nil {
return fmt.Errorf("cannot insert snapshot: %w", err)
}
snapshottable, err := coredata.GetSnapshottable(req.Type)
if err != nil {
return err
}
if err := snapshottable.Snapshot(ctx, conn, s.svc.scope, req.OrganizationID, snapshot.ID); err != nil {
return fmt.Errorf("cannot create snapshot: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return snapshot, nil
}
func (s *SnapshotService) Delete(
ctx context.Context,
snapshotID gid.GID,
) error {
err := s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
snapshot := &coredata.Snapshot{}
if err := snapshot.LoadByID(ctx, tx, s.svc.scope, snapshotID); err != nil {
return fmt.Errorf("cannot load snapshot: %w", err)
}
if err := snapshot.Delete(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot delete snapshot: %w", err)
}
return nil
},
)
return err
}
func (s *SnapshotService) ListForOrganizationID(
ctx context.Context,
organizationID gid.GID,
cursor *page.Cursor[coredata.SnapshotOrderField],
) (*page.Page[*coredata.Snapshot, coredata.SnapshotOrderField], error) {
snapshots := coredata.Snapshots{}
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := snapshots.LoadByOrganizationID(ctx, conn, s.svc.scope, organizationID, cursor); err != nil {
return fmt.Errorf("cannot load snapshots: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(snapshots, cursor), nil
}
func (s *SnapshotService) CountForOrganizationID(
ctx context.Context,
organizationID gid.GID,
) (int, error) {
var count int
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) (err error) {
snapshots := coredata.Snapshots{}
filter := coredata.NewSnapshotFilter(nil)
count, err = snapshots.CountByOrganizationID(ctx, conn, s.svc.scope, organizationID, filter)
if err != nil {
return fmt.Errorf("cannot count snapshots: %w", err)
}
return nil
},
)
if err != nil {
return 0, err
}
return count, nil
}
func (s *SnapshotService) ListForControlID(
ctx context.Context,
controlID gid.GID,
cursor *page.Cursor[coredata.SnapshotOrderField],
) (*page.Page[*coredata.Snapshot, coredata.SnapshotOrderField], error) {
var snapshots coredata.Snapshots
control := &coredata.Control{}
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := control.LoadByID(ctx, conn, s.svc.scope, controlID); err != nil {
return fmt.Errorf("cannot load control: %w", err)
}
err := snapshots.LoadByControlID(ctx, conn, s.svc.scope, control.ID, cursor)
if err != nil {
return fmt.Errorf("cannot load snapshots: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(snapshots, cursor), nil
}

View File

@@ -0,0 +1,178 @@
{
"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 register of all risks identified within the organization. It captures each risk's classification, treatment, ownership, inherent and residual scoring (likelihood × impact), the related controls, measures, documents and obligations, plus any relevant notes." }]
},
{ "type": "horizontalRule" },
{
"type": "heading",
"attrs": { "level": 1 },
"content": [{ "type": "text", "text": "2. Risks" }]
}{{range $i, $r := .Rows}},
{
"type": "heading",
"attrs": { "level": 2 },
"content": [{ "type": "text", "text": {{json (printf "2.%d %s" (add $i 1) $r.Name)}} }]
},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": {{json (printf "2.%d.1 General Information" (add $i 1))}} }]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Description: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.Description}} }
]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Category: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.Category}} }
]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Owner: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.Owner}} }
]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Treatment: ", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": {{json $r.Treatment}} }
]
},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": {{json (printf "2.%d.2 Inherent Risk" (add $i 1))}} }]
},
{
"type": "table",
"content": [
{
"type": "tableRow",
"content": [
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [160] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Likelihood", "marks": [{ "type": "bold" }] }] }] },
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [160] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Impact", "marks": [{ "type": "bold" }] }] }] },
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [160] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Score", "marks": [{ "type": "bold" }] }] }] }
]
},
{
"type": "tableRow",
"content": [
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [160] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json (printf "%d — %s" $r.InherentLikelihood $r.InherentLikelihoodLabel)}} }] }] },
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [160] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json (printf "%d — %s" $r.InherentImpact $r.InherentImpactLabel)}} }] }] },
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [160] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json (printf "%d — %s" $r.InherentRiskScore $r.InherentSeverity)}} }] }] }
]
}
]
},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": {{json (printf "2.%d.3 Residual Risk" (add $i 1))}} }]
},
{
"type": "table",
"content": [
{
"type": "tableRow",
"content": [
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [160] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Likelihood", "marks": [{ "type": "bold" }] }] }] },
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [160] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Impact", "marks": [{ "type": "bold" }] }] }] },
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [160] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Score", "marks": [{ "type": "bold" }] }] }] }
]
},
{
"type": "tableRow",
"content": [
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [160] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json (printf "%d — %s" $r.ResidualLikelihood $r.ResidualLikelihoodLabel)}} }] }] },
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [160] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json (printf "%d — %s" $r.ResidualImpact $r.ResidualImpactLabel)}} }] }] },
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [160] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json (printf "%d — %s" $r.ResidualRiskScore $r.ResidualSeverity)}} }] }] }
]
}
]
},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": {{json (printf "2.%d.4 Notes" (add $i 1))}} }]
},
{
"type": "paragraph",
"content": [{ "type": "text", "text": {{json $r.Note}} }]
},
{ "type": "horizontalRule" }{{end}},
{
"type": "heading",
"attrs": { "level": 1 },
"content": [{ "type": "text", "text": "3. Definitions" }]
},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": "Treatment" }]
},
{
"type": "bulletList",
"content": [
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Mitigated: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Controls are in place to reduce the likelihood and/or impact of the risk." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Accepted: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The risk is acknowledged and accepted without further action." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Avoided: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Activities giving rise to the risk are avoided altogether." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Transferred: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The risk is shifted to a third party (e.g., insurance, vendor contract)." }] }] }
]
},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": "Likelihood" }]
},
{
"type": "bulletList",
"content": [
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "1 — Improbable: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Highly unlikely to occur under normal circumstances." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "2 — Remote: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Unlikely but possible." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "3 — Occasional: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Could occur from time to time." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "4 — Probable: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Likely to occur in most circumstances." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "5 — Frequent: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Expected to occur regularly." }] }] }
]
},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": "Impact" }]
},
{
"type": "bulletList",
"content": [
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "1 — Negligible: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Minimal disruption; absorbed by routine operations." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "2 — Low: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Limited disruption; localized impact." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "3 — Moderate: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Notable disruption to operations or finances." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "4 — Significant: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Major disruption; prolonged recovery effort needed." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "5 — Catastrophic: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Severe organization-wide impact threatening continuity." }] }] }
]
},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": "Risk Score" }]
},
{
"type": "paragraph",
"content": [{ "type": "text", "text": "Risk Score is the product of Likelihood × Impact (range 1–25). Inherent values represent the risk before controls; residual values represent the risk after controls have been applied. Severity bands: Low (≤4), High (5–14), Critical (≥15)." }]
}
]
}

View File

@@ -239,15 +239,6 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error
}
return types.NewTransferImpactAssessment(tia), nil
}
case coredata.SnapshotEntityType:
action = probo.ActionSnapshotList
loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) {
snapshot, err := prb.Snapshots.Get(ctx, id)
if err != nil {
return nil, err
}
return types.NewSnapshot(snapshot), nil
}
case coredata.TrustCenterEntityType:
action = probo.ActionTrustCenterGet
loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) {

View File

@@ -300,37 +300,6 @@ func (r *controlResolver) Obligations(ctx context.Context, obj *types.Control, f
return types.NewObligationConnection(page, r, obj.ID), nil
}
// Snapshots is the resolver for the snapshots field.
func (r *controlResolver) Snapshots(ctx context.Context, obj *types.Control, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.SnapshotOrderBy) (*types.SnapshotConnection, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionSnapshotList); err != nil {
return nil, err
}
prb := r.ProboService(ctx, obj.ID.TenantID())
pageOrderBy := page.OrderBy[coredata.SnapshotOrderField]{
Field: coredata.SnapshotOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
if orderBy != nil {
pageOrderBy = page.OrderBy[coredata.SnapshotOrderField]{
Field: orderBy.Field,
Direction: orderBy.Direction,
}
}
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
page, err := prb.Snapshots.ListForControlID(ctx, obj.ID, cursor)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list control snapshots", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewSnapshotConnection(page, r, obj.ID), nil
}
// Permission is the resolver for the permission field.
func (r *controlResolver) Permission(ctx context.Context, obj *types.Control, action string) (bool, error) {
return r.Resolver.Permission(ctx, obj, action)
@@ -708,46 +677,6 @@ func (r *mutationResolver) DeleteControlObligationMapping(ctx context.Context, i
}, nil
}
// CreateControlSnapshotMapping is the resolver for the createControlSnapshotMapping field.
func (r *mutationResolver) CreateControlSnapshotMapping(ctx context.Context, input types.CreateControlSnapshotMappingInput) (*types.CreateControlSnapshotMappingPayload, error) {
if err := r.authorize(ctx, input.ControlID, probo.ActionControlSnapshotMappingCreate); err != nil {
return nil, err
}
prb := r.ProboService(ctx, input.SnapshotID.TenantID())
control, snapshot, err := prb.Controls.CreateSnapshotMapping(ctx, input.ControlID, input.SnapshotID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot create control snapshot mapping", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.CreateControlSnapshotMappingPayload{
ControlEdge: types.NewControlEdge(control, coredata.ControlOrderFieldCreatedAt),
SnapshotEdge: types.NewSnapshotEdge(snapshot, coredata.SnapshotOrderFieldCreatedAt),
}, nil
}
// DeleteControlSnapshotMapping is the resolver for the deleteControlSnapshotMapping field.
func (r *mutationResolver) DeleteControlSnapshotMapping(ctx context.Context, input types.DeleteControlSnapshotMappingInput) (*types.DeleteControlSnapshotMappingPayload, error) {
if err := r.authorize(ctx, input.ControlID, probo.ActionControlSnapshotMappingDelete); err != nil {
return nil, err
}
prb := r.ProboService(ctx, input.SnapshotID.TenantID())
control, snapshot, err := prb.Controls.DeleteSnapshotMapping(ctx, input.ControlID, input.SnapshotID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot delete control snapshot mapping", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.DeleteControlSnapshotMappingPayload{
DeletedControlID: control.ID,
DeletedSnapshotID: snapshot.ID,
}, nil
}
// CreateStatementOfApplicability is the resolver for the createStatementOfApplicability field.
func (r *mutationResolver) CreateStatementOfApplicability(ctx context.Context, input types.CreateStatementOfApplicabilityInput) (*types.CreateStatementOfApplicabilityPayload, error) {
if err := r.authorize(ctx, input.OrganizationID, probo.ActionStatementOfApplicabilityCreate); err != nil {

View File

@@ -141,14 +141,6 @@ type Control implements Node {
orderBy: ObligationOrder
): ObligationConnection! @goField(forceResolver: true)
snapshots(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: SnapshotOrder
): SnapshotConnection! @goField(forceResolver: true)
createdAt: Datetime!
updatedAt: Datetime!
@@ -265,12 +257,6 @@ extend type Mutation {
deleteControlObligationMapping(
input: DeleteControlObligationMappingInput!
): DeleteControlObligationMappingPayload!
createControlSnapshotMapping(
input: CreateControlSnapshotMappingInput!
): CreateControlSnapshotMappingPayload!
deleteControlSnapshotMapping(
input: DeleteControlSnapshotMappingInput!
): DeleteControlSnapshotMappingPayload!
createStatementOfApplicability(
input: CreateStatementOfApplicabilityInput!
): CreateStatementOfApplicabilityPayload!
@@ -366,16 +352,6 @@ input DeleteControlObligationMappingInput {
obligationId: ID!
}
input CreateControlSnapshotMappingInput {
controlId: ID!
snapshotId: ID!
}
input DeleteControlSnapshotMappingInput {
controlId: ID!
snapshotId: ID!
}
input CreateStatementOfApplicabilityInput {
organizationId: ID!
name: String!
@@ -465,16 +441,6 @@ type DeleteControlObligationMappingPayload {
deletedObligationId: ID!
}
type CreateControlSnapshotMappingPayload {
controlEdge: ControlEdge!
snapshotEdge: SnapshotEdge!
}
type DeleteControlSnapshotMappingPayload {
deletedControlId: ID!
deletedSnapshotId: ID!
}
type CreateStatementOfApplicabilityPayload {
statementOfApplicabilityEdge: StatementOfApplicabilityEdge!
}

View File

@@ -286,16 +286,10 @@ type Organization implements Node {
last: Int
before: CursorKey
orderBy: RiskOrder
filter: RiskFilter = { snapshotId: null }
filter: RiskFilter
): RiskConnection! @goField(forceResolver: true)
snapshots(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: SnapshotOrder
): SnapshotConnection! @goField(forceResolver: true)
risksDocument: Document @goField(forceResolver: true)
tasks(
first: Int

View File

@@ -53,12 +53,10 @@ input RiskOrder
input RiskFilter {
query: String
snapshotId: ID
}
type Risk implements Node {
id: ID!
snapshotId: ID
name: String!
description: String
category: String!
@@ -151,6 +149,19 @@ extend type Mutation {
deleteRiskObligationMapping(
input: DeleteRiskObligationMappingInput!
): DeleteRiskObligationMappingPayload!
publishRiskList(
input: PublishRiskListInput!
): PublishRiskListPayload!
}
input PublishRiskListInput {
organizationId: ID!
approverIds: [ID!]
}
type PublishRiskListPayload {
documentEdge: DocumentEdge!
documentVersionEdge: DocumentVersionEdge!
}
input CreateRiskInput {

View File

@@ -1,83 +0,0 @@
enum SnapshotsType
@goModel(model: "go.probo.inc/probo/pkg/coredata.SnapshotsType") {
RISKS @goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeRisks")
}
enum SnapshotOrderField
@goModel(model: "go.probo.inc/probo/pkg/coredata.SnapshotOrderField") {
CREATED_AT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.SnapshotOrderFieldCreatedAt"
)
NAME
@goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotOrderFieldName")
TYPE
@goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotOrderFieldType")
}
input SnapshotOrder
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.SnapshotOrderBy"
) {
direction: OrderDirection!
field: SnapshotOrderField!
}
type Snapshot implements Node {
id: ID!
organization: Organization! @goField(forceResolver: true)
name: String!
description: String
type: SnapshotsType!
controls(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: ControlOrder
filter: ControlFilter
): ControlConnection! @goField(forceResolver: true)
createdAt: Datetime!
permission(action: String!): Boolean! @goField(forceResolver: true)
}
type SnapshotConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.SnapshotConnection"
) {
totalCount: Int! @goField(forceResolver: true)
edges: [SnapshotEdge!]!
pageInfo: PageInfo!
}
type SnapshotEdge {
cursor: CursorKey!
node: Snapshot!
}
extend type Mutation {
createSnapshot(input: CreateSnapshotInput!): CreateSnapshotPayload!
deleteSnapshot(input: DeleteSnapshotInput!): DeleteSnapshotPayload!
}
input CreateSnapshotInput {
organizationId: ID!
name: String!
description: String
type: SnapshotsType!
}
input DeleteSnapshotInput {
snapshotId: ID!
}
type CreateSnapshotPayload {
snapshotEdge: SnapshotEdge!
}
type DeleteSnapshotPayload {
deletedSnapshotId: ID!
}

View File

@@ -101,9 +101,9 @@ func (r *measureResolver) Risks(ctx context.Context, obj *types.Measure, first *
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
var riskFilter = coredata.NewRiskFilter(nil, nil)
var riskFilter = coredata.NewRiskFilter(nil)
if filter != nil {
riskFilter = coredata.NewRiskFilter(filter.Query, &filter.SnapshotID)
riskFilter = coredata.NewRiskFilter(filter.Query)
}
page, err := prb.Risks.ListForMeasureID(ctx, obj.ID, cursor, riskFilter)

View File

@@ -1027,9 +1027,9 @@ func (r *organizationResolver) Risks(ctx context.Context, obj *types.Organizatio
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
var riskFilter = coredata.NewRiskFilter(nil, nil)
var riskFilter = coredata.NewRiskFilter(nil)
if filter != nil {
riskFilter = coredata.NewRiskFilter(filter.Query, &filter.SnapshotID)
riskFilter = coredata.NewRiskFilter(filter.Query)
}
page, err := prb.Risks.ListForOrganizationID(ctx, obj.ID, cursor, riskFilter)
@@ -1041,34 +1041,33 @@ func (r *organizationResolver) Risks(ctx context.Context, obj *types.Organizatio
return types.NewRiskConnection(page, r, obj.ID, riskFilter), nil
}
// Snapshots is the resolver for the snapshots field.
func (r *organizationResolver) Snapshots(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.SnapshotOrderBy) (*types.SnapshotConnection, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionSnapshotList); err != nil {
// RisksDocument is the resolver for the risksDocument field.
func (r *organizationResolver) RisksDocument(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())
pageOrderBy := page.OrderBy[coredata.SnapshotOrderField]{
Field: coredata.SnapshotOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
if orderBy != nil {
pageOrderBy = page.OrderBy[coredata.SnapshotOrderField]{
Field: orderBy.Field,
Direction: orderBy.Direction,
}
}
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
page, err := prb.Snapshots.ListForOrganizationID(ctx, obj.ID, cursor)
documentID, err := prb.GeneratedDocuments.GetRisksDocumentID(ctx, obj.ID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list organization snapshots", log.Error(err))
r.logger.ErrorCtx(ctx, "cannot get risks document ID", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
if documentID == nil {
return nil, nil
}
document, err := prb.Documents.Get(ctx, *documentID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, nil
}
r.logger.ErrorCtx(ctx, "cannot load risks document", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewSnapshotConnection(page, r, obj.ID), nil
return types.NewDocument(document), nil
}
// Tasks is the resolver for the tasks field.

View File

@@ -239,6 +239,29 @@ func (r *mutationResolver) DeleteRiskObligationMapping(ctx context.Context, inpu
}, nil
}
// PublishRiskList is the resolver for the publishRiskList field.
func (r *mutationResolver) PublishRiskList(ctx context.Context, input types.PublishRiskListInput) (*types.PublishRiskListPayload, error) {
if err := r.authorize(ctx, input.OrganizationID, probo.ActionRiskPublish); err != nil {
return nil, err
}
prb := r.ProboService(ctx, input.OrganizationID.TenantID())
document, documentVersion, err := prb.GeneratedDocuments.PublishRiskList(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 risk list", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.PublishRiskListPayload{
DocumentEdge: types.NewDocumentEdge(document, coredata.DocumentOrderFieldCreatedAt),
DocumentVersionEdge: types.NewDocumentVersionEdge(documentVersion, coredata.DocumentVersionOrderFieldCreatedAt),
}, nil
}
// Owner is the resolver for the owner field.
func (r *riskResolver) Owner(ctx context.Context, obj *types.Risk) (*types.Profile, error) {
if err := r.authorize(ctx, obj.ID, iam.ActionMembershipProfileGet); err != nil {

View File

@@ -1,165 +0,0 @@
package console_v1
// This file will be automatically regenerated based on the schema, any resolver
// implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.87
import (
"context"
"errors"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/probo"
"go.probo.inc/probo/pkg/server/api/console/v1/schema"
"go.probo.inc/probo/pkg/server/api/console/v1/types"
"go.probo.inc/probo/pkg/server/gqlutils"
)
// CreateSnapshot is the resolver for the createSnapshot field.
func (r *mutationResolver) CreateSnapshot(ctx context.Context, input types.CreateSnapshotInput) (*types.CreateSnapshotPayload, error) {
if err := r.authorize(ctx, input.OrganizationID, probo.ActionSnapshotCreate); err != nil {
return nil, err
}
prb := r.ProboService(ctx, input.OrganizationID.TenantID())
snapshot, err := prb.Snapshots.Create(
ctx,
&probo.CreateSnapshotRequest{
OrganizationID: input.OrganizationID,
Name: input.Name,
Description: input.Description,
Type: input.Type,
},
)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot create snapshot", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.CreateSnapshotPayload{
SnapshotEdge: types.NewSnapshotEdge(snapshot, coredata.SnapshotOrderFieldCreatedAt),
}, nil
}
// DeleteSnapshot is the resolver for the deleteSnapshot field.
func (r *mutationResolver) DeleteSnapshot(ctx context.Context, input types.DeleteSnapshotInput) (*types.DeleteSnapshotPayload, error) {
if err := r.authorize(ctx, input.SnapshotID, probo.ActionSnapshotDelete); err != nil {
return nil, err
}
prb := r.ProboService(ctx, input.SnapshotID.TenantID())
err := prb.Snapshots.Delete(ctx, input.SnapshotID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot delete snapshot", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.DeleteSnapshotPayload{
DeletedSnapshotID: input.SnapshotID,
}, nil
}
// Organization is the resolver for the organization field.
func (r *snapshotResolver) Organization(ctx context.Context, obj *types.Snapshot) (*types.Organization, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionOrganizationGet); err != nil {
return nil, err
}
prb := r.ProboService(ctx, obj.ID.TenantID())
snapshot, err := prb.Snapshots.Get(ctx, obj.ID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot get snapshot", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
organization, err := prb.Organizations.Get(ctx, snapshot.OrganizationID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, gqlutils.NotFound(ctx, err)
}
r.logger.ErrorCtx(ctx, "cannot get organization", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewOrganization(organization), nil
}
// Controls is the resolver for the controls field.
func (r *snapshotResolver) Controls(ctx context.Context, obj *types.Snapshot, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ControlOrderBy, filter *types.ControlFilter) (*types.ControlConnection, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionControlList); err != nil {
return nil, err
}
prb := r.ProboService(ctx, obj.ID.TenantID())
pageOrderBy := page.OrderBy[coredata.ControlOrderField]{
Field: coredata.ControlOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
if orderBy != nil {
pageOrderBy = page.OrderBy[coredata.ControlOrderField]{
Field: orderBy.Field,
Direction: orderBy.Direction,
}
}
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
var controlFilter = coredata.NewControlFilter(nil)
if filter != nil {
controlFilter = coredata.NewControlFilter(filter.Query)
}
page, err := prb.Controls.ListForSnapshotID(ctx, obj.ID, cursor, controlFilter)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list snapshot controls", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewControlConnection(page, r, obj.ID, controlFilter), nil
}
// Permission is the resolver for the permission field.
func (r *snapshotResolver) Permission(ctx context.Context, obj *types.Snapshot, action string) (bool, error) {
return r.Resolver.Permission(ctx, obj, action)
}
// TotalCount is the resolver for the totalCount field.
func (r *snapshotConnectionResolver) TotalCount(ctx context.Context, obj *types.SnapshotConnection) (int, error) {
if err := r.authorize(ctx, obj.ParentID, probo.ActionSnapshotList); err != nil {
return 0, err
}
prb := r.ProboService(ctx, obj.ParentID.TenantID())
switch obj.Resolver.(type) {
case *organizationResolver:
count, err := prb.Snapshots.CountForOrganizationID(ctx, obj.ParentID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot count snapshots", log.Error(err))
return 0, gqlutils.Internal(ctx)
}
return count, nil
}
r.logger.ErrorCtx(ctx, "unsupported resolver")
return 0, gqlutils.Internal(ctx)
}
// Snapshot returns schema.SnapshotResolver implementation.
func (r *Resolver) Snapshot() schema.SnapshotResolver { return &snapshotResolver{r} }
// SnapshotConnection returns schema.SnapshotConnectionResolver implementation.
func (r *Resolver) SnapshotConnection() schema.SnapshotConnectionResolver {
return &snapshotConnectionResolver{r}
}
type snapshotResolver struct{ *Resolver }
type snapshotConnectionResolver struct{ *Resolver }

View File

@@ -67,7 +67,6 @@ func NewRisk(r *coredata.Risk) *Risk {
risk := &Risk{
ID: r.ID,
Name: r.Name,
SnapshotID: r.SnapshotID,
Description: r.Description,
Treatment: r.Treatment,
InherentLikelihood: r.InherentLikelihood,

View File

@@ -1,73 +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.
package types
import (
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
SnapshotOrderBy OrderBy[coredata.SnapshotOrderField]
SnapshotConnection struct {
TotalCount int
Edges []*SnapshotEdge
PageInfo PageInfo
Resolver any
ParentID gid.GID
}
)
func NewSnapshotConnection(
p *page.Page[*coredata.Snapshot, coredata.SnapshotOrderField],
parentType any,
parentID gid.GID,
) *SnapshotConnection {
edges := make([]*SnapshotEdge, len(p.Data))
for i, snapshot := range p.Data {
edges[i] = NewSnapshotEdge(snapshot, p.Cursor.OrderBy.Field)
}
return &SnapshotConnection{
Edges: edges,
PageInfo: *NewPageInfo(p),
Resolver: parentType,
ParentID: parentID,
}
}
func NewSnapshotEdge(s *coredata.Snapshot, orderField coredata.SnapshotOrderField) *SnapshotEdge {
return &SnapshotEdge{
Node: NewSnapshot(s),
Cursor: s.CursorKey(orderField),
}
}
func NewSnapshot(s *coredata.Snapshot) *Snapshot {
return &Snapshot{
ID: s.ID,
Organization: &Organization{
ID: s.OrganizationID,
},
Name: s.Name,
Type: s.Type,
Description: s.Description,
CreatedAt: s.CreatedAt,
}
}

View File

@@ -274,10 +274,9 @@ func (r *Resolver) ListRisksTool(ctx context.Context, req *mcp.CallToolRequest,
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
noSnapshot := (*gid.GID)(nil)
riskFilter := coredata.NewRiskFilter(nil, &noSnapshot)
riskFilter := coredata.NewRiskFilter(nil)
if input.Filter != nil {
riskFilter = coredata.NewRiskFilter(input.Filter.Query, &input.Filter.SnapshotID)
riskFilter = coredata.NewRiskFilter(input.Filter.Query)
}
page, err := prb.Risks.ListForOrganizationID(ctx, input.OrganizationID, cursor, riskFilter)
@@ -1511,11 +1510,6 @@ func (r *Resolver) LinkControlTool(ctx context.Context, req *mcp.CallToolRequest
if _, _, err := svc.Controls.CreateAuditMapping(ctx, input.ControlID, input.ResourceID); err != nil {
return nil, types.LinkControlOutput{}, fmt.Errorf("failed to link control to audit: %w", err)
}
case coredata.SnapshotEntityType:
r.MustAuthorize(ctx, input.ControlID, probo.ActionControlSnapshotMappingCreate)
if _, _, err := svc.Controls.CreateSnapshotMapping(ctx, input.ControlID, input.ResourceID); err != nil {
return nil, types.LinkControlOutput{}, fmt.Errorf("failed to link control to snapshot: %w", err)
}
case coredata.ObligationEntityType:
r.MustAuthorize(ctx, input.ControlID, probo.ActionControlObligationMappingCreate)
if _, _, err := svc.Controls.CreateObligationMapping(ctx, input.ControlID, input.ResourceID); err != nil {
@@ -1547,11 +1541,6 @@ func (r *Resolver) UnlinkControlTool(ctx context.Context, req *mcp.CallToolReque
if _, _, err := svc.Controls.DeleteAuditMapping(ctx, input.ControlID, input.ResourceID); err != nil {
return nil, types.UnlinkControlOutput{}, fmt.Errorf("failed to unlink control from audit: %w", err)
}
case coredata.SnapshotEntityType:
r.MustAuthorize(ctx, input.ControlID, probo.ActionControlSnapshotMappingDelete)
if _, _, err := svc.Controls.DeleteSnapshotMapping(ctx, input.ControlID, input.ResourceID); err != nil {
return nil, types.UnlinkControlOutput{}, fmt.Errorf("failed to unlink control from snapshot: %w", err)
}
case coredata.ObligationEntityType:
r.MustAuthorize(ctx, input.ControlID, probo.ActionControlObligationMappingDelete)
if _, _, err := svc.Controls.DeleteObligationMapping(ctx, input.ControlID, input.ResourceID); err != nil {
@@ -1668,32 +1657,6 @@ func (r *Resolver) ListControlAuditsTool(ctx context.Context, req *mcp.CallToolR
return nil, types.NewListControlAuditsOutput(auditPage), nil
}
func (r *Resolver) ListControlSnapshotsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListControlSnapshotsInput) (*mcp.CallToolResult, types.ListControlSnapshotsOutput, error) {
r.MustAuthorize(ctx, input.ControlID, probo.ActionControlGet)
prb := r.ProboService(ctx, input.ControlID)
pageOrderBy := page.OrderBy[coredata.SnapshotOrderField]{
Field: coredata.SnapshotOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
if input.OrderBy != nil {
pageOrderBy = page.OrderBy[coredata.SnapshotOrderField]{
Field: input.OrderBy.Field,
Direction: input.OrderBy.Direction,
}
}
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
snapshotPage, err := prb.Snapshots.ListForControlID(ctx, input.ControlID, cursor)
if err != nil {
return nil, types.ListControlSnapshotsOutput{}, fmt.Errorf("failed to list control snapshots: %w", err)
}
return nil, types.NewListControlSnapshotsOutput(snapshotPage), nil
}
func (r *Resolver) ListRiskObligationsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListRiskObligationsInput) (*mcp.CallToolResult, types.ListRiskObligationsOutput, error) {
r.MustAuthorize(ctx, input.RiskID, probo.ActionRiskGet)
@@ -1915,68 +1878,6 @@ func (r *Resolver) DeleteTaskTool(ctx context.Context, req *mcp.CallToolRequest,
}, nil
}
func (r *Resolver) ListSnapshotsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListSnapshotsInput) (*mcp.CallToolResult, types.ListSnapshotsOutput, error) {
r.MustAuthorize(ctx, input.OrganizationID, probo.ActionSnapshotList)
prb := r.ProboService(ctx, input.OrganizationID)
pageOrderBy := page.OrderBy[coredata.SnapshotOrderField]{
Field: coredata.SnapshotOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
if input.OrderBy != nil {
pageOrderBy = page.OrderBy[coredata.SnapshotOrderField]{
Field: input.OrderBy.Field,
Direction: input.OrderBy.Direction,
}
}
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
page, err := prb.Snapshots.ListForOrganizationID(ctx, input.OrganizationID, cursor)
if err != nil {
panic(fmt.Errorf("cannot list organization snapshots: %w", err))
}
return nil, types.NewListSnapshotsOutput(page), nil
}
func (r *Resolver) GetSnapshotTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetSnapshotInput) (*mcp.CallToolResult, types.GetSnapshotOutput, error) {
r.MustAuthorize(ctx, input.ID, probo.ActionSnapshotGet)
prb := r.ProboService(ctx, input.ID)
snapshot, err := prb.Snapshots.Get(ctx, input.ID)
if err != nil {
return nil, types.GetSnapshotOutput{}, fmt.Errorf("failed to get snapshot: %w", err)
}
return nil, types.GetSnapshotOutput{
Snapshot: types.NewSnapshot(snapshot),
}, nil
}
func (r *Resolver) TakeSnapshotTool(ctx context.Context, req *mcp.CallToolRequest, input *types.TakeSnapshotInput) (*mcp.CallToolResult, types.TakeSnapshotOutput, error) {
r.MustAuthorize(ctx, input.OrganizationID, probo.ActionSnapshotCreate)
prb := r.ProboService(ctx, input.OrganizationID)
snapshot, err := prb.Snapshots.Create(
ctx,
&probo.CreateSnapshotRequest{
OrganizationID: input.OrganizationID,
Name: input.Name,
Description: input.Description,
Type: input.Type,
},
)
if err != nil {
return nil, types.TakeSnapshotOutput{}, fmt.Errorf("failed to take snapshot: %w", err)
}
return nil, types.TakeSnapshotOutput{
Snapshot: types.NewSnapshot(snapshot),
}, nil
}
func (r *Resolver) ListDocumentsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListDocumentsInput) (*mcp.CallToolResult, types.ListDocumentsOutput, error) {
r.MustAuthorize(ctx, input.OrganizationID, probo.ActionDocumentList)
@@ -2314,7 +2215,7 @@ func (r *Resolver) ListMeasureRisksTool(ctx context.Context, req *mcp.CallToolRe
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
riskPage, err := prb.Risks.ListForMeasureID(ctx, input.MeasureID, cursor, coredata.NewRiskFilter(nil, nil))
riskPage, err := prb.Risks.ListForMeasureID(ctx, input.MeasureID, cursor, coredata.NewRiskFilter(nil))
if err != nil {
return nil, types.ListMeasureRisksOutput{}, fmt.Errorf("failed to list measure risks: %w", err)
}
@@ -5190,3 +5091,19 @@ func (r *Resolver) GetCookieConsentRecordTool(ctx context.Context, req *mcp.Call
}
return nil, types.GetCookieConsentRecordOutput{CookieConsentRecord: types.NewCookieConsentRecord(record)}, nil
}
func (r *Resolver) PublishRiskListTool(ctx context.Context, req *mcp.CallToolRequest, input *types.PublishRiskListInput) (*mcp.CallToolResult, types.PublishRiskListOutput, error) {
r.MustAuthorize(ctx, input.OrganizationID, probo.ActionRiskPublish)
svc := r.ProboService(ctx, input.OrganizationID)
document, documentVersion, err := svc.GeneratedDocuments.PublishRiskList(ctx, input.OrganizationID, input.ApproverIds)
if err != nil {
return nil, types.PublishRiskListOutput{}, fmt.Errorf("cannot publish risk list: %w", err)
}
return nil, types.PublishRiskListOutput{
DocumentID: document.ID,
DocumentVersionID: documentVersion.ID,
}, nil
}

View File

@@ -1499,13 +1499,6 @@ components:
organization_id:
$ref: "#/components/schemas/GID"
description: Organization ID
snapshot_id:
anyOf:
- type: string
$ref: "#/components/schemas/GID"
- type: "null"
description: No snapshot
description: Snapshot ID
name:
type: string
description: Risk name
@@ -1579,12 +1572,6 @@ components:
query:
type: string
description: Search query
snapshot_id:
anyOf:
- $ref: "#/components/schemas/GID"
- type: "null"
description: Filter by snapshot ID. Defaults to null, which returns only risks with no snapshot (current live data). Pass a specific snapshot ID to retrieve risks as they were at that snapshot.
default: null
ListRisksOutput:
type: object
@@ -4772,7 +4759,7 @@ components:
description: Control ID
resource_id:
$ref: "#/components/schemas/GID"
description: ID of the resource to link (measure, document, audit, snapshot, or obligation)
description: ID of the resource to link (measure, document, audit, or obligation)
LinkControlOutput:
type: object
@@ -4788,7 +4775,7 @@ components:
description: Control ID
resource_id:
$ref: "#/components/schemas/GID"
description: ID of the resource to unlink (measure, document, audit, snapshot, or obligation)
description: ID of the resource to unlink (measure, document, audit, or obligation)
UnlinkControlOutput:
type: object
@@ -4917,37 +4904,6 @@ components:
items:
$ref: "#/components/schemas/Audit"
ListControlSnapshotsInput:
type: object
required:
- control_id
properties:
control_id:
$ref: "#/components/schemas/GID"
description: Control ID
cursor:
$ref: "#/components/schemas/CursorKey"
description: Page cursor
size:
type: integer
description: Page size
order_by:
$ref: "#/components/schemas/SnapshotOrderBy"
description: Snapshot order by
ListControlSnapshotsOutput:
type: object
required:
- snapshots
properties:
next_cursor:
$ref: "#/components/schemas/CursorKey"
description: Next cursor
snapshots:
type: array
items:
$ref: "#/components/schemas/Snapshot"
ListRiskObligationsInput:
type: object
required:
@@ -5354,150 +5310,6 @@ components:
$ref: "#/components/schemas/GID"
description: Deleted task ID
SnapshotsType:
type: string
enum:
- RISKS
- NONCONFORMITIES
- OBLIGATIONS
- CONTINUAL_IMPROVEMENTS
- PROCESSING_ACTIVITIES
- STATEMENTS_OF_APPLICABILITY
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.SnapshotsType
SnapshotOrderField:
type: string
enum:
- CREATED_AT
- NAME
- TYPE
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.SnapshotOrderField
SnapshotOrderBy:
type: object
required:
- field
- direction
properties:
field:
$ref: "#/components/schemas/SnapshotOrderField"
description: Snapshot order field
direction:
$ref: "#/components/schemas/OrderDirection"
description: Snapshot order direction
Snapshot:
type: object
required:
- id
- organization_id
- name
- type
- created_at
properties:
id:
$ref: "#/components/schemas/GID"
description: Snapshot ID
organization_id:
$ref: "#/components/schemas/GID"
description: Organization ID
name:
type: string
description: Snapshot name
description:
anyOf:
- type: string
description: Snapshot description
- type: "null"
description: No description
description: Snapshot description
type:
$ref: "#/components/schemas/SnapshotsType"
description: Snapshot type
created_at:
type: string
format: date-time
description: Creation timestamp
ListSnapshotsInput:
type: object
required:
- organization_id
properties:
organization_id:
$ref: "#/components/schemas/GID"
description: Organization ID
order_by:
$ref: "#/components/schemas/SnapshotOrderBy"
description: Snapshot order by
size:
type: integer
description: Page size
cursor:
$ref: "#/components/schemas/CursorKey"
description: Page cursor
ListSnapshotsOutput:
type: object
required:
- snapshots
properties:
snapshots:
type: array
items:
$ref: "#/components/schemas/Snapshot"
description: List of snapshots
next_cursor:
anyOf:
- $ref: "#/components/schemas/CursorKey"
- type: "null"
description: Next page cursor
GetSnapshotInput:
type: object
required:
- id
properties:
id:
$ref: "#/components/schemas/GID"
description: Snapshot ID
GetSnapshotOutput:
type: object
required:
- snapshot
properties:
snapshot:
$ref: "#/components/schemas/Snapshot"
TakeSnapshotInput:
type: object
required:
- organization_id
- name
- type
properties:
organization_id:
$ref: "#/components/schemas/GID"
description: Organization ID
name:
type: string
description: Snapshot name
description:
type: string
description: Snapshot description
type:
$ref: "#/components/schemas/SnapshotsType"
description: Snapshot type (determines which collection to snapshot)
TakeSnapshotOutput:
type: object
required:
- snapshot
properties:
snapshot:
$ref: "#/components/schemas/Snapshot"
DocumentType:
type: string
enum:
@@ -6815,16 +6627,6 @@ components:
cursor:
$ref: "#/components/schemas/CursorKey"
description: Page cursor
filter:
type: object
properties:
snapshot_id:
anyOf:
- $ref: "#/components/schemas/GID"
- type: "null"
description: Filter by snapshot ID. Defaults to null, which returns only statements of applicability with no snapshot (current live data). Pass a specific snapshot ID to retrieve statements of applicability as they were at that snapshot.
default: null
ListStatementsOfApplicabilityOutput:
type: object
required:
@@ -7131,6 +6933,33 @@ components:
$ref: "#/components/schemas/GID"
description: Created document version ID
PublishRiskListInput:
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.
PublishRiskListOutput:
type: object
required:
- document_id
- document_version_id
properties:
document_id:
$ref: "#/components/schemas/GID"
description: Created or updated document ID
document_version_id:
$ref: "#/components/schemas/GID"
description: Created document version ID
PublishStatementOfApplicabilityInput:
type: object
required:
@@ -7201,11 +7030,6 @@ components:
organization_id:
$ref: "#/components/schemas/GID"
description: Organization ID
snapshot_id:
anyOf:
- $ref: "#/components/schemas/GID"
- type: "null"
description: Snapshot ID
applicability:
type: boolean
description: Whether the control is applicable
@@ -10842,7 +10666,7 @@ tools:
outputSchema:
$ref: "#/components/schemas/UpdateControlOutput"
- name: linkControl
description: Link a resource to a control (measure, document, audit, snapshot, or obligation). The resource type is determined from the resource_id GID.
description: Link a resource to a control (measure, document, audit, or obligation). The resource type is determined from the resource_id GID.
hints:
readonly: false
inputSchema:
@@ -10850,7 +10674,7 @@ tools:
outputSchema:
$ref: "#/components/schemas/LinkControlOutput"
- name: unlinkControl
description: Unlink a resource from a control (measure, document, audit, snapshot, or obligation). The resource type is determined from the resource_id GID.
description: Unlink a resource from a control (measure, document, audit, or obligation). The resource type is determined from the resource_id GID.
hints:
readonly: false
inputSchema:
@@ -10893,15 +10717,6 @@ tools:
$ref: "#/components/schemas/ListControlAuditsInput"
outputSchema:
$ref: "#/components/schemas/ListControlAuditsOutput"
- name: listControlSnapshots
description: List snapshots linked to a control
hints:
readonly: true
idempotent: true
inputSchema:
$ref: "#/components/schemas/ListControlSnapshotsInput"
outputSchema:
$ref: "#/components/schemas/ListControlSnapshotsOutput"
- name: listRiskObligations
description: List obligations linked to a risk
hints:
@@ -10986,32 +10801,6 @@ tools:
$ref: "#/components/schemas/DeleteTaskInput"
outputSchema:
$ref: "#/components/schemas/DeleteTaskOutput"
- name: listSnapshots
description: List all snapshots for the organization
hints:
readonly: true
idempotent: true
inputSchema:
$ref: "#/components/schemas/ListSnapshotsInput"
outputSchema:
$ref: "#/components/schemas/ListSnapshotsOutput"
- name: getSnapshot
description: Get a snapshot by ID
hints:
readonly: true
idempotent: true
inputSchema:
$ref: "#/components/schemas/GetSnapshotInput"
outputSchema:
$ref: "#/components/schemas/GetSnapshotOutput"
- name: takeSnapshot
description: Take a snapshot of a collection of objects (risks, vendors, findings, obligations, or processing activities)
hints:
readonly: false
inputSchema:
$ref: "#/components/schemas/TakeSnapshotInput"
outputSchema:
$ref: "#/components/schemas/TakeSnapshotOutput"
- name: listDocuments
description: List documents for the organization. By default only ACTIVE documents are returned; pass status filter to include ARCHIVED.
hints:
@@ -11281,6 +11070,14 @@ tools:
$ref: "#/components/schemas/PublishVendorListInput"
outputSchema:
$ref: "#/components/schemas/PublishVendorListOutput"
- name: publishRiskList
description: Publish the risk register for an organization as a document. If a document already exists, a new version is created.
hints:
readonly: false
inputSchema:
$ref: "#/components/schemas/PublishRiskListInput"
outputSchema:
$ref: "#/components/schemas/PublishRiskListOutput"
- name: publishStatementOfApplicability
description: Publish a statement of applicability as a document. If a document already exists, a new version is created.
hints:

View File

@@ -35,7 +35,6 @@ func NewRisk(r *coredata.Risk) *Risk {
ResidualLikelihood: r.ResidualLikelihood,
ResidualImpact: r.ResidualImpact,
ResidualRiskScore: r.ResidualRiskScore,
SnapshotID: r.SnapshotID,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}

View File

@@ -1,66 +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
package types
import (
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/page"
)
func NewSnapshot(s *coredata.Snapshot) *Snapshot {
return &Snapshot{
ID: s.ID,
OrganizationID: s.OrganizationID,
Name: s.Name,
Type: s.Type,
Description: s.Description,
CreatedAt: s.CreatedAt,
}
}
func NewListControlSnapshotsOutput(snapshotPage *page.Page[*coredata.Snapshot, coredata.SnapshotOrderField]) ListControlSnapshotsOutput {
snapshots := make([]*Snapshot, 0, len(snapshotPage.Data))
for _, s := range snapshotPage.Data {
snapshots = append(snapshots, NewSnapshot(s))
}
var nextCursor *page.CursorKey
if len(snapshotPage.Data) > 0 {
cursorKey := snapshotPage.Data[len(snapshotPage.Data)-1].CursorKey(snapshotPage.Cursor.OrderBy.Field)
nextCursor = &cursorKey
}
return ListControlSnapshotsOutput{
NextCursor: nextCursor,
Snapshots: snapshots,
}
}
func NewListSnapshotsOutput(snapshotPage *page.Page[*coredata.Snapshot, coredata.SnapshotOrderField]) ListSnapshotsOutput {
snapshots := make([]*Snapshot, 0, len(snapshotPage.Data))
for _, s := range snapshotPage.Data {
snapshots = append(snapshots, NewSnapshot(s))
}
var nextCursor *page.CursorKey
if len(snapshotPage.Data) > 0 {
cursorKey := snapshotPage.Data[len(snapshotPage.Data)-1].CursorKey(snapshotPage.Cursor.OrderBy.Field)
nextCursor = &cursorKey
}
return ListSnapshotsOutput{
NextCursor: nextCursor,
Snapshots: snapshots,
}
}

View File

@@ -51,7 +51,6 @@ func NewApplicabilityStatement(a *coredata.ApplicabilityStatement) *Applicabilit
StatementOfApplicabilityID: a.StatementOfApplicabilityID,
ControlID: a.ControlID,
OrganizationID: a.OrganizationID,
SnapshotID: a.SnapshotID,
Applicability: a.Applicability,
Justification: a.Justification,
CreatedAt: a.CreatedAt,