Add finding and obligation publish to document system
Replace the old snapshot-based approach with the new publish document system for findings and obligations. Includes GraphQL mutations, MCP tools, CLI commands, e2e tests, frontend publish dialogs, and snapshot-to-document migration tools. Remove snapshot mode entirely from findings and obligations: drop snapshotId from GraphQL schemas, filters, resolvers, MCP spec, frontend routes, pages, and helpers. The snapshot_id column remains in the database but is now filtered out with snapshot_id IS NULL. Remove auditor's ability to publish SoA. Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
@@ -36,7 +36,6 @@ import {
|
|||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useFragment } from "react-relay";
|
import { useFragment } from "react-relay";
|
||||||
import { useParams } from "react-router";
|
|
||||||
import { graphql } from "relay-runtime";
|
import { graphql } from "relay-runtime";
|
||||||
|
|
||||||
import type { LinkedObligationsCardFragment$key } from "#/__generated__/core/LinkedObligationsCardFragment.graphql";
|
import type { LinkedObligationsCardFragment$key } from "#/__generated__/core/LinkedObligationsCardFragment.graphql";
|
||||||
@@ -203,16 +202,12 @@ function ObligationRow(props: {
|
|||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const obligation = useFragment(linkedObligationFragment, props.obligation);
|
const obligation = useFragment(linkedObligationFragment, props.obligation);
|
||||||
const organizationId = useOrganizationId();
|
const organizationId = useOrganizationId();
|
||||||
const { snapshotId } = useParams<{ snapshotId?: string }>();
|
|
||||||
const isSnapshotMode = Boolean(snapshotId);
|
|
||||||
|
|
||||||
const onDetach = () => {
|
const onDetach = () => {
|
||||||
props.onClick(obligation.id);
|
props.onClick(obligation.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const detailsUrl = isSnapshotMode
|
const detailsUrl = `/organizations/${organizationId}/obligations/${obligation.id}`;
|
||||||
? `/organizations/${organizationId}/snapshots/${snapshotId}/obligations/${obligation.id}`
|
|
||||||
: `/organizations/${organizationId}/obligations/${obligation.id}`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tr to={detailsUrl}>
|
<Tr to={detailsUrl}>
|
||||||
|
|||||||
@@ -25,11 +25,18 @@ import { useMutationWithToasts } from "../useMutationWithToasts";
|
|||||||
export const ObligationsConnectionKey = "ObligationsPage_obligations";
|
export const ObligationsConnectionKey = "ObligationsPage_obligations";
|
||||||
|
|
||||||
export const obligationsQuery = graphql`
|
export const obligationsQuery = graphql`
|
||||||
query ObligationGraphListQuery($organizationId: ID!, $snapshotId: ID) {
|
query ObligationGraphListQuery($organizationId: ID!) {
|
||||||
node(id: $organizationId) {
|
node(id: $organizationId) {
|
||||||
... on Organization {
|
... on Organization {
|
||||||
canCreateObligation: permission(action: "core:obligation:create")
|
canCreateObligation: permission(action: "core:obligation:create")
|
||||||
...ObligationsPageFragment @arguments(snapshotId: $snapshotId)
|
canPublishObligations: permission(action: "core:obligation:publish")
|
||||||
|
obligationsDocument {
|
||||||
|
id
|
||||||
|
defaultApprovers {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
...ObligationsPageFragment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,8 +47,6 @@ export const obligationNodeQuery = graphql`
|
|||||||
node(id: $obligationId) {
|
node(id: $obligationId) {
|
||||||
... on Obligation {
|
... on Obligation {
|
||||||
id
|
id
|
||||||
snapshotId
|
|
||||||
sourceId
|
|
||||||
area
|
area
|
||||||
source
|
source
|
||||||
requirement
|
requirement
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
getStatusVariant,
|
getStatusVariant,
|
||||||
type GraphQLError,
|
type GraphQLError,
|
||||||
sprintf,
|
sprintf,
|
||||||
validateSnapshotConsistency,
|
|
||||||
} from "@probo/helpers";
|
} from "@probo/helpers";
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import {
|
import {
|
||||||
@@ -48,7 +47,6 @@ import {
|
|||||||
useMutation,
|
useMutation,
|
||||||
usePreloadedQuery,
|
usePreloadedQuery,
|
||||||
} from "react-relay";
|
} from "react-relay";
|
||||||
import { useParams } from "react-router";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import type { FindingDetailsPageDeleteMutation } from "#/__generated__/core/FindingDetailsPageDeleteMutation.graphql";
|
import type { FindingDetailsPageDeleteMutation } from "#/__generated__/core/FindingDetailsPageDeleteMutation.graphql";
|
||||||
@@ -56,7 +54,6 @@ import type { FindingDetailsPageQuery } from "#/__generated__/core/FindingDetail
|
|||||||
import type { FindingDetailsPageUpdateMutation } from "#/__generated__/core/FindingDetailsPageUpdateMutation.graphql";
|
import type { FindingDetailsPageUpdateMutation } from "#/__generated__/core/FindingDetailsPageUpdateMutation.graphql";
|
||||||
import { ControlledField } from "#/components/form/ControlledField";
|
import { ControlledField } from "#/components/form/ControlledField";
|
||||||
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
|
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
|
||||||
import { SnapshotBanner } from "#/components/SnapshotBanner";
|
|
||||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||||
|
|
||||||
@@ -67,7 +64,6 @@ export const findingDetailsPageQuery = graphql`
|
|||||||
node(id: $findingId) {
|
node(id: $findingId) {
|
||||||
... on Finding {
|
... on Finding {
|
||||||
id
|
id
|
||||||
snapshotId
|
|
||||||
kind
|
kind
|
||||||
referenceId
|
referenceId
|
||||||
description
|
description
|
||||||
@@ -166,12 +162,8 @@ export default function FindingDetailsPage(props: Props) {
|
|||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const organizationId = useOrganizationId();
|
const organizationId = useOrganizationId();
|
||||||
const { snapshotId } = useParams<{ snapshotId?: string }>();
|
|
||||||
const isSnapshotMode = Boolean(snapshotId);
|
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
|
|
||||||
validateSnapshotConsistency(finding, snapshotId);
|
|
||||||
|
|
||||||
const [updateFinding] = useMutation<FindingDetailsPageUpdateMutation>(updateFindingMutation);
|
const [updateFinding] = useMutation<FindingDetailsPageUpdateMutation>(updateFindingMutation);
|
||||||
const [deleteFinding] = useMutation<FindingDetailsPageDeleteMutation>(deleteFindingMutation);
|
const [deleteFinding] = useMutation<FindingDetailsPageDeleteMutation>(deleteFindingMutation);
|
||||||
|
|
||||||
@@ -181,7 +173,6 @@ export default function FindingDetailsPage(props: Props) {
|
|||||||
FindingsConnectionKey,
|
FindingsConnectionKey,
|
||||||
{
|
{
|
||||||
filter: {
|
filter: {
|
||||||
snapshotId: snapshotId || null,
|
|
||||||
kind: null,
|
kind: null,
|
||||||
status: null,
|
status: null,
|
||||||
priority: null,
|
priority: null,
|
||||||
@@ -194,7 +185,6 @@ export default function FindingDetailsPage(props: Props) {
|
|||||||
FindingsConnectionKey,
|
FindingsConnectionKey,
|
||||||
{
|
{
|
||||||
filter: {
|
filter: {
|
||||||
snapshotId: snapshotId || null,
|
|
||||||
kind: finding.kind,
|
kind: finding.kind,
|
||||||
status: null,
|
status: null,
|
||||||
priority: null,
|
priority: null,
|
||||||
@@ -322,15 +312,10 @@ export default function FindingDetailsPage(props: Props) {
|
|||||||
{ value: "HIGH", label: __("High") },
|
{ value: "HIGH", label: __("High") },
|
||||||
];
|
];
|
||||||
|
|
||||||
const breadcrumbFindingsUrl = isSnapshotMode
|
const breadcrumbFindingsUrl = `/organizations/${organizationId}/findings`;
|
||||||
? `/organizations/${organizationId}/snapshots/${snapshotId}/findings`
|
|
||||||
: `/organizations/${organizationId}/findings`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{isSnapshotMode && snapshotId && (
|
|
||||||
<SnapshotBanner snapshotId={snapshotId} />
|
|
||||||
)}
|
|
||||||
<Breadcrumb
|
<Breadcrumb
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
@@ -370,19 +355,17 @@ export default function FindingDetailsPage(props: Props) {
|
|||||||
: __("Low")}
|
: __("Low")}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{!isSnapshotMode && (
|
<ActionDropdown variant="secondary">
|
||||||
<ActionDropdown variant="secondary">
|
{finding.canDelete && (
|
||||||
{finding.canDelete && (
|
<DropdownItem
|
||||||
<DropdownItem
|
variant="danger"
|
||||||
variant="danger"
|
icon={IconTrashCan}
|
||||||
icon={IconTrashCan}
|
onClick={handleDelete}
|
||||||
onClick={handleDelete}
|
>
|
||||||
>
|
{__("Delete")}
|
||||||
{__("Delete")}
|
</DropdownItem>
|
||||||
</DropdownItem>
|
)}
|
||||||
)}
|
</ActionDropdown>
|
||||||
</ActionDropdown>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-w-4xl">
|
<div className="max-w-4xl">
|
||||||
@@ -393,7 +376,6 @@ export default function FindingDetailsPage(props: Props) {
|
|||||||
{...register("description")}
|
{...register("description")}
|
||||||
placeholder={__("Enter description")}
|
placeholder={__("Enter description")}
|
||||||
rows={3}
|
rows={3}
|
||||||
disabled={isSnapshotMode}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
@@ -405,7 +387,6 @@ export default function FindingDetailsPage(props: Props) {
|
|||||||
<Input
|
<Input
|
||||||
{...register("source")}
|
{...register("source")}
|
||||||
placeholder={__("Enter source")}
|
placeholder={__("Enter source")}
|
||||||
disabled={isSnapshotMode}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
@@ -416,7 +397,6 @@ export default function FindingDetailsPage(props: Props) {
|
|||||||
label={__("Owner")}
|
label={__("Owner")}
|
||||||
error={formState.errors.ownerId?.message}
|
error={formState.errors.ownerId?.message}
|
||||||
optional
|
optional
|
||||||
disabled={isSnapshotMode}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -427,7 +407,6 @@ export default function FindingDetailsPage(props: Props) {
|
|||||||
type="select"
|
type="select"
|
||||||
label={__("Status")}
|
label={__("Status")}
|
||||||
required
|
required
|
||||||
disabled={isSnapshotMode}
|
|
||||||
>
|
>
|
||||||
{statusOptions.map(option => (
|
{statusOptions.map(option => (
|
||||||
<Option key={option.value} value={option.value}>
|
<Option key={option.value} value={option.value}>
|
||||||
@@ -449,7 +428,6 @@ export default function FindingDetailsPage(props: Props) {
|
|||||||
<Select
|
<Select
|
||||||
value={field.value}
|
value={field.value}
|
||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
disabled={isSnapshotMode}
|
|
||||||
>
|
>
|
||||||
{priorityOptions.map(option => (
|
{priorityOptions.map(option => (
|
||||||
<Option key={option.value} value={option.value}>
|
<Option key={option.value} value={option.value}>
|
||||||
@@ -472,7 +450,6 @@ export default function FindingDetailsPage(props: Props) {
|
|||||||
<Input
|
<Input
|
||||||
{...register("identifiedOn")}
|
{...register("identifiedOn")}
|
||||||
type="date"
|
type="date"
|
||||||
disabled={isSnapshotMode}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
@@ -480,7 +457,6 @@ export default function FindingDetailsPage(props: Props) {
|
|||||||
<Input
|
<Input
|
||||||
{...register("dueDate")}
|
{...register("dueDate")}
|
||||||
type="date"
|
type="date"
|
||||||
disabled={isSnapshotMode}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
@@ -490,7 +466,6 @@ export default function FindingDetailsPage(props: Props) {
|
|||||||
{...register("rootCause")}
|
{...register("rootCause")}
|
||||||
placeholder={__("Enter root cause")}
|
placeholder={__("Enter root cause")}
|
||||||
rows={3}
|
rows={3}
|
||||||
disabled={isSnapshotMode}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
@@ -499,7 +474,6 @@ export default function FindingDetailsPage(props: Props) {
|
|||||||
{...register("correctiveAction")}
|
{...register("correctiveAction")}
|
||||||
placeholder={__("Enter corrective action")}
|
placeholder={__("Enter corrective action")}
|
||||||
rows={3}
|
rows={3}
|
||||||
disabled={isSnapshotMode}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
@@ -508,13 +482,11 @@ export default function FindingDetailsPage(props: Props) {
|
|||||||
{...register("effectivenessCheck")}
|
{...register("effectivenessCheck")}
|
||||||
placeholder={__("Enter effectiveness check details")}
|
placeholder={__("Enter effectiveness check details")}
|
||||||
rows={3}
|
rows={3}
|
||||||
disabled={isSnapshotMode}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
{formState.isDirty
|
{formState.isDirty
|
||||||
&& !isSnapshotMode
|
|
||||||
&& finding.canUpdate && (
|
&& finding.canUpdate && (
|
||||||
<Button type="submit" disabled={formState.isSubmitting}>
|
<Button type="submit" disabled={formState.isSubmitting}>
|
||||||
{formState.isSubmitting ? __("Updating...") : __("Update")}
|
{formState.isSubmitting ? __("Updating...") : __("Update")}
|
||||||
|
|||||||
@@ -30,8 +30,10 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
DropdownItem,
|
DropdownItem,
|
||||||
|
IconPageTextLine,
|
||||||
IconPlusLarge,
|
IconPlusLarge,
|
||||||
IconTrashCan,
|
IconTrashCan,
|
||||||
|
IconUpload,
|
||||||
Option,
|
Option,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
Select,
|
Select,
|
||||||
@@ -54,7 +56,7 @@ import {
|
|||||||
usePaginationFragment,
|
usePaginationFragment,
|
||||||
usePreloadedQuery,
|
usePreloadedQuery,
|
||||||
} from "react-relay";
|
} from "react-relay";
|
||||||
import { useParams } from "react-router";
|
import { Link, useNavigate } from "react-router";
|
||||||
|
|
||||||
import type { FindingsPageDeleteMutation } from "#/__generated__/core/FindingsPageDeleteMutation.graphql";
|
import type { FindingsPageDeleteMutation } from "#/__generated__/core/FindingsPageDeleteMutation.graphql";
|
||||||
import type { FindingsPageFragment$key } from "#/__generated__/core/FindingsPageFragment.graphql";
|
import type { FindingsPageFragment$key } from "#/__generated__/core/FindingsPageFragment.graphql";
|
||||||
@@ -66,20 +68,27 @@ import type {
|
|||||||
FindingStatus,
|
FindingStatus,
|
||||||
} from "#/__generated__/core/FindingsPageRefetchQuery.graphql";
|
} from "#/__generated__/core/FindingsPageRefetchQuery.graphql";
|
||||||
import type { FindingsPageRowFragment$key } from "#/__generated__/core/FindingsPageRowFragment.graphql";
|
import type { FindingsPageRowFragment$key } from "#/__generated__/core/FindingsPageRowFragment.graphql";
|
||||||
import { SnapshotBanner } from "#/components/SnapshotBanner";
|
|
||||||
import { usePeople } from "#/hooks/graph/PeopleGraph";
|
import { usePeople } from "#/hooks/graph/PeopleGraph";
|
||||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||||
|
|
||||||
import { CreateFindingDialog } from "./dialogs/CreateFindingDialog";
|
import { CreateFindingDialog } from "./dialogs/CreateFindingDialog";
|
||||||
|
import { PublishFindingListDialog } from "./dialogs/PublishFindingListDialog";
|
||||||
|
|
||||||
export const FindingsConnectionKey = "FindingsPage_findings";
|
export const FindingsConnectionKey = "FindingsPage_findings";
|
||||||
|
|
||||||
export const findingsPageQuery = graphql`
|
export const findingsPageQuery = graphql`
|
||||||
query FindingsPageListQuery($organizationId: ID!, $snapshotId: ID) {
|
query FindingsPageListQuery($organizationId: ID!) {
|
||||||
node(id: $organizationId) {
|
node(id: $organizationId) {
|
||||||
... on Organization {
|
... on Organization {
|
||||||
canCreateFinding: permission(action: "core:finding:create")
|
canCreateFinding: permission(action: "core:finding:create")
|
||||||
...FindingsPageFragment @arguments(snapshotId: $snapshotId)
|
canPublishFindings: permission(action: "core:finding:publish")
|
||||||
|
findingsDocument {
|
||||||
|
id
|
||||||
|
defaultApprovers {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
...FindingsPageFragment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,7 +129,6 @@ const findingsPageFragment = graphql`
|
|||||||
@argumentDefinitions(
|
@argumentDefinitions(
|
||||||
first: { type: "Int", defaultValue: 500 }
|
first: { type: "Int", defaultValue: 500 }
|
||||||
after: { type: "CursorKey" }
|
after: { type: "CursorKey" }
|
||||||
snapshotId: { type: "ID", defaultValue: null }
|
|
||||||
kind: { type: "FindingKind", defaultValue: null }
|
kind: { type: "FindingKind", defaultValue: null }
|
||||||
status: { type: "FindingStatus", defaultValue: null }
|
status: { type: "FindingStatus", defaultValue: null }
|
||||||
priority: { type: "FindingPriority", defaultValue: null }
|
priority: { type: "FindingPriority", defaultValue: null }
|
||||||
@@ -131,7 +139,6 @@ const findingsPageFragment = graphql`
|
|||||||
first: $first
|
first: $first
|
||||||
after: $after
|
after: $after
|
||||||
filter: {
|
filter: {
|
||||||
snapshotId: $snapshotId
|
|
||||||
kind: $kind
|
kind: $kind
|
||||||
status: $status
|
status: $status
|
||||||
priority: $priority
|
priority: $priority
|
||||||
@@ -165,12 +172,12 @@ interface FindingsPageProps {
|
|||||||
export default function FindingsPage({ queryRef }: FindingsPageProps) {
|
export default function FindingsPage({ queryRef }: FindingsPageProps) {
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const organizationId = useOrganizationId();
|
const organizationId = useOrganizationId();
|
||||||
const { snapshotId } = useParams<{ snapshotId?: string }>();
|
|
||||||
const isSnapshotMode = Boolean(snapshotId);
|
|
||||||
|
|
||||||
usePageTitle(__("Findings"));
|
usePageTitle(__("Findings"));
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
const organization = usePreloadedQuery(findingsPageQuery, queryRef);
|
const organization = usePreloadedQuery(findingsPageQuery, queryRef);
|
||||||
|
const defaultApproverIds = (organization.node.findingsDocument?.defaultApprovers ?? []).map(a => a.id);
|
||||||
|
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [kindFilter, setKindFilter] = useState<FindingKind | null>(null);
|
const [kindFilter, setKindFilter] = useState<FindingKind | null>(null);
|
||||||
@@ -192,7 +199,6 @@ export default function FindingsPage({ queryRef }: FindingsPageProps) {
|
|||||||
status: statusFilter,
|
status: statusFilter,
|
||||||
priority: priorityFilter,
|
priority: priorityFilter,
|
||||||
ownerId: ownerFilter,
|
ownerId: ownerFilter,
|
||||||
snapshotId: snapshotId || null,
|
|
||||||
...overrides,
|
...overrides,
|
||||||
},
|
},
|
||||||
{ fetchPolicy: "network-only" },
|
{ fetchPolicy: "network-only" },
|
||||||
@@ -225,7 +231,6 @@ export default function FindingsPage({ queryRef }: FindingsPageProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const currentFilter = {
|
const currentFilter = {
|
||||||
snapshotId: snapshotId || null,
|
|
||||||
kind: kindFilter,
|
kind: kindFilter,
|
||||||
status: statusFilter,
|
status: statusFilter,
|
||||||
priority: priorityFilter,
|
priority: priorityFilter,
|
||||||
@@ -242,7 +247,6 @@ export default function FindingsPage({ queryRef }: FindingsPageProps) {
|
|||||||
FindingsConnectionKey,
|
FindingsConnectionKey,
|
||||||
{
|
{
|
||||||
filter: {
|
filter: {
|
||||||
snapshotId: snapshotId || null,
|
|
||||||
kind: null,
|
kind: null,
|
||||||
status: null,
|
status: null,
|
||||||
priority: null,
|
priority: null,
|
||||||
@@ -257,26 +261,49 @@ export default function FindingsPage({ queryRef }: FindingsPageProps) {
|
|||||||
const findings = data?.findings?.edges?.map(edge => edge.node) ?? [];
|
const findings = data?.findings?.edges?.map(edge => edge.node) ?? [];
|
||||||
|
|
||||||
const hasAnyAction
|
const hasAnyAction
|
||||||
= !isSnapshotMode
|
= findings.some(({ canDelete, canUpdate }) => canDelete || canUpdate);
|
||||||
&& findings.some(({ canDelete, canUpdate }) => canDelete || canUpdate);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{isSnapshotMode && snapshotId && (
|
|
||||||
<SnapshotBanner snapshotId={snapshotId} />
|
|
||||||
)}
|
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={__("Findings")}
|
title={__("Findings")}
|
||||||
description={__("Manage your organization's findings.")}
|
description={__("Manage your organization's findings.")}
|
||||||
>
|
>
|
||||||
{!isSnapshotMode && organization.node.canCreateFinding && (
|
<div className="flex gap-2">
|
||||||
<CreateFindingDialog
|
{organization.node.findingsDocument?.id && (
|
||||||
organizationId={organizationId}
|
<Button variant="secondary" asChild>
|
||||||
connectionIds={createConnectionIds}
|
<Link
|
||||||
>
|
to={`/organizations/${organizationId}/documents/${organization.node.findingsDocument.id}`}
|
||||||
<Button icon={IconPlusLarge}>{__("Add finding")}</Button>
|
>
|
||||||
</CreateFindingDialog>
|
<IconPageTextLine size={16} />
|
||||||
)}
|
{__("Document")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{organization.node.canPublishFindings && (
|
||||||
|
<PublishFindingListDialog
|
||||||
|
organizationId={organizationId}
|
||||||
|
defaultApproverIds={defaultApproverIds}
|
||||||
|
onPublished={(documentId) => {
|
||||||
|
void navigate(
|
||||||
|
`/organizations/${organizationId}/documents/${documentId}`,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button variant="secondary" icon={IconUpload}>
|
||||||
|
{__("Publish")}
|
||||||
|
</Button>
|
||||||
|
</PublishFindingListDialog>
|
||||||
|
)}
|
||||||
|
{organization.node.canCreateFinding && (
|
||||||
|
<CreateFindingDialog
|
||||||
|
organizationId={organizationId}
|
||||||
|
connectionIds={createConnectionIds}
|
||||||
|
>
|
||||||
|
<Button icon={IconPlusLarge}>{__("Add finding")}</Button>
|
||||||
|
</CreateFindingDialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -342,7 +369,6 @@ export default function FindingsPage({ queryRef }: FindingsPageProps) {
|
|||||||
key={finding.id}
|
key={finding.id}
|
||||||
findingKey={finding}
|
findingKey={finding}
|
||||||
connectionId={connectionId}
|
connectionId={connectionId}
|
||||||
snapshotId={snapshotId}
|
|
||||||
hasAnyAction={hasAnyAction}
|
hasAnyAction={hasAnyAction}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -397,7 +423,6 @@ function getKindLabel(kind: string, __: (s: string) => string): string {
|
|||||||
type FindingRowProps = {
|
type FindingRowProps = {
|
||||||
findingKey: FindingsPageRowFragment$key;
|
findingKey: FindingsPageRowFragment$key;
|
||||||
connectionId: string;
|
connectionId: string;
|
||||||
snapshotId?: string;
|
|
||||||
hasAnyAction: boolean;
|
hasAnyAction: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -408,7 +433,6 @@ function FindingRow(props: FindingRowProps) {
|
|||||||
const [deleteFinding] = useMutation<FindingsPageDeleteMutation>(deleteFindingMutation);
|
const [deleteFinding] = useMutation<FindingsPageDeleteMutation>(deleteFindingMutation);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const isSnapshotMode = Boolean(props.snapshotId);
|
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
confirm(
|
confirm(
|
||||||
@@ -464,9 +488,7 @@ function FindingRow(props: FindingRowProps) {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const detailsUrl = isSnapshotMode
|
const detailsUrl = `/organizations/${organizationId}/findings/${finding.id}`;
|
||||||
? `/organizations/${organizationId}/snapshots/${props.snapshotId}/findings/${finding.id}`
|
|
||||||
: `/organizations/${organizationId}/findings/${finding.id}`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tr to={detailsUrl}>
|
<Tr to={detailsUrl}>
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
|
|
||||||
import { Suspense, useEffect } from "react";
|
import { Suspense, useEffect } from "react";
|
||||||
import { useQueryLoader } from "react-relay";
|
import { useQueryLoader } from "react-relay";
|
||||||
import { useParams } from "react-router";
|
|
||||||
|
|
||||||
import type { FindingsPageListQuery } from "#/__generated__/core/FindingsPageListQuery.graphql";
|
import type { FindingsPageListQuery } from "#/__generated__/core/FindingsPageListQuery.graphql";
|
||||||
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
|
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
|
||||||
@@ -24,16 +23,12 @@ import FindingsPage, { findingsPageQuery } from "./FindingsPage";
|
|||||||
|
|
||||||
export default function FindingsPageLoader() {
|
export default function FindingsPageLoader() {
|
||||||
const organizationId = useOrganizationId();
|
const organizationId = useOrganizationId();
|
||||||
const { snapshotId } = useParams<{ snapshotId?: string }>();
|
|
||||||
const [queryRef, loadQuery]
|
const [queryRef, loadQuery]
|
||||||
= useQueryLoader<FindingsPageListQuery>(findingsPageQuery);
|
= useQueryLoader<FindingsPageListQuery>(findingsPageQuery);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadQuery({
|
loadQuery({ organizationId });
|
||||||
organizationId,
|
}, [loadQuery, organizationId]);
|
||||||
snapshotId: snapshotId ?? null,
|
|
||||||
});
|
|
||||||
}, [loadQuery, organizationId, snapshotId]);
|
|
||||||
|
|
||||||
if (!queryRef) {
|
if (!queryRef) {
|
||||||
return <PageSkeleton />;
|
return <PageSkeleton />;
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import { formatError, type GraphQLError } from "@probo/helpers";
|
||||||
|
import { useTranslate } from "@probo/i18n";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
IconSend,
|
||||||
|
IconUpload,
|
||||||
|
useDialogRef,
|
||||||
|
useToast,
|
||||||
|
} from "@probo/ui";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useMutation } from "react-relay";
|
||||||
|
import { graphql } from "relay-runtime";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import type { PublishFindingListDialogMutation } from "#/__generated__/core/PublishFindingListDialogMutation.graphql";
|
||||||
|
import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField";
|
||||||
|
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||||
|
|
||||||
|
const publishMutation = graphql`
|
||||||
|
mutation PublishFindingListDialogMutation(
|
||||||
|
$input: PublishFindingListInput!
|
||||||
|
) {
|
||||||
|
publishFindingList(input: $input) {
|
||||||
|
documentEdge {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: ReactNode;
|
||||||
|
organizationId: string;
|
||||||
|
defaultApproverIds?: string[];
|
||||||
|
onPublished?: (documentId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PublishFindingListDialog({
|
||||||
|
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<PublishFindingListDialogMutation>(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.publishFindingList?.documentEdge?.node?.id;
|
||||||
|
if (documentId) {
|
||||||
|
toast({
|
||||||
|
title: __("Success"),
|
||||||
|
description: hasApprovers
|
||||||
|
? __("Approval requested successfully.")
|
||||||
|
: __("Finding list published successfully."),
|
||||||
|
variant: "success",
|
||||||
|
});
|
||||||
|
dialogRef.current?.close();
|
||||||
|
reset();
|
||||||
|
onPublished?.(documentId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
toast({
|
||||||
|
title: __("Error"),
|
||||||
|
description: formatError(
|
||||||
|
__("Failed to publish finding list"),
|
||||||
|
error as GraphQLError,
|
||||||
|
),
|
||||||
|
variant: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
className="max-w-xl"
|
||||||
|
ref={dialogRef}
|
||||||
|
trigger={children}
|
||||||
|
title={__("Publish Finding List")}
|
||||||
|
>
|
||||||
|
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
|
||||||
|
<DialogContent padded>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-txt-secondary">
|
||||||
|
{__("Select approvers to request approval before publishing, or publish directly without approvers.")}
|
||||||
|
</p>
|
||||||
|
<PeopleMultiSelectField
|
||||||
|
name="approverIds"
|
||||||
|
label={__("Approvers")}
|
||||||
|
control={control}
|
||||||
|
organizationId={organizationId}
|
||||||
|
placeholder={__("Add approvers...")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
icon={hasApprovers ? IconSend : IconUpload}
|
||||||
|
disabled={isPublishing}
|
||||||
|
>
|
||||||
|
{hasApprovers ? __("Request approval") : __("Publish")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
getObligationStatusOptions,
|
getObligationStatusOptions,
|
||||||
getObligationStatusVariant,
|
getObligationStatusVariant,
|
||||||
getObligationTypeOptions,
|
getObligationTypeOptions,
|
||||||
validateSnapshotConsistency,
|
|
||||||
} from "@probo/helpers";
|
} from "@probo/helpers";
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import {
|
import {
|
||||||
@@ -43,12 +42,10 @@ import {
|
|||||||
type PreloadedQuery,
|
type PreloadedQuery,
|
||||||
usePreloadedQuery,
|
usePreloadedQuery,
|
||||||
} from "react-relay";
|
} from "react-relay";
|
||||||
import { useParams } from "react-router";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import type { ObligationGraphNodeQuery } from "#/__generated__/core/ObligationGraphNodeQuery.graphql";
|
import type { ObligationGraphNodeQuery } from "#/__generated__/core/ObligationGraphNodeQuery.graphql";
|
||||||
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
|
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
|
||||||
import { SnapshotBanner } from "#/components/SnapshotBanner";
|
|
||||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||||
|
|
||||||
@@ -85,12 +82,8 @@ export default function ObligationDetailsPage(props: Props) {
|
|||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const organizationId = useOrganizationId();
|
const organizationId = useOrganizationId();
|
||||||
const { snapshotId } = useParams<{ snapshotId?: string }>();
|
|
||||||
const isSnapshotMode = Boolean(snapshotId);
|
|
||||||
|
|
||||||
const disabled = isSnapshotMode || !obligation.canUpdate;
|
const disabled = !obligation.canUpdate;
|
||||||
|
|
||||||
validateSnapshotConsistency(obligation, snapshotId);
|
|
||||||
|
|
||||||
const updateObligation = useUpdateObligation();
|
const updateObligation = useUpdateObligation();
|
||||||
const statusOptions = getObligationStatusOptions(__);
|
const statusOptions = getObligationStatusOptions(__);
|
||||||
@@ -165,15 +158,10 @@ export default function ObligationDetailsPage(props: Props) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const breadcrumbObligationsUrl = isSnapshotMode
|
const breadcrumbObligationsUrl = `/organizations/${organizationId}/obligations`;
|
||||||
? `/organizations/${organizationId}/snapshots/${snapshotId}/obligations`
|
|
||||||
: `/organizations/${organizationId}/obligations`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{isSnapshotMode && snapshotId && (
|
|
||||||
<SnapshotBanner snapshotId={snapshotId} />
|
|
||||||
)}
|
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<Breadcrumb
|
<Breadcrumb
|
||||||
@@ -201,7 +189,7 @@ export default function ObligationDetailsPage(props: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isSnapshotMode && obligation.canDelete && (
|
{obligation.canDelete && (
|
||||||
<ActionDropdown>
|
<ActionDropdown>
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
icon={IconTrashCan}
|
icon={IconTrashCan}
|
||||||
@@ -378,20 +366,18 @@ export default function ObligationDetailsPage(props: Props) {
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
{!isSnapshotMode && (
|
<div className="flex justify-end">
|
||||||
<div className="flex justify-end">
|
{obligation.canUpdate && (
|
||||||
{obligation.canUpdate && (
|
<Button
|
||||||
<Button
|
type="submit"
|
||||||
type="submit"
|
disabled={formState.isSubmitting}
|
||||||
disabled={formState.isSubmitting}
|
>
|
||||||
>
|
{formState.isSubmitting
|
||||||
{formState.isSubmitting
|
? __("Saving...")
|
||||||
? __("Saving...")
|
: __("Save Changes")}
|
||||||
: __("Save Changes")}
|
</Button>
|
||||||
</Button>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,8 +26,10 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
DropdownItem,
|
DropdownItem,
|
||||||
|
IconPageTextLine,
|
||||||
IconPlusLarge,
|
IconPlusLarge,
|
||||||
IconTrashCan,
|
IconTrashCan,
|
||||||
|
IconUpload,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
Table,
|
Table,
|
||||||
Tbody,
|
Tbody,
|
||||||
@@ -44,7 +46,7 @@ import {
|
|||||||
usePaginationFragment,
|
usePaginationFragment,
|
||||||
usePreloadedQuery,
|
usePreloadedQuery,
|
||||||
} from "react-relay";
|
} from "react-relay";
|
||||||
import { useParams } from "react-router";
|
import { Link, useNavigate } from "react-router";
|
||||||
|
|
||||||
import type { ObligationGraphDeleteMutation } from "#/__generated__/core/ObligationGraphDeleteMutation.graphql";
|
import type { ObligationGraphDeleteMutation } from "#/__generated__/core/ObligationGraphDeleteMutation.graphql";
|
||||||
import type { ObligationGraphListQuery } from "#/__generated__/core/ObligationGraphListQuery.graphql";
|
import type { ObligationGraphListQuery } from "#/__generated__/core/ObligationGraphListQuery.graphql";
|
||||||
@@ -53,7 +55,6 @@ import type {
|
|||||||
ObligationsPageFragment$key,
|
ObligationsPageFragment$key,
|
||||||
} from "#/__generated__/core/ObligationsPageFragment.graphql";
|
} from "#/__generated__/core/ObligationsPageFragment.graphql";
|
||||||
import type { ObligationsPageRefetchQuery } from "#/__generated__/core/ObligationsPageRefetchQuery.graphql";
|
import type { ObligationsPageRefetchQuery } from "#/__generated__/core/ObligationsPageRefetchQuery.graphql";
|
||||||
import { SnapshotBanner } from "#/components/SnapshotBanner";
|
|
||||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -62,6 +63,7 @@ import {
|
|||||||
} from "../../../hooks/graph/ObligationGraph";
|
} from "../../../hooks/graph/ObligationGraph";
|
||||||
|
|
||||||
import { CreateObligationDialog } from "./dialogs/CreateObligationDialog";
|
import { CreateObligationDialog } from "./dialogs/CreateObligationDialog";
|
||||||
|
import { PublishObligationListDialog } from "./dialogs/PublishObligationListDialog";
|
||||||
|
|
||||||
type Obligation
|
type Obligation
|
||||||
= ObligationsPageFragment$data["obligations"]["edges"][number]["node"];
|
= ObligationsPageFragment$data["obligations"]["edges"][number]["node"];
|
||||||
@@ -76,19 +78,16 @@ const obligationsPageFragment = graphql`
|
|||||||
@argumentDefinitions(
|
@argumentDefinitions(
|
||||||
first: { type: "Int", defaultValue: 500 }
|
first: { type: "Int", defaultValue: 500 }
|
||||||
after: { type: "CursorKey" }
|
after: { type: "CursorKey" }
|
||||||
snapshotId: { type: "ID", defaultValue: null }
|
|
||||||
) {
|
) {
|
||||||
id
|
id
|
||||||
obligations(
|
obligations(
|
||||||
first: $first
|
first: $first
|
||||||
after: $after
|
after: $after
|
||||||
filter: { snapshotId: $snapshotId }
|
) @connection(key: "ObligationsPage_obligations") {
|
||||||
) @connection(key: "ObligationsPage_obligations", filters: ["filter"]) {
|
|
||||||
__id
|
__id
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
id
|
id
|
||||||
snapshotId
|
|
||||||
area
|
area
|
||||||
source
|
source
|
||||||
status
|
status
|
||||||
@@ -112,12 +111,12 @@ const obligationsPageFragment = graphql`
|
|||||||
export default function ObligationsPage({ queryRef }: ObligationsPageProps) {
|
export default function ObligationsPage({ queryRef }: ObligationsPageProps) {
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const organizationId = useOrganizationId();
|
const organizationId = useOrganizationId();
|
||||||
const { snapshotId } = useParams<{ snapshotId?: string }>();
|
const navigate = useNavigate();
|
||||||
const isSnapshotMode = Boolean(snapshotId);
|
|
||||||
|
|
||||||
usePageTitle(__("Obligations"));
|
usePageTitle(__("Obligations"));
|
||||||
|
|
||||||
const organization = usePreloadedQuery(obligationsQuery, queryRef);
|
const organization = usePreloadedQuery(obligationsQuery, queryRef);
|
||||||
|
const defaultApproverIds = (organization.node.obligationsDocument?.defaultApprovers ?? []).map(a => a.id);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: obligationsData,
|
data: obligationsData,
|
||||||
@@ -133,26 +132,49 @@ export default function ObligationsPage({ queryRef }: ObligationsPageProps) {
|
|||||||
= obligationsData?.obligations?.edges?.map(edge => edge.node) ?? [];
|
= obligationsData?.obligations?.edges?.map(edge => edge.node) ?? [];
|
||||||
|
|
||||||
const hasAnyAction
|
const hasAnyAction
|
||||||
= !isSnapshotMode
|
= obligations.some(({ canUpdate, canDelete }) => canDelete || canUpdate);
|
||||||
&& obligations.some(({ canUpdate, canDelete }) => canDelete || canUpdate);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{isSnapshotMode && snapshotId && (
|
|
||||||
<SnapshotBanner snapshotId={snapshotId} />
|
|
||||||
)}
|
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={__("Obligations")}
|
title={__("Obligations")}
|
||||||
description={__("Manage your organization's obligations.")}
|
description={__("Manage your organization's obligations.")}
|
||||||
>
|
>
|
||||||
{!snapshotId && organization.node.canCreateObligation && (
|
<div className="flex gap-2">
|
||||||
<CreateObligationDialog
|
{organization.node.obligationsDocument?.id && (
|
||||||
organizationId={organizationId}
|
<Button variant="secondary" asChild>
|
||||||
connection={connectionId}
|
<Link
|
||||||
>
|
to={`/organizations/${organizationId}/documents/${organization.node.obligationsDocument.id}`}
|
||||||
<Button icon={IconPlusLarge}>{__("Add obligation")}</Button>
|
>
|
||||||
</CreateObligationDialog>
|
<IconPageTextLine size={16} />
|
||||||
)}
|
{__("Document")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{organization.node.canPublishObligations && (
|
||||||
|
<PublishObligationListDialog
|
||||||
|
organizationId={organizationId}
|
||||||
|
defaultApproverIds={defaultApproverIds}
|
||||||
|
onPublished={(documentId) => {
|
||||||
|
void navigate(
|
||||||
|
`/organizations/${organizationId}/documents/${documentId}`,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button variant="secondary" icon={IconUpload}>
|
||||||
|
{__("Publish")}
|
||||||
|
</Button>
|
||||||
|
</PublishObligationListDialog>
|
||||||
|
)}
|
||||||
|
{organization.node.canCreateObligation && (
|
||||||
|
<CreateObligationDialog
|
||||||
|
organizationId={organizationId}
|
||||||
|
connection={connectionId}
|
||||||
|
>
|
||||||
|
<Button icon={IconPlusLarge}>{__("Add obligation")}</Button>
|
||||||
|
</CreateObligationDialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
{obligations.length === 0
|
{obligations.length === 0
|
||||||
@@ -187,7 +209,6 @@ export default function ObligationsPage({ queryRef }: ObligationsPageProps) {
|
|||||||
key={obligation.id}
|
key={obligation.id}
|
||||||
obligation={obligation}
|
obligation={obligation}
|
||||||
connectionId={connectionId}
|
connectionId={connectionId}
|
||||||
snapshotId={snapshotId}
|
|
||||||
hasAnyAction={hasAnyAction}
|
hasAnyAction={hasAnyAction}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -214,19 +235,16 @@ export default function ObligationsPage({ queryRef }: ObligationsPageProps) {
|
|||||||
function ObligationRow({
|
function ObligationRow({
|
||||||
obligation,
|
obligation,
|
||||||
connectionId,
|
connectionId,
|
||||||
snapshotId,
|
|
||||||
hasAnyAction,
|
hasAnyAction,
|
||||||
}: {
|
}: {
|
||||||
obligation: Obligation;
|
obligation: Obligation;
|
||||||
connectionId: string;
|
connectionId: string;
|
||||||
snapshotId?: string;
|
|
||||||
hasAnyAction: boolean;
|
hasAnyAction: boolean;
|
||||||
}) {
|
}) {
|
||||||
const organizationId = useOrganizationId();
|
const organizationId = useOrganizationId();
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const [deleteObligation] = useMutation<ObligationGraphDeleteMutation>(deleteObligationMutation);
|
const [deleteObligation] = useMutation<ObligationGraphDeleteMutation>(deleteObligationMutation);
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const isSnapshotMode = Boolean(snapshotId);
|
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
confirm(
|
confirm(
|
||||||
@@ -247,9 +265,7 @@ function ObligationRow({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const detailsUrl = isSnapshotMode
|
const detailsUrl = `/organizations/${organizationId}/obligations/${obligation.id}`;
|
||||||
? `/organizations/${organizationId}/snapshots/${snapshotId}/obligations/${obligation.id}`
|
|
||||||
: `/organizations/${organizationId}/obligations/${obligation.id}`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tr to={detailsUrl}>
|
<Tr to={detailsUrl}>
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import { formatError, type GraphQLError } from "@probo/helpers";
|
||||||
|
import { useTranslate } from "@probo/i18n";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
IconSend,
|
||||||
|
IconUpload,
|
||||||
|
useDialogRef,
|
||||||
|
useToast,
|
||||||
|
} from "@probo/ui";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useMutation } from "react-relay";
|
||||||
|
import { graphql } from "relay-runtime";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import type { PublishObligationListDialogMutation } from "#/__generated__/core/PublishObligationListDialogMutation.graphql";
|
||||||
|
import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField";
|
||||||
|
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||||
|
|
||||||
|
const publishMutation = graphql`
|
||||||
|
mutation PublishObligationListDialogMutation(
|
||||||
|
$input: PublishObligationListInput!
|
||||||
|
) {
|
||||||
|
publishObligationList(input: $input) {
|
||||||
|
documentEdge {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: ReactNode;
|
||||||
|
organizationId: string;
|
||||||
|
defaultApproverIds?: string[];
|
||||||
|
onPublished?: (documentId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PublishObligationListDialog({
|
||||||
|
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<PublishObligationListDialogMutation>(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.publishObligationList?.documentEdge?.node?.id;
|
||||||
|
if (documentId) {
|
||||||
|
toast({
|
||||||
|
title: __("Success"),
|
||||||
|
description: hasApprovers
|
||||||
|
? __("Approval requested successfully.")
|
||||||
|
: __("Obligation list published successfully."),
|
||||||
|
variant: "success",
|
||||||
|
});
|
||||||
|
dialogRef.current?.close();
|
||||||
|
reset();
|
||||||
|
onPublished?.(documentId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
toast({
|
||||||
|
title: __("Error"),
|
||||||
|
description: formatError(
|
||||||
|
__("Failed to publish obligation list"),
|
||||||
|
error as GraphQLError,
|
||||||
|
),
|
||||||
|
variant: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
className="max-w-xl"
|
||||||
|
ref={dialogRef}
|
||||||
|
trigger={children}
|
||||||
|
title={__("Publish Obligation List")}
|
||||||
|
>
|
||||||
|
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
|
||||||
|
<DialogContent padded>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-txt-secondary">
|
||||||
|
{__("Select approvers to request approval before publishing, or publish directly without approvers.")}
|
||||||
|
</p>
|
||||||
|
<PeopleMultiSelectField
|
||||||
|
name="approverIds"
|
||||||
|
label={__("Approvers")}
|
||||||
|
control={control}
|
||||||
|
organizationId={organizationId}
|
||||||
|
placeholder={__("Add approvers...")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
icon={hasApprovers ? IconSend : IconUpload}
|
||||||
|
disabled={isPublishing}
|
||||||
|
>
|
||||||
|
{hasApprovers ? __("Request approval") : __("Publish")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,14 +26,6 @@ export const findingRoutes = [
|
|||||||
import("#/pages/organizations/findings/FindingsPageLoader"),
|
import("#/pages/organizations/findings/FindingsPageLoader"),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "snapshots/:snapshotId/findings",
|
|
||||||
Fallback: PageSkeleton,
|
|
||||||
Component: lazy(
|
|
||||||
() =>
|
|
||||||
import("#/pages/organizations/findings/FindingsPageLoader"),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "findings/:findingId",
|
path: "findings/:findingId",
|
||||||
Fallback: PageSkeleton,
|
Fallback: PageSkeleton,
|
||||||
@@ -42,12 +34,4 @@ export const findingRoutes = [
|
|||||||
import("#/pages/organizations/findings/FindingDetailsPageLoader"),
|
import("#/pages/organizations/findings/FindingDetailsPageLoader"),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "snapshots/:snapshotId/findings/:findingId",
|
|
||||||
Fallback: PageSkeleton,
|
|
||||||
Component: lazy(
|
|
||||||
() =>
|
|
||||||
import("#/pages/organizations/findings/FindingDetailsPageLoader"),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
] satisfies AppRoute[];
|
] satisfies AppRoute[];
|
||||||
|
|||||||
@@ -36,20 +36,6 @@ export const obligationRoutes = [
|
|||||||
loader: loaderFromQueryLoader(({ organizationId }) =>
|
loader: loaderFromQueryLoader(({ organizationId }) =>
|
||||||
loadQuery<ObligationGraphListQuery>(coreEnvironment, obligationsQuery, {
|
loadQuery<ObligationGraphListQuery>(coreEnvironment, obligationsQuery, {
|
||||||
organizationId,
|
organizationId,
|
||||||
snapshotId: null,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
Component: withQueryRef(
|
|
||||||
lazy(() => import("#/pages/organizations/obligations/ObligationsPage")),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "snapshots/:snapshotId/obligations",
|
|
||||||
Fallback: PageSkeleton,
|
|
||||||
loader: loaderFromQueryLoader(({ organizationId, snapshotId }) =>
|
|
||||||
loadQuery<ObligationGraphListQuery>(coreEnvironment, obligationsQuery, {
|
|
||||||
organizationId,
|
|
||||||
snapshotId,
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
Component: withQueryRef(
|
Component: withQueryRef(
|
||||||
@@ -74,22 +60,4 @@ export const obligationRoutes = [
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "snapshots/:snapshotId/obligations/:obligationId",
|
|
||||||
Fallback: PageSkeleton,
|
|
||||||
loader: loaderFromQueryLoader(({ obligationId }) =>
|
|
||||||
loadQuery<ObligationGraphNodeQuery>(
|
|
||||||
coreEnvironment,
|
|
||||||
obligationNodeQuery,
|
|
||||||
{
|
|
||||||
obligationId,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Component: withQueryRef(
|
|
||||||
lazy(
|
|
||||||
() => import("#/pages/organizations/obligations/ObligationDetailsPage"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
] satisfies AppRoute[];
|
] satisfies AppRoute[];
|
||||||
|
|||||||
487
cmd/migrate-finding-snapshots-to-documents/main.go
Normal file
487
cmd/migrate-finding-snapshots-to-documents/main.go
Normal file
@@ -0,0 +1,487 @@
|
|||||||
|
// 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-finding-snapshots-to-documents creates documents and document
|
||||||
|
// versions from existing finding snapshots. For each organization that has finding
|
||||||
|
// snapshots, it generates a finding register 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 pgClient.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
|
||||||
|
return migrate(ctx, tx, dryRun)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type orgWithFindingSnapshots struct {
|
||||||
|
organizationID gid.GID
|
||||||
|
tenantID gid.TenantID
|
||||||
|
organizationName string
|
||||||
|
}
|
||||||
|
|
||||||
|
type findingSnapshot struct {
|
||||||
|
snapshotID string
|
||||||
|
publishedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrate(ctx context.Context, tx pg.Tx, dryRun bool) error {
|
||||||
|
orgs, err := loadOrgsWithFindingSnapshots(ctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(orgs) == 0 {
|
||||||
|
fmt.Println("no organizations with finding snapshots to migrate")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats struct {
|
||||||
|
documents, versions int
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, org := range orgs {
|
||||||
|
snapshots, err := loadFindingSnapshots(ctx, tx, org.organizationID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if dryRun {
|
||||||
|
fmt.Printf("would migrate org %s (%s) — %d finding snapshot(s)\n",
|
||||||
|
org.organizationID, org.organizationName, len(snapshots))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
documentID := gid.New(org.tenantID, coredata.DocumentEntityType)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
_, err = tx.Exec(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
INSERT INTO documents (
|
||||||
|
id, tenant_id, organization_id, write_mode,
|
||||||
|
current_published_major, current_published_minor,
|
||||||
|
trust_center_visibility, status, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
@id, @tenant_id, @organization_id,
|
||||||
|
'GENERATED'::document_write_mode,
|
||||||
|
@current_published_major, 0,
|
||||||
|
'NONE'::trust_center_visibility,
|
||||||
|
'ACTIVE'::document_status,
|
||||||
|
@created_at, @updated_at
|
||||||
|
)`,
|
||||||
|
pgx.NamedArgs{
|
||||||
|
"id": documentID,
|
||||||
|
"tenant_id": org.tenantID,
|
||||||
|
"organization_id": org.organizationID,
|
||||||
|
"current_published_major": len(snapshots),
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot insert document for org %s: %w", org.organizationID, err)
|
||||||
|
}
|
||||||
|
stats.documents++
|
||||||
|
|
||||||
|
_, err = tx.Exec(
|
||||||
|
ctx,
|
||||||
|
`INSERT INTO generated_documents (organization_id, tenant_id, findings_document_id, created_at, updated_at)
|
||||||
|
VALUES (@organization_id, @tenant_id, @findings_document_id, @created_at, @updated_at)
|
||||||
|
ON CONFLICT (organization_id) DO UPDATE SET findings_document_id = @findings_document_id, updated_at = @updated_at`,
|
||||||
|
pgx.NamedArgs{
|
||||||
|
"organization_id": org.organizationID,
|
||||||
|
"tenant_id": org.tenantID,
|
||||||
|
"findings_document_id": documentID,
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot link document to org %s: %w", org.organizationID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for major, snap := range snapshots {
|
||||||
|
content, err := buildSnapshotContent(ctx, tx, snap.snapshotID, org.organizationName, snap.publishedAt)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot build content for snapshot %s of org %s: %w",
|
||||||
|
snap.snapshotID, org.organizationID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
versionID := gid.New(org.tenantID, coredata.DocumentVersionEntityType)
|
||||||
|
|
||||||
|
_, err = tx.Exec(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
INSERT INTO document_versions (
|
||||||
|
id, tenant_id, organization_id, document_id,
|
||||||
|
title, major, minor, classification, document_type,
|
||||||
|
content, changelog, status, orientation,
|
||||||
|
published_at, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
@id, @tenant_id, @organization_id, @document_id,
|
||||||
|
@title, @major, 0,
|
||||||
|
'CONFIDENTIAL'::document_classification,
|
||||||
|
'REGISTER'::document_type,
|
||||||
|
@content, '',
|
||||||
|
'PUBLISHED'::document_version_status,
|
||||||
|
'LANDSCAPE'::document_version_orientation,
|
||||||
|
@published_at, @published_at, @published_at
|
||||||
|
)`,
|
||||||
|
pgx.NamedArgs{
|
||||||
|
"id": versionID,
|
||||||
|
"tenant_id": org.tenantID,
|
||||||
|
"organization_id": org.organizationID,
|
||||||
|
"document_id": documentID,
|
||||||
|
"title": "Finding List",
|
||||||
|
"major": major + 1,
|
||||||
|
"content": content,
|
||||||
|
"published_at": snap.publishedAt,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot insert version for snapshot %s: %w", snap.snapshotID, err)
|
||||||
|
}
|
||||||
|
stats.versions++
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("migrated org %s (%s) — %d version(s)\n",
|
||||||
|
org.organizationID, org.organizationName, len(snapshots))
|
||||||
|
}
|
||||||
|
|
||||||
|
if dryRun {
|
||||||
|
fmt.Printf("\n%d organization(s) would be migrated\n", len(orgs))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\ncreated %d document(s), %d version(s)\n", stats.documents, stats.versions)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadOrgsWithFindingSnapshots(ctx context.Context, tx pg.Tx) ([]orgWithFindingSnapshots, error) {
|
||||||
|
rows, err := tx.Query(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
SELECT DISTINCT
|
||||||
|
o.id,
|
||||||
|
o.tenant_id,
|
||||||
|
o.name,
|
||||||
|
o.created_at
|
||||||
|
FROM organizations o
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM generated_documents gd
|
||||||
|
WHERE gd.organization_id = o.id AND gd.findings_document_id IS NOT NULL
|
||||||
|
)
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM findings f
|
||||||
|
WHERE f.organization_id = o.id AND f.snapshot_id IS NOT NULL
|
||||||
|
)
|
||||||
|
ORDER BY o.created_at;
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot query organizations with finding snapshots: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var result []orgWithFindingSnapshots
|
||||||
|
for rows.Next() {
|
||||||
|
var o orgWithFindingSnapshots
|
||||||
|
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 loadFindingSnapshots(ctx context.Context, tx pg.Tx, organizationID gid.GID) ([]findingSnapshot, error) {
|
||||||
|
rows, err := tx.Query(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
SELECT DISTINCT
|
||||||
|
s.id,
|
||||||
|
s.created_at
|
||||||
|
FROM snapshots s
|
||||||
|
WHERE s.organization_id = @organization_id
|
||||||
|
AND s.type = 'FINDINGS'
|
||||||
|
ORDER BY s.created_at ASC;
|
||||||
|
`,
|
||||||
|
pgx.NamedArgs{"organization_id": organizationID},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot query finding snapshots for org %s: %w", organizationID, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var result []findingSnapshot
|
||||||
|
for rows.Next() {
|
||||||
|
var s findingSnapshot
|
||||||
|
if err := rows.Scan(&s.snapshotID, &s.publishedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot scan snapshot: %w", err)
|
||||||
|
}
|
||||||
|
result = append(result, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSnapshotContent(
|
||||||
|
ctx context.Context,
|
||||||
|
tx pg.Tx,
|
||||||
|
snapshotID string,
|
||||||
|
orgName string,
|
||||||
|
publishedAt time.Time,
|
||||||
|
) (string, error) {
|
||||||
|
rows, err := tx.Query(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
f.reference_id,
|
||||||
|
f.kind,
|
||||||
|
f.description,
|
||||||
|
f.source,
|
||||||
|
f.identified_on,
|
||||||
|
f.root_cause,
|
||||||
|
f.corrective_action,
|
||||||
|
f.effectiveness_check,
|
||||||
|
f.status,
|
||||||
|
f.priority,
|
||||||
|
f.due_date,
|
||||||
|
COALESCE(p.full_name, '-')
|
||||||
|
FROM findings f
|
||||||
|
LEFT JOIN iam_membership_profiles p ON p.id = f.owner_id
|
||||||
|
WHERE f.snapshot_id = @snapshot_id
|
||||||
|
ORDER BY f.reference_id ASC;
|
||||||
|
`,
|
||||||
|
pgx.NamedArgs{"snapshot_id": snapshotID},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("cannot load snapshot findings: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type findingInfo struct {
|
||||||
|
referenceID string
|
||||||
|
kind string
|
||||||
|
description *string
|
||||||
|
source *string
|
||||||
|
identifiedOn *time.Time
|
||||||
|
rootCause *string
|
||||||
|
correctiveAction *string
|
||||||
|
effectivenessCheck *string
|
||||||
|
status string
|
||||||
|
priority string
|
||||||
|
dueDate *time.Time
|
||||||
|
ownerName string
|
||||||
|
}
|
||||||
|
|
||||||
|
var findings []findingInfo
|
||||||
|
for rows.Next() {
|
||||||
|
var f findingInfo
|
||||||
|
if err := rows.Scan(&f.referenceID, &f.kind, &f.description, &f.source, &f.identifiedOn, &f.rootCause, &f.correctiveAction, &f.effectivenessCheck, &f.status, &f.priority, &f.dueDate, &f.ownerName); err != nil {
|
||||||
|
return "", fmt.Errorf("cannot scan finding: %w", err)
|
||||||
|
}
|
||||||
|
findings = append(findings, f)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
findingRows := make([]docgen.FindingListRow, len(findings))
|
||||||
|
for i, f := range findings {
|
||||||
|
description := "-"
|
||||||
|
if f.description != nil && *f.description != "" {
|
||||||
|
description = *f.description
|
||||||
|
}
|
||||||
|
|
||||||
|
source := "-"
|
||||||
|
if f.source != nil && *f.source != "" {
|
||||||
|
source = *f.source
|
||||||
|
}
|
||||||
|
|
||||||
|
identifiedOn := "-"
|
||||||
|
if f.identifiedOn != nil {
|
||||||
|
identifiedOn = f.identifiedOn.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
|
rootCause := "-"
|
||||||
|
if f.rootCause != nil && *f.rootCause != "" {
|
||||||
|
rootCause = *f.rootCause
|
||||||
|
}
|
||||||
|
|
||||||
|
correctiveAction := "-"
|
||||||
|
if f.correctiveAction != nil && *f.correctiveAction != "" {
|
||||||
|
correctiveAction = *f.correctiveAction
|
||||||
|
}
|
||||||
|
|
||||||
|
effectivenessCheck := "-"
|
||||||
|
if f.effectivenessCheck != nil && *f.effectivenessCheck != "" {
|
||||||
|
effectivenessCheck = *f.effectivenessCheck
|
||||||
|
}
|
||||||
|
|
||||||
|
dueDate := "-"
|
||||||
|
if f.dueDate != nil {
|
||||||
|
dueDate = f.dueDate.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
|
findingRows[i] = docgen.FindingListRow{
|
||||||
|
ReferenceID: f.referenceID,
|
||||||
|
Kind: formatFindingKindString(f.kind),
|
||||||
|
Description: description,
|
||||||
|
Source: source,
|
||||||
|
IdentifiedOn: identifiedOn,
|
||||||
|
RootCause: rootCause,
|
||||||
|
CorrectiveAction: correctiveAction,
|
||||||
|
EffectivenessCheck: effectivenessCheck,
|
||||||
|
Status: formatFindingStatusString(f.status),
|
||||||
|
Priority: formatFindingPriorityString(f.priority),
|
||||||
|
Owner: f.ownerName,
|
||||||
|
DueDate: dueDate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
docData := docgen.FindingListData{
|
||||||
|
Title: "Finding List",
|
||||||
|
OrganizationName: orgName,
|
||||||
|
CreatedAt: publishedAt,
|
||||||
|
TotalFindings: len(findingRows),
|
||||||
|
Rows: findingRows,
|
||||||
|
}
|
||||||
|
|
||||||
|
return probo.BuildFindingListDocument(docData)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatFindingKindString(k string) string {
|
||||||
|
switch k {
|
||||||
|
case "MINOR_NONCONFORMITY":
|
||||||
|
return "Minor Nonconformity"
|
||||||
|
case "MAJOR_NONCONFORMITY":
|
||||||
|
return "Major Nonconformity"
|
||||||
|
case "OBSERVATION":
|
||||||
|
return "Observation"
|
||||||
|
case "EXCEPTION":
|
||||||
|
return "Exception"
|
||||||
|
default:
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatFindingStatusString(s string) string {
|
||||||
|
switch s {
|
||||||
|
case "OPEN":
|
||||||
|
return "Open"
|
||||||
|
case "IN_PROGRESS":
|
||||||
|
return "In Progress"
|
||||||
|
case "CLOSED":
|
||||||
|
return "Closed"
|
||||||
|
case "RISK_ACCEPTED":
|
||||||
|
return "Risk Accepted"
|
||||||
|
case "MITIGATED":
|
||||||
|
return "Mitigated"
|
||||||
|
case "FALSE_POSITIVE":
|
||||||
|
return "False Positive"
|
||||||
|
default:
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatFindingPriorityString(p string) string {
|
||||||
|
switch p {
|
||||||
|
case "LOW":
|
||||||
|
return "Low"
|
||||||
|
case "MEDIUM":
|
||||||
|
return "Medium"
|
||||||
|
case "HIGH":
|
||||||
|
return "High"
|
||||||
|
default:
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPgClientFromDSN(dsn string) (*pg.Client, error) {
|
||||||
|
u, err := url.Parse(dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot parse DSN: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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...)
|
||||||
|
}
|
||||||
450
cmd/migrate-obligation-snapshots-to-documents/main.go
Normal file
450
cmd/migrate-obligation-snapshots-to-documents/main.go
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
// 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-obligation-snapshots-to-documents creates documents and document
|
||||||
|
// versions from existing obligation snapshots. For each organization that has
|
||||||
|
// obligation snapshots, it generates an obligation register 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 pgClient.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
|
||||||
|
return migrate(ctx, tx, dryRun)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type orgWithObligationSnapshots struct {
|
||||||
|
organizationID gid.GID
|
||||||
|
tenantID gid.TenantID
|
||||||
|
organizationName string
|
||||||
|
}
|
||||||
|
|
||||||
|
type obligationSnapshot struct {
|
||||||
|
snapshotID string
|
||||||
|
publishedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrate(ctx context.Context, tx pg.Tx, dryRun bool) error {
|
||||||
|
orgs, err := loadOrgsWithObligationSnapshots(ctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(orgs) == 0 {
|
||||||
|
fmt.Println("no organizations with obligation snapshots to migrate")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats struct {
|
||||||
|
documents, versions int
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, org := range orgs {
|
||||||
|
snapshots, err := loadObligationSnapshots(ctx, tx, org.organizationID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if dryRun {
|
||||||
|
fmt.Printf("would migrate org %s (%s) — %d obligation snapshot(s)\n",
|
||||||
|
org.organizationID, org.organizationName, len(snapshots))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
documentID := gid.New(org.tenantID, coredata.DocumentEntityType)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
_, err = tx.Exec(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
INSERT INTO documents (
|
||||||
|
id, tenant_id, organization_id, write_mode,
|
||||||
|
current_published_major, current_published_minor,
|
||||||
|
trust_center_visibility, status, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
@id, @tenant_id, @organization_id,
|
||||||
|
'GENERATED'::document_write_mode,
|
||||||
|
@current_published_major, 0,
|
||||||
|
'NONE'::trust_center_visibility,
|
||||||
|
'ACTIVE'::document_status,
|
||||||
|
@created_at, @updated_at
|
||||||
|
)`,
|
||||||
|
pgx.NamedArgs{
|
||||||
|
"id": documentID,
|
||||||
|
"tenant_id": org.tenantID,
|
||||||
|
"organization_id": org.organizationID,
|
||||||
|
"current_published_major": len(snapshots),
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot insert document for org %s: %w", org.organizationID, err)
|
||||||
|
}
|
||||||
|
stats.documents++
|
||||||
|
|
||||||
|
_, err = tx.Exec(
|
||||||
|
ctx,
|
||||||
|
`INSERT INTO generated_documents (organization_id, tenant_id, obligations_document_id, created_at, updated_at)
|
||||||
|
VALUES (@organization_id, @tenant_id, @obligations_document_id, @created_at, @updated_at)
|
||||||
|
ON CONFLICT (organization_id) DO UPDATE SET obligations_document_id = @obligations_document_id, updated_at = @updated_at`,
|
||||||
|
pgx.NamedArgs{
|
||||||
|
"organization_id": org.organizationID,
|
||||||
|
"tenant_id": org.tenantID,
|
||||||
|
"obligations_document_id": documentID,
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot link document to org %s: %w", org.organizationID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for major, snap := range snapshots {
|
||||||
|
content, err := buildSnapshotContent(ctx, tx, snap.snapshotID, org.organizationName, snap.publishedAt)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot build content for snapshot %s of org %s: %w",
|
||||||
|
snap.snapshotID, org.organizationID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
versionID := gid.New(org.tenantID, coredata.DocumentVersionEntityType)
|
||||||
|
|
||||||
|
_, err = tx.Exec(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
INSERT INTO document_versions (
|
||||||
|
id, tenant_id, organization_id, document_id,
|
||||||
|
title, major, minor, classification, document_type,
|
||||||
|
content, changelog, status, orientation,
|
||||||
|
published_at, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
@id, @tenant_id, @organization_id, @document_id,
|
||||||
|
@title, @major, 0,
|
||||||
|
'CONFIDENTIAL'::document_classification,
|
||||||
|
'REGISTER'::document_type,
|
||||||
|
@content, '',
|
||||||
|
'PUBLISHED'::document_version_status,
|
||||||
|
'LANDSCAPE'::document_version_orientation,
|
||||||
|
@published_at, @published_at, @published_at
|
||||||
|
)`,
|
||||||
|
pgx.NamedArgs{
|
||||||
|
"id": versionID,
|
||||||
|
"tenant_id": org.tenantID,
|
||||||
|
"organization_id": org.organizationID,
|
||||||
|
"document_id": documentID,
|
||||||
|
"title": "Obligation List",
|
||||||
|
"major": major + 1,
|
||||||
|
"content": content,
|
||||||
|
"published_at": snap.publishedAt,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot insert version for snapshot %s: %w", snap.snapshotID, err)
|
||||||
|
}
|
||||||
|
stats.versions++
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("migrated org %s (%s) — %d version(s)\n",
|
||||||
|
org.organizationID, org.organizationName, len(snapshots))
|
||||||
|
}
|
||||||
|
|
||||||
|
if dryRun {
|
||||||
|
fmt.Printf("\n%d organization(s) would be migrated\n", len(orgs))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\ncreated %d document(s), %d version(s)\n", stats.documents, stats.versions)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadOrgsWithObligationSnapshots(ctx context.Context, tx pg.Tx) ([]orgWithObligationSnapshots, error) {
|
||||||
|
rows, err := tx.Query(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
SELECT DISTINCT
|
||||||
|
o.id,
|
||||||
|
o.tenant_id,
|
||||||
|
o.name,
|
||||||
|
o.created_at
|
||||||
|
FROM organizations o
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM generated_documents gd
|
||||||
|
WHERE gd.organization_id = o.id AND gd.obligations_document_id IS NOT NULL
|
||||||
|
)
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM obligations ob
|
||||||
|
WHERE ob.organization_id = o.id AND ob.snapshot_id IS NOT NULL
|
||||||
|
)
|
||||||
|
ORDER BY o.created_at;
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot query organizations with obligation snapshots: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var result []orgWithObligationSnapshots
|
||||||
|
for rows.Next() {
|
||||||
|
var o orgWithObligationSnapshots
|
||||||
|
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 loadObligationSnapshots(ctx context.Context, tx pg.Tx, organizationID gid.GID) ([]obligationSnapshot, error) {
|
||||||
|
rows, err := tx.Query(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
SELECT DISTINCT
|
||||||
|
s.id,
|
||||||
|
s.created_at
|
||||||
|
FROM snapshots s
|
||||||
|
WHERE s.organization_id = @organization_id
|
||||||
|
AND s.type = 'OBLIGATIONS'
|
||||||
|
ORDER BY s.created_at ASC;
|
||||||
|
`,
|
||||||
|
pgx.NamedArgs{"organization_id": organizationID},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot query obligation snapshots for org %s: %w", organizationID, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var result []obligationSnapshot
|
||||||
|
for rows.Next() {
|
||||||
|
var s obligationSnapshot
|
||||||
|
if err := rows.Scan(&s.snapshotID, &s.publishedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot scan snapshot: %w", err)
|
||||||
|
}
|
||||||
|
result = append(result, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSnapshotContent(
|
||||||
|
ctx context.Context,
|
||||||
|
tx pg.Tx,
|
||||||
|
snapshotID string,
|
||||||
|
orgName string,
|
||||||
|
publishedAt time.Time,
|
||||||
|
) (string, error) {
|
||||||
|
rows, err := tx.Query(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
ob.area,
|
||||||
|
ob.source,
|
||||||
|
ob.requirement,
|
||||||
|
ob.actions_to_be_implemented,
|
||||||
|
ob.status,
|
||||||
|
ob.type,
|
||||||
|
ob.regulator,
|
||||||
|
ob.due_date,
|
||||||
|
COALESCE(p.full_name, '-')
|
||||||
|
FROM obligations ob
|
||||||
|
LEFT JOIN iam_membership_profiles p ON p.id = ob.owner_profile_id
|
||||||
|
WHERE ob.snapshot_id = @snapshot_id
|
||||||
|
ORDER BY ob.created_at ASC;
|
||||||
|
`,
|
||||||
|
pgx.NamedArgs{"snapshot_id": snapshotID},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("cannot load snapshot obligations: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type obligationInfo struct {
|
||||||
|
area *string
|
||||||
|
source *string
|
||||||
|
requirement *string
|
||||||
|
actionsToBeImplemented *string
|
||||||
|
status string
|
||||||
|
oblType string
|
||||||
|
regulator *string
|
||||||
|
dueDate *time.Time
|
||||||
|
ownerName string
|
||||||
|
}
|
||||||
|
|
||||||
|
var obligations []obligationInfo
|
||||||
|
for rows.Next() {
|
||||||
|
var o obligationInfo
|
||||||
|
if err := rows.Scan(&o.area, &o.source, &o.requirement, &o.actionsToBeImplemented, &o.status, &o.oblType, &o.regulator, &o.dueDate, &o.ownerName); err != nil {
|
||||||
|
return "", fmt.Errorf("cannot scan obligation: %w", err)
|
||||||
|
}
|
||||||
|
obligations = append(obligations, o)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
obligationRows := make([]docgen.ObligationListRow, len(obligations))
|
||||||
|
for i, o := range obligations {
|
||||||
|
area := "-"
|
||||||
|
if o.area != nil && *o.area != "" {
|
||||||
|
area = *o.area
|
||||||
|
}
|
||||||
|
|
||||||
|
source := "-"
|
||||||
|
if o.source != nil && *o.source != "" {
|
||||||
|
source = *o.source
|
||||||
|
}
|
||||||
|
|
||||||
|
requirement := "-"
|
||||||
|
if o.requirement != nil && *o.requirement != "" {
|
||||||
|
requirement = *o.requirement
|
||||||
|
}
|
||||||
|
|
||||||
|
actionsToBeImplemented := "-"
|
||||||
|
if o.actionsToBeImplemented != nil && *o.actionsToBeImplemented != "" {
|
||||||
|
actionsToBeImplemented = *o.actionsToBeImplemented
|
||||||
|
}
|
||||||
|
|
||||||
|
regulator := "-"
|
||||||
|
if o.regulator != nil && *o.regulator != "" {
|
||||||
|
regulator = *o.regulator
|
||||||
|
}
|
||||||
|
|
||||||
|
dueDate := "-"
|
||||||
|
if o.dueDate != nil {
|
||||||
|
dueDate = o.dueDate.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
|
obligationRows[i] = docgen.ObligationListRow{
|
||||||
|
Area: area,
|
||||||
|
Source: source,
|
||||||
|
Requirement: requirement,
|
||||||
|
ActionsToBeImplemented: actionsToBeImplemented,
|
||||||
|
Status: formatObligationStatusString(o.status),
|
||||||
|
Type: formatObligationTypeString(o.oblType),
|
||||||
|
Regulator: regulator,
|
||||||
|
Owner: o.ownerName,
|
||||||
|
DueDate: dueDate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
docData := docgen.ObligationListData{
|
||||||
|
Title: "Obligation List",
|
||||||
|
OrganizationName: orgName,
|
||||||
|
CreatedAt: publishedAt,
|
||||||
|
TotalObligations: len(obligationRows),
|
||||||
|
Rows: obligationRows,
|
||||||
|
}
|
||||||
|
|
||||||
|
return probo.BuildObligationListDocument(docData)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatObligationStatusString(s string) string {
|
||||||
|
switch s {
|
||||||
|
case "NON_COMPLIANT":
|
||||||
|
return "Non Compliant"
|
||||||
|
case "PARTIALLY_COMPLIANT":
|
||||||
|
return "Partially Compliant"
|
||||||
|
case "COMPLIANT":
|
||||||
|
return "Compliant"
|
||||||
|
default:
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatObligationTypeString(t string) string {
|
||||||
|
switch t {
|
||||||
|
case "LEGAL":
|
||||||
|
return "Legal"
|
||||||
|
case "CONTRACTUAL":
|
||||||
|
return "Contractual"
|
||||||
|
default:
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPgClientFromDSN(dsn string) (*pg.Client, error) {
|
||||||
|
u, err := url.Parse(dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot parse DSN: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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...)
|
||||||
|
}
|
||||||
400
e2e/console/finding_publish_test.go
Normal file
400
e2e/console/finding_publish_test.go
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
// 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/testutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFinding_PublishFindingList(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
|
|
||||||
|
t.Run(
|
||||||
|
"publish without approvers publishes immediately",
|
||||||
|
func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
|
|
||||||
|
createFindingForPublish(t, owner, "Test Finding")
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
mutation($input: PublishFindingListInput!) {
|
||||||
|
publishFindingList(input: $input) {
|
||||||
|
documentEdge {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
writeMode
|
||||||
|
status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
documentVersionEdge {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
documentType
|
||||||
|
status
|
||||||
|
major
|
||||||
|
minor
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
PublishFindingList 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:"publishFindingList"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := owner.Execute(
|
||||||
|
query,
|
||||||
|
map[string]any{
|
||||||
|
"input": map[string]any{
|
||||||
|
"organizationId": owner.GetOrganizationID(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
&result,
|
||||||
|
)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
doc := result.PublishFindingList.DocumentEdge.Node
|
||||||
|
assert.NotEmpty(t, doc.ID)
|
||||||
|
assert.Equal(t, "GENERATED", doc.WriteMode)
|
||||||
|
assert.Equal(t, "ACTIVE", doc.Status)
|
||||||
|
|
||||||
|
ver := result.PublishFindingList.DocumentVersionEdge.Node
|
||||||
|
assert.NotEmpty(t, ver.ID)
|
||||||
|
assert.Equal(t, "REGISTER", ver.DocumentType)
|
||||||
|
assert.Equal(t, "PUBLISHED", ver.Status)
|
||||||
|
assert.Equal(t, 1, ver.Major)
|
||||||
|
assert.Equal(t, 0, ver.Minor)
|
||||||
|
assert.Contains(t, ver.Content, "Purpose")
|
||||||
|
assert.Contains(t, ver.Content, "Test Finding")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
t.Run(
|
||||||
|
"publish with approvers creates draft with quorum",
|
||||||
|
func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
mutation($input: PublishFindingListInput!) {
|
||||||
|
publishFindingList(input: $input) {
|
||||||
|
documentEdge {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
writeMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
documentVersionEdge {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
status
|
||||||
|
major
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
PublishFindingList 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:"publishFindingList"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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.PublishFindingList.DocumentEdge.Node
|
||||||
|
assert.NotEmpty(t, doc.ID)
|
||||||
|
assert.Equal(t, "GENERATED", doc.WriteMode)
|
||||||
|
|
||||||
|
ver := result.PublishFindingList.DocumentVersionEdge.Node
|
||||||
|
assert.NotEmpty(t, ver.ID)
|
||||||
|
assert.Equal(t, "PENDING_APPROVAL", ver.Status)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
t.Run(
|
||||||
|
"creating second document reuses existing document",
|
||||||
|
func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
secondOwner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
|
|
||||||
|
createFindingForPublish(t, secondOwner, "Reuse Test Finding")
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
mutation($input: PublishFindingListInput!) {
|
||||||
|
publishFindingList(input: $input) {
|
||||||
|
documentEdge {
|
||||||
|
node { id }
|
||||||
|
}
|
||||||
|
documentVersionEdge {
|
||||||
|
node { id major }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
var result1, result2 struct {
|
||||||
|
PublishFindingList 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:"publishFindingList"`
|
||||||
|
}
|
||||||
|
|
||||||
|
input := map[string]any{
|
||||||
|
"input": map[string]any{
|
||||||
|
"organizationId": secondOwner.GetOrganizationID(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := secondOwner.Execute(query, input, &result1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = secondOwner.Execute(query, input, &result2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
doc1 := result1.PublishFindingList.DocumentEdge.Node.ID
|
||||||
|
doc2 := result2.PublishFindingList.DocumentEdge.Node.ID
|
||||||
|
assert.Equal(t, doc1, doc2, "should reuse same document")
|
||||||
|
|
||||||
|
ver1Major := result1.PublishFindingList.DocumentVersionEdge.Node.Major
|
||||||
|
ver2Major := result2.PublishFindingList.DocumentVersionEdge.Node.Major
|
||||||
|
assert.Equal(t, 1, ver1Major)
|
||||||
|
assert.Equal(t, 2, ver2Major)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
t.Run(
|
||||||
|
"document linked back to organization",
|
||||||
|
func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
thirdOwner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
|
|
||||||
|
createFindingForPublish(t, thirdOwner, "Link Test Finding")
|
||||||
|
|
||||||
|
const publishQuery = `
|
||||||
|
mutation($input: PublishFindingListInput!) {
|
||||||
|
publishFindingList(input: $input) {
|
||||||
|
documentEdge {
|
||||||
|
node { id }
|
||||||
|
}
|
||||||
|
documentVersionEdge {
|
||||||
|
node { id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
var publishResult struct {
|
||||||
|
PublishFindingList 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:"publishFindingList"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := thirdOwner.Execute(
|
||||||
|
publishQuery,
|
||||||
|
map[string]any{
|
||||||
|
"input": map[string]any{
|
||||||
|
"organizationId": thirdOwner.GetOrganizationID(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
&publishResult,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
docID := publishResult.PublishFindingList.DocumentEdge.Node.ID
|
||||||
|
|
||||||
|
const orgQuery = `
|
||||||
|
query($id: ID!) {
|
||||||
|
node(id: $id) {
|
||||||
|
... on Organization {
|
||||||
|
id
|
||||||
|
findingsDocument { id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
var orgResult struct {
|
||||||
|
Node struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
FindingsDocument *struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"findingsDocument"`
|
||||||
|
} `json:"node"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err = thirdOwner.Execute(
|
||||||
|
orgQuery,
|
||||||
|
map[string]any{"id": thirdOwner.GetOrganizationID()},
|
||||||
|
&orgResult,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, orgResult.Node.FindingsDocument)
|
||||||
|
assert.Equal(t, docID, orgResult.Node.FindingsDocument.ID)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinding_PublishFindingList_RBAC(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
|
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
|
||||||
|
|
||||||
|
createFindingForPublish(t, owner, "RBAC Test Finding")
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
mutation($input: PublishFindingListInput!) {
|
||||||
|
publishFindingList(input: $input) {
|
||||||
|
documentEdge {
|
||||||
|
node { id }
|
||||||
|
}
|
||||||
|
documentVersionEdge {
|
||||||
|
node { id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
t.Run(
|
||||||
|
"viewer cannot publish finding list",
|
||||||
|
func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := viewer.ExecuteShouldFail(
|
||||||
|
query,
|
||||||
|
map[string]any{
|
||||||
|
"input": map[string]any{
|
||||||
|
"organizationId": owner.GetOrganizationID(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
testutil.RequireForbiddenError(t, err)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createFindingForPublish(t *testing.T, client *testutil.Client, description string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
mutation($input: CreateFindingInput!) {
|
||||||
|
createFinding(input: $input) {
|
||||||
|
findingEdge {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
CreateFinding struct {
|
||||||
|
FindingEdge struct {
|
||||||
|
Node struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"node"`
|
||||||
|
} `json:"findingEdge"`
|
||||||
|
} `json:"createFinding"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := client.Execute(query, map[string]any{
|
||||||
|
"input": map[string]any{
|
||||||
|
"organizationId": client.GetOrganizationID().String(),
|
||||||
|
"kind": "OBSERVATION",
|
||||||
|
"description": description,
|
||||||
|
"status": "OPEN",
|
||||||
|
"priority": "MEDIUM",
|
||||||
|
},
|
||||||
|
}, &result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return result.CreateFinding.FindingEdge.Node.ID
|
||||||
|
}
|
||||||
400
e2e/console/obligation_publish_test.go
Normal file
400
e2e/console/obligation_publish_test.go
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
// 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/testutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestObligation_PublishObligationList(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
|
|
||||||
|
t.Run(
|
||||||
|
"publish without approvers publishes immediately",
|
||||||
|
func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
|
|
||||||
|
createObligationForPublish(t, owner, "Test Obligation Requirement")
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
mutation($input: PublishObligationListInput!) {
|
||||||
|
publishObligationList(input: $input) {
|
||||||
|
documentEdge {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
writeMode
|
||||||
|
status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
documentVersionEdge {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
documentType
|
||||||
|
status
|
||||||
|
major
|
||||||
|
minor
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
PublishObligationList 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:"publishObligationList"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := owner.Execute(
|
||||||
|
query,
|
||||||
|
map[string]any{
|
||||||
|
"input": map[string]any{
|
||||||
|
"organizationId": owner.GetOrganizationID(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
&result,
|
||||||
|
)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
doc := result.PublishObligationList.DocumentEdge.Node
|
||||||
|
assert.NotEmpty(t, doc.ID)
|
||||||
|
assert.Equal(t, "GENERATED", doc.WriteMode)
|
||||||
|
assert.Equal(t, "ACTIVE", doc.Status)
|
||||||
|
|
||||||
|
ver := result.PublishObligationList.DocumentVersionEdge.Node
|
||||||
|
assert.NotEmpty(t, ver.ID)
|
||||||
|
assert.Equal(t, "REGISTER", ver.DocumentType)
|
||||||
|
assert.Equal(t, "PUBLISHED", ver.Status)
|
||||||
|
assert.Equal(t, 1, ver.Major)
|
||||||
|
assert.Equal(t, 0, ver.Minor)
|
||||||
|
assert.Contains(t, ver.Content, "Purpose")
|
||||||
|
assert.Contains(t, ver.Content, "Test Obligation Requirement")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
t.Run(
|
||||||
|
"publish with approvers creates draft with quorum",
|
||||||
|
func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
mutation($input: PublishObligationListInput!) {
|
||||||
|
publishObligationList(input: $input) {
|
||||||
|
documentEdge {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
writeMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
documentVersionEdge {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
status
|
||||||
|
major
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
PublishObligationList 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:"publishObligationList"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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.PublishObligationList.DocumentEdge.Node
|
||||||
|
assert.NotEmpty(t, doc.ID)
|
||||||
|
assert.Equal(t, "GENERATED", doc.WriteMode)
|
||||||
|
|
||||||
|
ver := result.PublishObligationList.DocumentVersionEdge.Node
|
||||||
|
assert.NotEmpty(t, ver.ID)
|
||||||
|
assert.Equal(t, "PENDING_APPROVAL", ver.Status)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
t.Run(
|
||||||
|
"creating second document reuses existing document",
|
||||||
|
func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
secondOwner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
|
|
||||||
|
createObligationForPublish(t, secondOwner, "Reuse Test Obligation")
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
mutation($input: PublishObligationListInput!) {
|
||||||
|
publishObligationList(input: $input) {
|
||||||
|
documentEdge {
|
||||||
|
node { id }
|
||||||
|
}
|
||||||
|
documentVersionEdge {
|
||||||
|
node { id major }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
var result1, result2 struct {
|
||||||
|
PublishObligationList 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:"publishObligationList"`
|
||||||
|
}
|
||||||
|
|
||||||
|
input := map[string]any{
|
||||||
|
"input": map[string]any{
|
||||||
|
"organizationId": secondOwner.GetOrganizationID(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := secondOwner.Execute(query, input, &result1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = secondOwner.Execute(query, input, &result2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
doc1 := result1.PublishObligationList.DocumentEdge.Node.ID
|
||||||
|
doc2 := result2.PublishObligationList.DocumentEdge.Node.ID
|
||||||
|
assert.Equal(t, doc1, doc2, "should reuse same document")
|
||||||
|
|
||||||
|
ver1Major := result1.PublishObligationList.DocumentVersionEdge.Node.Major
|
||||||
|
ver2Major := result2.PublishObligationList.DocumentVersionEdge.Node.Major
|
||||||
|
assert.Equal(t, 1, ver1Major)
|
||||||
|
assert.Equal(t, 2, ver2Major)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
t.Run(
|
||||||
|
"document linked back to organization",
|
||||||
|
func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
thirdOwner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
|
|
||||||
|
createObligationForPublish(t, thirdOwner, "Link Test Obligation")
|
||||||
|
|
||||||
|
const publishQuery = `
|
||||||
|
mutation($input: PublishObligationListInput!) {
|
||||||
|
publishObligationList(input: $input) {
|
||||||
|
documentEdge {
|
||||||
|
node { id }
|
||||||
|
}
|
||||||
|
documentVersionEdge {
|
||||||
|
node { id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
var publishResult struct {
|
||||||
|
PublishObligationList 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:"publishObligationList"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := thirdOwner.Execute(
|
||||||
|
publishQuery,
|
||||||
|
map[string]any{
|
||||||
|
"input": map[string]any{
|
||||||
|
"organizationId": thirdOwner.GetOrganizationID(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
&publishResult,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
docID := publishResult.PublishObligationList.DocumentEdge.Node.ID
|
||||||
|
|
||||||
|
const orgQuery = `
|
||||||
|
query($id: ID!) {
|
||||||
|
node(id: $id) {
|
||||||
|
... on Organization {
|
||||||
|
id
|
||||||
|
obligationsDocument { id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
var orgResult struct {
|
||||||
|
Node struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ObligationsDocument *struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"obligationsDocument"`
|
||||||
|
} `json:"node"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err = thirdOwner.Execute(
|
||||||
|
orgQuery,
|
||||||
|
map[string]any{"id": thirdOwner.GetOrganizationID()},
|
||||||
|
&orgResult,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, orgResult.Node.ObligationsDocument)
|
||||||
|
assert.Equal(t, docID, orgResult.Node.ObligationsDocument.ID)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestObligation_PublishObligationList_RBAC(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
|
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
|
||||||
|
|
||||||
|
createObligationForPublish(t, owner, "RBAC Test Obligation")
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
mutation($input: PublishObligationListInput!) {
|
||||||
|
publishObligationList(input: $input) {
|
||||||
|
documentEdge {
|
||||||
|
node { id }
|
||||||
|
}
|
||||||
|
documentVersionEdge {
|
||||||
|
node { id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
t.Run(
|
||||||
|
"viewer cannot publish obligation list",
|
||||||
|
func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := viewer.ExecuteShouldFail(
|
||||||
|
query,
|
||||||
|
map[string]any{
|
||||||
|
"input": map[string]any{
|
||||||
|
"organizationId": owner.GetOrganizationID(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
testutil.RequireForbiddenError(t, err)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createObligationForPublish(t *testing.T, client *testutil.Client, requirement string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
mutation($input: CreateObligationInput!) {
|
||||||
|
createObligation(input: $input) {
|
||||||
|
obligationEdge {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
CreateObligation struct {
|
||||||
|
ObligationEdge struct {
|
||||||
|
Node struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"node"`
|
||||||
|
} `json:"obligationEdge"`
|
||||||
|
} `json:"createObligation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := client.Execute(query, map[string]any{
|
||||||
|
"input": map[string]any{
|
||||||
|
"organizationId": client.GetOrganizationID().String(),
|
||||||
|
"requirement": requirement,
|
||||||
|
"status": "NON_COMPLIANT",
|
||||||
|
"type": "LEGAL",
|
||||||
|
"ownerId": client.GetProfileID().String(),
|
||||||
|
},
|
||||||
|
}, &result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return result.CreateObligation.ObligationEdge.Node.ID
|
||||||
|
}
|
||||||
@@ -17,8 +17,6 @@ type Translator = (s: string) => string;
|
|||||||
export const snapshotTypes = [
|
export const snapshotTypes = [
|
||||||
"RISKS",
|
"RISKS",
|
||||||
"VENDORS",
|
"VENDORS",
|
||||||
"FINDINGS",
|
|
||||||
"OBLIGATIONS",
|
|
||||||
"PROCESSING_ACTIVITIES",
|
"PROCESSING_ACTIVITIES",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -32,12 +30,6 @@ export function getSnapshotTypeLabel(__: Translator, type: string | null | undef
|
|||||||
return __("Risks");
|
return __("Risks");
|
||||||
case "VENDORS":
|
case "VENDORS":
|
||||||
return __("Vendors");
|
return __("Vendors");
|
||||||
case "FINDINGS":
|
|
||||||
case "NONCONFORMITIES":
|
|
||||||
case "CONTINUAL_IMPROVEMENTS":
|
|
||||||
return __("Findings");
|
|
||||||
case "OBLIGATIONS":
|
|
||||||
return __("Obligations");
|
|
||||||
case "PROCESSING_ACTIVITIES":
|
case "PROCESSING_ACTIVITIES":
|
||||||
return __("Processing Activities");
|
return __("Processing Activities");
|
||||||
default:
|
default:
|
||||||
@@ -51,12 +43,6 @@ export function getSnapshotTypeUrlPath(type?: string): string {
|
|||||||
return "/risks";
|
return "/risks";
|
||||||
case "VENDORS":
|
case "VENDORS":
|
||||||
return "/vendors";
|
return "/vendors";
|
||||||
case "FINDINGS":
|
|
||||||
case "NONCONFORMITIES":
|
|
||||||
case "CONTINUAL_IMPROVEMENTS":
|
|
||||||
return "/findings";
|
|
||||||
case "OBLIGATIONS":
|
|
||||||
return "/obligations";
|
|
||||||
case "PROCESSING_ACTIVITIES":
|
case "PROCESSING_ACTIVITIES":
|
||||||
return "/processing-activities";
|
return "/processing-activities";
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import * as deleteOp from './delete.operation';
|
|||||||
import * as getOp from './get.operation';
|
import * as getOp from './get.operation';
|
||||||
import * as getAllOp from './getAll.operation';
|
import * as getAllOp from './getAll.operation';
|
||||||
import * as linkAuditOp from './linkAudit.operation';
|
import * as linkAuditOp from './linkAudit.operation';
|
||||||
|
import * as publishOp from './publish.operation';
|
||||||
import * as unlinkAuditOp from './unlinkAudit.operation';
|
import * as unlinkAuditOp from './unlinkAudit.operation';
|
||||||
|
|
||||||
export const description: INodeProperties[] = [
|
export const description: INodeProperties[] = [
|
||||||
@@ -63,6 +64,12 @@ export const description: INodeProperties[] = [
|
|||||||
description: 'Link an audit to a finding',
|
description: 'Link an audit to a finding',
|
||||||
action: 'Link an audit to a finding',
|
action: 'Link an audit to a finding',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Publish',
|
||||||
|
value: 'publish',
|
||||||
|
description: 'Publish the finding list as a document',
|
||||||
|
action: 'Publish the finding list',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Unlink Audit',
|
name: 'Unlink Audit',
|
||||||
value: 'unlinkAudit',
|
value: 'unlinkAudit',
|
||||||
@@ -84,6 +91,7 @@ export const description: INodeProperties[] = [
|
|||||||
...getOp.description,
|
...getOp.description,
|
||||||
...getAllOp.description,
|
...getAllOp.description,
|
||||||
...linkAuditOp.description,
|
...linkAuditOp.description,
|
||||||
|
...publishOp.description,
|
||||||
...unlinkAuditOp.description,
|
...unlinkAuditOp.description,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -94,5 +102,6 @@ export {
|
|||||||
getOp as get,
|
getOp as get,
|
||||||
getAllOp as getAll,
|
getAllOp as getAll,
|
||||||
linkAuditOp as linkAudit,
|
linkAuditOp as linkAudit,
|
||||||
|
publishOp as publish,
|
||||||
unlinkAuditOp as unlinkAudit,
|
unlinkAuditOp as unlinkAudit,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||||
|
import { proboApiRequest } from '../../GenericFunctions';
|
||||||
|
|
||||||
|
export const description: INodeProperties[] = [
|
||||||
|
{
|
||||||
|
displayName: 'Organization ID',
|
||||||
|
name: 'organizationId',
|
||||||
|
type: 'string',
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
resource: ['finding'],
|
||||||
|
operation: ['publish'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
default: '',
|
||||||
|
description: 'The ID of the organization whose finding list to publish',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Approver IDs',
|
||||||
|
name: 'approverIds',
|
||||||
|
type: 'string',
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
resource: ['finding'],
|
||||||
|
operation: ['publish'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
default: '',
|
||||||
|
description: 'Comma-separated list of approver profile IDs',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function execute(
|
||||||
|
this: IExecuteFunctions,
|
||||||
|
itemIndex: number,
|
||||||
|
): Promise<INodeExecutionData> {
|
||||||
|
const organizationId = this.getNodeParameter('organizationId', itemIndex) as string;
|
||||||
|
const approverIds = this.getNodeParameter('approverIds', itemIndex, '') as string;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
mutation PublishFindingList($input: PublishFindingListInput!) {
|
||||||
|
publishFindingList(input: $input) {
|
||||||
|
documentEdge {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
status
|
||||||
|
currentPublishedMajor
|
||||||
|
currentPublishedMinor
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
documentVersionEdge {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
major
|
||||||
|
minor
|
||||||
|
status
|
||||||
|
classification
|
||||||
|
documentType
|
||||||
|
publishedAt
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const input: Record<string, unknown> = { organizationId };
|
||||||
|
|
||||||
|
if (approverIds) {
|
||||||
|
input.approverIds = approverIds
|
||||||
|
.split(',')
|
||||||
|
.map(id => id.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseData = await proboApiRequest.call(this, query, { input });
|
||||||
|
|
||||||
|
return {
|
||||||
|
json: responseData,
|
||||||
|
pairedItem: { item: itemIndex },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import * as updateOp from './update.operation';
|
|||||||
import * as deleteOp from './delete.operation';
|
import * as deleteOp from './delete.operation';
|
||||||
import * as getOp from './get.operation';
|
import * as getOp from './get.operation';
|
||||||
import * as getAllOp from './getAll.operation';
|
import * as getAllOp from './getAll.operation';
|
||||||
|
import * as publishOp from './publish.operation';
|
||||||
|
|
||||||
export const description: INodeProperties[] = [
|
export const description: INodeProperties[] = [
|
||||||
{
|
{
|
||||||
@@ -55,6 +56,12 @@ export const description: INodeProperties[] = [
|
|||||||
description: 'Get many obligations',
|
description: 'Get many obligations',
|
||||||
action: 'Get many obligations',
|
action: 'Get many obligations',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Publish',
|
||||||
|
value: 'publish',
|
||||||
|
description: 'Publish the obligation list as a document',
|
||||||
|
action: 'Publish the obligation list',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Update',
|
name: 'Update',
|
||||||
value: 'update',
|
value: 'update',
|
||||||
@@ -69,6 +76,7 @@ export const description: INodeProperties[] = [
|
|||||||
...deleteOp.description,
|
...deleteOp.description,
|
||||||
...getOp.description,
|
...getOp.description,
|
||||||
...getAllOp.description,
|
...getAllOp.description,
|
||||||
|
...publishOp.description,
|
||||||
];
|
];
|
||||||
|
|
||||||
export { createOp as create, updateOp as update, deleteOp as delete, getOp as get, getAllOp as getAll };
|
export { createOp as create, updateOp as update, deleteOp as delete, getOp as get, getAllOp as getAll, publishOp as publish };
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||||
|
import { proboApiRequest } from '../../GenericFunctions';
|
||||||
|
|
||||||
|
export const description: INodeProperties[] = [
|
||||||
|
{
|
||||||
|
displayName: 'Organization ID',
|
||||||
|
name: 'organizationId',
|
||||||
|
type: 'string',
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
resource: ['obligation'],
|
||||||
|
operation: ['publish'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
default: '',
|
||||||
|
description: 'The ID of the organization whose obligation list to publish',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Approver IDs',
|
||||||
|
name: 'approverIds',
|
||||||
|
type: 'string',
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
resource: ['obligation'],
|
||||||
|
operation: ['publish'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
default: '',
|
||||||
|
description: 'Comma-separated list of approver profile IDs',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function execute(
|
||||||
|
this: IExecuteFunctions,
|
||||||
|
itemIndex: number,
|
||||||
|
): Promise<INodeExecutionData> {
|
||||||
|
const organizationId = this.getNodeParameter('organizationId', itemIndex) as string;
|
||||||
|
const approverIds = this.getNodeParameter('approverIds', itemIndex, '') as string;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
mutation PublishObligationList($input: PublishObligationListInput!) {
|
||||||
|
publishObligationList(input: $input) {
|
||||||
|
documentEdge {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
status
|
||||||
|
currentPublishedMajor
|
||||||
|
currentPublishedMinor
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
documentVersionEdge {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
major
|
||||||
|
minor
|
||||||
|
status
|
||||||
|
classification
|
||||||
|
documentType
|
||||||
|
publishedAt
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const input: Record<string, unknown> = { organizationId };
|
||||||
|
|
||||||
|
if (approverIds) {
|
||||||
|
input.approverIds = approverIds
|
||||||
|
.split(',')
|
||||||
|
.map(id => id.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseData = await proboApiRequest.call(this, query, { input });
|
||||||
|
|
||||||
|
return {
|
||||||
|
json: responseData,
|
||||||
|
pairedItem: { item: itemIndex },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/cmd/finding/create"
|
"go.probo.inc/probo/pkg/cmd/finding/create"
|
||||||
"go.probo.inc/probo/pkg/cmd/finding/delete"
|
"go.probo.inc/probo/pkg/cmd/finding/delete"
|
||||||
"go.probo.inc/probo/pkg/cmd/finding/list"
|
"go.probo.inc/probo/pkg/cmd/finding/list"
|
||||||
|
"go.probo.inc/probo/pkg/cmd/finding/publish"
|
||||||
"go.probo.inc/probo/pkg/cmd/finding/update"
|
"go.probo.inc/probo/pkg/cmd/finding/update"
|
||||||
"go.probo.inc/probo/pkg/cmd/finding/view"
|
"go.probo.inc/probo/pkg/cmd/finding/view"
|
||||||
)
|
)
|
||||||
@@ -35,6 +36,7 @@ func NewCmdFinding(f *cmdutil.Factory) *cobra.Command {
|
|||||||
cmd.AddCommand(view.NewCmdView(f))
|
cmd.AddCommand(view.NewCmdView(f))
|
||||||
cmd.AddCommand(update.NewCmdUpdate(f))
|
cmd.AddCommand(update.NewCmdUpdate(f))
|
||||||
cmd.AddCommand(delete.NewCmdDelete(f))
|
cmd.AddCommand(delete.NewCmdDelete(f))
|
||||||
|
cmd.AddCommand(publish.NewCmdPublish(f))
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|||||||
147
pkg/cmd/finding/publish/publish.go
Normal file
147
pkg/cmd/finding/publish/publish.go
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
package publish
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"go.probo.inc/probo/pkg/cli/api"
|
||||||
|
"go.probo.inc/probo/pkg/cmd/cmdutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
const publishMutation = `
|
||||||
|
mutation($input: PublishFindingListInput!) {
|
||||||
|
publishFindingList(input: $input) {
|
||||||
|
documentEdge {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
status
|
||||||
|
createdAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
documentVersionEdge {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
major
|
||||||
|
minor
|
||||||
|
status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
type publishResponse struct {
|
||||||
|
PublishFindingList 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:"publishFindingList"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCmdPublish(f *cmdutil.Factory) *cobra.Command {
|
||||||
|
var (
|
||||||
|
flagOrg string
|
||||||
|
flagApprover []string
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "publish",
|
||||||
|
Short: "Publish the finding register as a document version",
|
||||||
|
Example: ` # Publish the finding register
|
||||||
|
prb finding publish --org ORG_ID
|
||||||
|
|
||||||
|
# Publish with approvers
|
||||||
|
prb finding publish --org ORG_ID --approver PROFILE_ID1 --approver PROFILE_ID2`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
cfg, err := f.Config()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
host, hc, err := cfg.DefaultHost()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if flagOrg == "" {
|
||||||
|
flagOrg = hc.Organization
|
||||||
|
}
|
||||||
|
if flagOrg == "" {
|
||||||
|
return fmt.Errorf("organization is required: pass --org or run `prb auth login`")
|
||||||
|
}
|
||||||
|
|
||||||
|
client := api.NewClient(
|
||||||
|
host,
|
||||||
|
hc.Token,
|
||||||
|
"/api/console/v1/graphql",
|
||||||
|
cfg.HTTPTimeoutDuration(),
|
||||||
|
)
|
||||||
|
|
||||||
|
input := map[string]any{
|
||||||
|
"organizationId": flagOrg,
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(flagApprover) > 0 {
|
||||||
|
input["approverIds"] = flagApprover
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := client.Do(
|
||||||
|
publishMutation,
|
||||||
|
map[string]any{"input": input},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp publishResponse
|
||||||
|
if err := json.Unmarshal(data, &resp); err != nil {
|
||||||
|
return fmt.Errorf("cannot parse response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
v := resp.PublishFindingList.DocumentVersionEdge.Node
|
||||||
|
_, _ = fmt.Fprintf(
|
||||||
|
f.IOStreams.Out,
|
||||||
|
"Published finding 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
|
||||||
|
}
|
||||||
@@ -24,11 +24,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const listQuery = `
|
const listQuery = `
|
||||||
query($id: ID!, $first: Int, $after: CursorKey, $orderBy: ObligationOrder, $filter: ObligationFilter) {
|
query($id: ID!, $first: Int, $after: CursorKey, $orderBy: ObligationOrder) {
|
||||||
node(id: $id) {
|
node(id: $id) {
|
||||||
__typename
|
__typename
|
||||||
... on Organization {
|
... on Organization {
|
||||||
obligations(first: $first, after: $after, orderBy: $orderBy, filter: $filter) {
|
obligations(first: $first, after: $after, orderBy: $orderBy) {
|
||||||
totalCount
|
totalCount
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/cmd/obligation/create"
|
"go.probo.inc/probo/pkg/cmd/obligation/create"
|
||||||
"go.probo.inc/probo/pkg/cmd/obligation/delete"
|
"go.probo.inc/probo/pkg/cmd/obligation/delete"
|
||||||
"go.probo.inc/probo/pkg/cmd/obligation/list"
|
"go.probo.inc/probo/pkg/cmd/obligation/list"
|
||||||
|
"go.probo.inc/probo/pkg/cmd/obligation/publish"
|
||||||
"go.probo.inc/probo/pkg/cmd/obligation/update"
|
"go.probo.inc/probo/pkg/cmd/obligation/update"
|
||||||
"go.probo.inc/probo/pkg/cmd/obligation/view"
|
"go.probo.inc/probo/pkg/cmd/obligation/view"
|
||||||
)
|
)
|
||||||
@@ -35,6 +36,7 @@ func NewCmdObligation(f *cmdutil.Factory) *cobra.Command {
|
|||||||
cmd.AddCommand(view.NewCmdView(f))
|
cmd.AddCommand(view.NewCmdView(f))
|
||||||
cmd.AddCommand(update.NewCmdUpdate(f))
|
cmd.AddCommand(update.NewCmdUpdate(f))
|
||||||
cmd.AddCommand(delete.NewCmdDelete(f))
|
cmd.AddCommand(delete.NewCmdDelete(f))
|
||||||
|
cmd.AddCommand(publish.NewCmdPublish(f))
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|||||||
147
pkg/cmd/obligation/publish/publish.go
Normal file
147
pkg/cmd/obligation/publish/publish.go
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
package publish
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"go.probo.inc/probo/pkg/cli/api"
|
||||||
|
"go.probo.inc/probo/pkg/cmd/cmdutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
const publishMutation = `
|
||||||
|
mutation($input: PublishObligationListInput!) {
|
||||||
|
publishObligationList(input: $input) {
|
||||||
|
documentEdge {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
status
|
||||||
|
createdAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
documentVersionEdge {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
major
|
||||||
|
minor
|
||||||
|
status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
type publishResponse struct {
|
||||||
|
PublishObligationList 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:"publishObligationList"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCmdPublish(f *cmdutil.Factory) *cobra.Command {
|
||||||
|
var (
|
||||||
|
flagOrg string
|
||||||
|
flagApprover []string
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "publish",
|
||||||
|
Short: "Publish the obligation register as a document version",
|
||||||
|
Example: ` # Publish the obligation register
|
||||||
|
prb obligation publish --org ORG_ID
|
||||||
|
|
||||||
|
# Publish with approvers
|
||||||
|
prb obligation publish --org ORG_ID --approver PROFILE_ID1 --approver PROFILE_ID2`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
cfg, err := f.Config()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
host, hc, err := cfg.DefaultHost()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if flagOrg == "" {
|
||||||
|
flagOrg = hc.Organization
|
||||||
|
}
|
||||||
|
if flagOrg == "" {
|
||||||
|
return fmt.Errorf("organization is required: pass --org or run `prb auth login`")
|
||||||
|
}
|
||||||
|
|
||||||
|
client := api.NewClient(
|
||||||
|
host,
|
||||||
|
hc.Token,
|
||||||
|
"/api/console/v1/graphql",
|
||||||
|
cfg.HTTPTimeoutDuration(),
|
||||||
|
)
|
||||||
|
|
||||||
|
input := map[string]any{
|
||||||
|
"organizationId": flagOrg,
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(flagApprover) > 0 {
|
||||||
|
input["approverIds"] = flagApprover
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := client.Do(
|
||||||
|
publishMutation,
|
||||||
|
map[string]any{"input": input},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp publishResponse
|
||||||
|
if err := json.Unmarshal(data, &resp); err != nil {
|
||||||
|
return fmt.Errorf("cannot parse response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
v := resp.PublishObligationList.DocumentVersionEdge.Node
|
||||||
|
_, _ = fmt.Fprintf(
|
||||||
|
f.IOStreams.Out,
|
||||||
|
"Published obligation 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
|
||||||
|
}
|
||||||
@@ -120,6 +120,7 @@ FROM
|
|||||||
WHERE
|
WHERE
|
||||||
%s
|
%s
|
||||||
AND id = @finding_id
|
AND id = @finding_id
|
||||||
|
AND snapshot_id IS NULL
|
||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -158,6 +159,7 @@ FROM
|
|||||||
WHERE
|
WHERE
|
||||||
%s
|
%s
|
||||||
AND organization_id = @organization_id
|
AND organization_id = @organization_id
|
||||||
|
AND snapshot_id IS NULL
|
||||||
AND %s
|
AND %s
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -212,6 +214,7 @@ FROM
|
|||||||
WHERE
|
WHERE
|
||||||
%s
|
%s
|
||||||
AND organization_id = @organization_id
|
AND organization_id = @organization_id
|
||||||
|
AND snapshot_id IS NULL
|
||||||
AND %s
|
AND %s
|
||||||
AND %s
|
AND %s
|
||||||
`
|
`
|
||||||
@@ -412,95 +415,6 @@ WHERE
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (fs Findings) Snapshot(ctx context.Context, conn pg.Tx, scope Scoper, organizationID, snapshotID gid.GID) error {
|
|
||||||
query := `
|
|
||||||
INSERT INTO findings (
|
|
||||||
id,
|
|
||||||
tenant_id,
|
|
||||||
snapshot_id,
|
|
||||||
source_id,
|
|
||||||
organization_id,
|
|
||||||
kind,
|
|
||||||
reference_id,
|
|
||||||
description,
|
|
||||||
source,
|
|
||||||
identified_on,
|
|
||||||
root_cause,
|
|
||||||
corrective_action,
|
|
||||||
owner_id,
|
|
||||||
due_date,
|
|
||||||
status,
|
|
||||||
priority,
|
|
||||||
risk_id,
|
|
||||||
effectiveness_check,
|
|
||||||
created_at,
|
|
||||||
updated_at
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
generate_gid(decode_base64_unpadded(@tenant_id), @finding_entity_type),
|
|
||||||
@tenant_id,
|
|
||||||
@snapshot_id,
|
|
||||||
f.id,
|
|
||||||
f.organization_id,
|
|
||||||
f.kind,
|
|
||||||
f.reference_id,
|
|
||||||
f.description,
|
|
||||||
f.source,
|
|
||||||
f.identified_on,
|
|
||||||
f.root_cause,
|
|
||||||
f.corrective_action,
|
|
||||||
f.owner_id,
|
|
||||||
f.due_date,
|
|
||||||
f.status,
|
|
||||||
f.priority,
|
|
||||||
f.risk_id,
|
|
||||||
f.effectiveness_check,
|
|
||||||
f.created_at,
|
|
||||||
f.updated_at
|
|
||||||
FROM findings f
|
|
||||||
WHERE %s AND f.organization_id = @organization_id AND f.snapshot_id IS NULL
|
|
||||||
`
|
|
||||||
|
|
||||||
query = fmt.Sprintf(query, scope.SQLFragment())
|
|
||||||
|
|
||||||
args := pgx.StrictNamedArgs{
|
|
||||||
"tenant_id": scope.GetTenantID(),
|
|
||||||
"snapshot_id": snapshotID,
|
|
||||||
"organization_id": organizationID,
|
|
||||||
"finding_entity_type": FindingEntityType,
|
|
||||||
}
|
|
||||||
maps.Copy(args, scope.SQLArguments())
|
|
||||||
|
|
||||||
_, err := conn.Exec(ctx, query, args)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("cannot insert finding snapshots: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
auditQuery := `
|
|
||||||
INSERT INTO findings_audits (finding_id, audit_id, reference_id, organization_id, tenant_id, created_at)
|
|
||||||
SELECT
|
|
||||||
snap.id,
|
|
||||||
fa.audit_id,
|
|
||||||
fa.reference_id,
|
|
||||||
fa.organization_id,
|
|
||||||
fa.tenant_id,
|
|
||||||
fa.created_at
|
|
||||||
FROM findings_audits fa
|
|
||||||
JOIN findings live ON fa.finding_id = live.id AND live.snapshot_id IS NULL
|
|
||||||
JOIN findings snap ON snap.source_id = live.id AND snap.snapshot_id = @snapshot_id
|
|
||||||
WHERE %s AND live.organization_id = @organization_id
|
|
||||||
`
|
|
||||||
|
|
||||||
auditQuery = fmt.Sprintf(auditQuery, scope.SQLFragment())
|
|
||||||
|
|
||||||
_, err = conn.Exec(ctx, auditQuery, args)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("cannot insert finding audit snapshots: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fs *Findings) LoadByAuditID(
|
func (fs *Findings) LoadByAuditID(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
conn pg.Querier,
|
conn pg.Querier,
|
||||||
@@ -538,6 +452,7 @@ WITH f AS (
|
|||||||
findings_audits fa ON fi.id = fa.finding_id
|
findings_audits fa ON fi.id = fa.finding_id
|
||||||
WHERE
|
WHERE
|
||||||
fa.audit_id = @audit_id
|
fa.audit_id = @audit_id
|
||||||
|
AND fi.snapshot_id IS NULL
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
@@ -605,6 +520,7 @@ WITH f AS (
|
|||||||
findings_audits fa ON fi.id = fa.finding_id
|
findings_audits fa ON fi.id = fa.finding_id
|
||||||
WHERE
|
WHERE
|
||||||
fa.audit_id = @audit_id
|
fa.audit_id = @audit_id
|
||||||
|
AND fi.snapshot_id IS NULL
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(id)
|
COUNT(id)
|
||||||
@@ -631,3 +547,167 @@ WHERE
|
|||||||
|
|
||||||
return count, nil
|
return count, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (fs *Findings) LoadAllByOrganizationID(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Querier,
|
||||||
|
scope Scoper,
|
||||||
|
organizationID gid.GID,
|
||||||
|
) error {
|
||||||
|
q := `
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
organization_id,
|
||||||
|
snapshot_id,
|
||||||
|
source_id,
|
||||||
|
kind,
|
||||||
|
reference_id,
|
||||||
|
description,
|
||||||
|
source,
|
||||||
|
identified_on,
|
||||||
|
root_cause,
|
||||||
|
corrective_action,
|
||||||
|
owner_id,
|
||||||
|
due_date,
|
||||||
|
status,
|
||||||
|
priority,
|
||||||
|
risk_id,
|
||||||
|
effectiveness_check,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM
|
||||||
|
findings
|
||||||
|
WHERE
|
||||||
|
%s
|
||||||
|
AND organization_id = @organization_id
|
||||||
|
AND snapshot_id IS NULL
|
||||||
|
ORDER BY
|
||||||
|
reference_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 findings: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
findings, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Finding])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot collect findings: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
*fs = findings
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f Finding) GetGeneratedDocumentID(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Querier,
|
||||||
|
organizationID gid.GID,
|
||||||
|
) (*gid.GID, error) {
|
||||||
|
var documentID *gid.GID
|
||||||
|
|
||||||
|
err := conn.QueryRow(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
findings_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 finding list document ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return documentID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f Finding) 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,
|
||||||
|
findings_document_id,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
) VALUES (
|
||||||
|
@organization_id,
|
||||||
|
@tenant_id,
|
||||||
|
@findings_document_id,
|
||||||
|
@created_at,
|
||||||
|
@updated_at
|
||||||
|
)
|
||||||
|
ON CONFLICT (organization_id) DO UPDATE
|
||||||
|
SET
|
||||||
|
findings_document_id = @findings_document_id,
|
||||||
|
updated_at = @updated_at
|
||||||
|
`,
|
||||||
|
pgx.NamedArgs{
|
||||||
|
"organization_id": organizationID,
|
||||||
|
"tenant_id": tenantID,
|
||||||
|
"findings_document_id": documentID,
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot upsert finding list document ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f Finding) 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
|
||||||
|
findings_document_id = NULL,
|
||||||
|
updated_at = @now
|
||||||
|
WHERE
|
||||||
|
findings_document_id = ANY(@ids)
|
||||||
|
`,
|
||||||
|
pgx.NamedArgs{
|
||||||
|
"ids": ids,
|
||||||
|
"now": time.Now(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot clear finding list document references: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,34 +21,29 @@ import (
|
|||||||
|
|
||||||
type (
|
type (
|
||||||
FindingFilter struct {
|
FindingFilter struct {
|
||||||
snapshotID **gid.GID
|
kind *FindingKind
|
||||||
kind *FindingKind
|
status *FindingStatus
|
||||||
status *FindingStatus
|
priority *FindingPriority
|
||||||
priority *FindingPriority
|
ownerID *gid.GID
|
||||||
ownerID *gid.GID
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewFindingFilter(
|
func NewFindingFilter(
|
||||||
snapshotID **gid.GID,
|
|
||||||
kind *FindingKind,
|
kind *FindingKind,
|
||||||
status *FindingStatus,
|
status *FindingStatus,
|
||||||
priority *FindingPriority,
|
priority *FindingPriority,
|
||||||
ownerID *gid.GID,
|
ownerID *gid.GID,
|
||||||
) *FindingFilter {
|
) *FindingFilter {
|
||||||
return &FindingFilter{
|
return &FindingFilter{
|
||||||
snapshotID: snapshotID,
|
kind: kind,
|
||||||
kind: kind,
|
status: status,
|
||||||
status: status,
|
priority: priority,
|
||||||
priority: priority,
|
ownerID: ownerID,
|
||||||
ownerID: ownerID,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *FindingFilter) SQLArguments() pgx.StrictNamedArgs {
|
func (f *FindingFilter) SQLArguments() pgx.StrictNamedArgs {
|
||||||
args := pgx.StrictNamedArgs{
|
args := pgx.StrictNamedArgs{
|
||||||
"has_snapshot_filter": false,
|
|
||||||
"filter_snapshot_id": nil,
|
|
||||||
"has_kind_filter": false,
|
"has_kind_filter": false,
|
||||||
"filter_kind": nil,
|
"filter_kind": nil,
|
||||||
"has_status_filter": false,
|
"has_status_filter": false,
|
||||||
@@ -59,13 +54,6 @@ func (f *FindingFilter) SQLArguments() pgx.StrictNamedArgs {
|
|||||||
"filter_owner_id": nil,
|
"filter_owner_id": nil,
|
||||||
}
|
}
|
||||||
|
|
||||||
if f.snapshotID != nil {
|
|
||||||
args["has_snapshot_filter"] = true
|
|
||||||
if *f.snapshotID != nil {
|
|
||||||
args["filter_snapshot_id"] = **f.snapshotID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if f.kind != nil {
|
if f.kind != nil {
|
||||||
args["has_kind_filter"] = true
|
args["has_kind_filter"] = true
|
||||||
args["filter_kind"] = string(*f.kind)
|
args["filter_kind"] = string(*f.kind)
|
||||||
@@ -92,15 +80,6 @@ func (f *FindingFilter) SQLArguments() pgx.StrictNamedArgs {
|
|||||||
func (f *FindingFilter) SQLFragment() string {
|
func (f *FindingFilter) SQLFragment() string {
|
||||||
return `
|
return `
|
||||||
(
|
(
|
||||||
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
|
|
||||||
AND
|
|
||||||
CASE
|
CASE
|
||||||
WHEN @has_kind_filter::boolean = false THEN TRUE
|
WHEN @has_kind_filter::boolean = false THEN TRUE
|
||||||
WHEN @has_kind_filter::boolean = true THEN
|
WHEN @has_kind_filter::boolean = true THEN
|
||||||
|
|||||||
17
pkg/coredata/migrations/20260422T130000Z.sql
Normal file
17
pkg/coredata/migrations/20260422T130000Z.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
-- 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 findings_document_id TEXT REFERENCES documents(id) ON DELETE SET NULL,
|
||||||
|
ADD COLUMN obligations_document_id TEXT REFERENCES documents(id) ON DELETE SET NULL;
|
||||||
@@ -108,6 +108,7 @@ FROM
|
|||||||
WHERE
|
WHERE
|
||||||
%s
|
%s
|
||||||
AND id = @obligation_id
|
AND id = @obligation_id
|
||||||
|
AND snapshot_id IS NULL
|
||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -136,7 +137,6 @@ func (os *Obligations) CountByOrganizationID(
|
|||||||
conn pg.Querier,
|
conn pg.Querier,
|
||||||
scope Scoper,
|
scope Scoper,
|
||||||
organizationID gid.GID,
|
organizationID gid.GID,
|
||||||
filter *ObligationFilter,
|
|
||||||
) (int, error) {
|
) (int, error) {
|
||||||
q := `
|
q := `
|
||||||
SELECT
|
SELECT
|
||||||
@@ -146,14 +146,13 @@ FROM
|
|||||||
WHERE
|
WHERE
|
||||||
%s
|
%s
|
||||||
AND organization_id = @organization_id
|
AND organization_id = @organization_id
|
||||||
AND %s
|
AND snapshot_id IS NULL
|
||||||
`
|
`
|
||||||
|
|
||||||
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||||
|
|
||||||
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
||||||
maps.Copy(args, scope.SQLArguments())
|
maps.Copy(args, scope.SQLArguments())
|
||||||
maps.Copy(args, filter.SQLArguments())
|
|
||||||
|
|
||||||
row := conn.QueryRow(ctx, q, args)
|
row := conn.QueryRow(ctx, q, args)
|
||||||
|
|
||||||
@@ -171,7 +170,6 @@ func (os *Obligations) CountByRiskID(
|
|||||||
conn pg.Querier,
|
conn pg.Querier,
|
||||||
scope Scoper,
|
scope Scoper,
|
||||||
riskID gid.GID,
|
riskID gid.GID,
|
||||||
filter *ObligationFilter,
|
|
||||||
) (int, error) {
|
) (int, error) {
|
||||||
q := `
|
q := `
|
||||||
WITH obls AS (
|
WITH obls AS (
|
||||||
@@ -186,20 +184,19 @@ WITH obls AS (
|
|||||||
risks_obligations ro ON o.id = ro.obligation_id
|
risks_obligations ro ON o.id = ro.obligation_id
|
||||||
WHERE
|
WHERE
|
||||||
ro.risk_id = @risk_id
|
ro.risk_id = @risk_id
|
||||||
|
AND o.snapshot_id IS NULL
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(id)
|
COUNT(id)
|
||||||
FROM
|
FROM
|
||||||
obls
|
obls
|
||||||
WHERE %s
|
WHERE %s
|
||||||
AND %s
|
|
||||||
`
|
`
|
||||||
|
|
||||||
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||||
|
|
||||||
args := pgx.StrictNamedArgs{"risk_id": riskID}
|
args := pgx.StrictNamedArgs{"risk_id": riskID}
|
||||||
maps.Copy(args, scope.SQLArguments())
|
maps.Copy(args, scope.SQLArguments())
|
||||||
maps.Copy(args, filter.SQLArguments())
|
|
||||||
|
|
||||||
row := conn.QueryRow(ctx, q, args)
|
row := conn.QueryRow(ctx, q, args)
|
||||||
|
|
||||||
@@ -218,7 +215,6 @@ func (os *Obligations) LoadByOrganizationID(
|
|||||||
scope Scoper,
|
scope Scoper,
|
||||||
organizationID gid.GID,
|
organizationID gid.GID,
|
||||||
cursor *page.Cursor[ObligationOrderField],
|
cursor *page.Cursor[ObligationOrderField],
|
||||||
filter *ObligationFilter,
|
|
||||||
) error {
|
) error {
|
||||||
q := `
|
q := `
|
||||||
SELECT
|
SELECT
|
||||||
@@ -243,15 +239,14 @@ FROM
|
|||||||
WHERE
|
WHERE
|
||||||
%s
|
%s
|
||||||
AND organization_id = @organization_id
|
AND organization_id = @organization_id
|
||||||
AND %s
|
AND snapshot_id IS NULL
|
||||||
AND %s
|
AND %s
|
||||||
`
|
`
|
||||||
|
|
||||||
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment())
|
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
|
||||||
|
|
||||||
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
||||||
maps.Copy(args, scope.SQLArguments())
|
maps.Copy(args, scope.SQLArguments())
|
||||||
maps.Copy(args, filter.SQLArguments())
|
|
||||||
maps.Copy(args, cursor.SQLArguments())
|
maps.Copy(args, cursor.SQLArguments())
|
||||||
|
|
||||||
rows, err := conn.Query(ctx, q, args)
|
rows, err := conn.Query(ctx, q, args)
|
||||||
@@ -275,7 +270,6 @@ func (os *Obligations) LoadByRiskID(
|
|||||||
scope Scoper,
|
scope Scoper,
|
||||||
riskID gid.GID,
|
riskID gid.GID,
|
||||||
cursor *page.Cursor[ObligationOrderField],
|
cursor *page.Cursor[ObligationOrderField],
|
||||||
filter *ObligationFilter,
|
|
||||||
) error {
|
) error {
|
||||||
q := `
|
q := `
|
||||||
WITH obls AS (
|
WITH obls AS (
|
||||||
@@ -304,6 +298,7 @@ WITH obls AS (
|
|||||||
risks_obligations ro ON o.id = ro.obligation_id
|
risks_obligations ro ON o.id = ro.obligation_id
|
||||||
WHERE
|
WHERE
|
||||||
ro.risk_id = @risk_id
|
ro.risk_id = @risk_id
|
||||||
|
AND o.snapshot_id IS NULL
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
@@ -326,14 +321,12 @@ FROM
|
|||||||
obls
|
obls
|
||||||
WHERE %s
|
WHERE %s
|
||||||
AND %s
|
AND %s
|
||||||
AND %s
|
|
||||||
`
|
`
|
||||||
|
|
||||||
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment())
|
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
|
||||||
|
|
||||||
args := pgx.StrictNamedArgs{"risk_id": riskID}
|
args := pgx.StrictNamedArgs{"risk_id": riskID}
|
||||||
maps.Copy(args, scope.SQLArguments())
|
maps.Copy(args, scope.SQLArguments())
|
||||||
maps.Copy(args, filter.SQLArguments())
|
|
||||||
maps.Copy(args, cursor.SQLArguments())
|
maps.Copy(args, cursor.SQLArguments())
|
||||||
|
|
||||||
rows, err := conn.Query(ctx, q, args)
|
rows, err := conn.Query(ctx, q, args)
|
||||||
@@ -356,7 +349,6 @@ func (os *Obligations) CountByControlID(
|
|||||||
conn pg.Querier,
|
conn pg.Querier,
|
||||||
scope Scoper,
|
scope Scoper,
|
||||||
controlID gid.GID,
|
controlID gid.GID,
|
||||||
filter *ObligationFilter,
|
|
||||||
) (int, error) {
|
) (int, error) {
|
||||||
q := `
|
q := `
|
||||||
WITH obls AS (
|
WITH obls AS (
|
||||||
@@ -370,20 +362,19 @@ WITH obls AS (
|
|||||||
controls_obligations co ON o.id = co.obligation_id
|
controls_obligations co ON o.id = co.obligation_id
|
||||||
WHERE
|
WHERE
|
||||||
co.control_id = @control_id
|
co.control_id = @control_id
|
||||||
|
AND o.snapshot_id IS NULL
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(id)
|
COUNT(id)
|
||||||
FROM
|
FROM
|
||||||
obls
|
obls
|
||||||
WHERE %s
|
WHERE %s
|
||||||
AND %s
|
|
||||||
`
|
`
|
||||||
|
|
||||||
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||||
|
|
||||||
args := pgx.StrictNamedArgs{"control_id": controlID}
|
args := pgx.StrictNamedArgs{"control_id": controlID}
|
||||||
maps.Copy(args, scope.SQLArguments())
|
maps.Copy(args, scope.SQLArguments())
|
||||||
maps.Copy(args, filter.SQLArguments())
|
|
||||||
|
|
||||||
row := conn.QueryRow(ctx, q, args)
|
row := conn.QueryRow(ctx, q, args)
|
||||||
|
|
||||||
@@ -402,7 +393,6 @@ func (os *Obligations) LoadByControlID(
|
|||||||
scope Scoper,
|
scope Scoper,
|
||||||
controlID gid.GID,
|
controlID gid.GID,
|
||||||
cursor *page.Cursor[ObligationOrderField],
|
cursor *page.Cursor[ObligationOrderField],
|
||||||
filter *ObligationFilter,
|
|
||||||
) error {
|
) error {
|
||||||
q := `
|
q := `
|
||||||
WITH obls AS (
|
WITH obls AS (
|
||||||
@@ -430,6 +420,7 @@ WITH obls AS (
|
|||||||
controls_obligations co ON o.id = co.obligation_id
|
controls_obligations co ON o.id = co.obligation_id
|
||||||
WHERE
|
WHERE
|
||||||
co.control_id = @control_id
|
co.control_id = @control_id
|
||||||
|
AND o.snapshot_id IS NULL
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
@@ -452,13 +443,11 @@ FROM
|
|||||||
obls
|
obls
|
||||||
WHERE %s
|
WHERE %s
|
||||||
AND %s
|
AND %s
|
||||||
AND %s
|
|
||||||
`
|
`
|
||||||
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment())
|
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
|
||||||
|
|
||||||
args := pgx.NamedArgs{"control_id": controlID}
|
args := pgx.NamedArgs{"control_id": controlID}
|
||||||
maps.Copy(args, scope.SQLArguments())
|
maps.Copy(args, scope.SQLArguments())
|
||||||
maps.Copy(args, filter.SQLArguments())
|
|
||||||
maps.Copy(args, cursor.SQLArguments())
|
maps.Copy(args, cursor.SQLArguments())
|
||||||
|
|
||||||
rows, err := conn.Query(ctx, q, args)
|
rows, err := conn.Query(ctx, q, args)
|
||||||
@@ -625,13 +614,15 @@ WHERE
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (os Obligations) Snapshot(ctx context.Context, conn pg.Tx, scope Scoper, organizationID, snapshotID gid.GID) error {
|
func (os *Obligations) LoadAllByOrganizationID(
|
||||||
query := `
|
ctx context.Context,
|
||||||
INSERT INTO obligations (
|
conn pg.Querier,
|
||||||
|
scope Scoper,
|
||||||
|
organizationID gid.GID,
|
||||||
|
) error {
|
||||||
|
q := `
|
||||||
|
SELECT
|
||||||
id,
|
id,
|
||||||
tenant_id,
|
|
||||||
snapshot_id,
|
|
||||||
source_id,
|
|
||||||
organization_id,
|
organization_id,
|
||||||
area,
|
area,
|
||||||
source,
|
source,
|
||||||
@@ -643,44 +634,142 @@ INSERT INTO obligations (
|
|||||||
due_date,
|
due_date,
|
||||||
status,
|
status,
|
||||||
type,
|
type,
|
||||||
|
snapshot_id,
|
||||||
|
source_id,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
)
|
FROM
|
||||||
SELECT
|
obligations
|
||||||
generate_gid(decode_base64_unpadded(@tenant_id), @obligation_entity_type),
|
WHERE
|
||||||
@tenant_id,
|
%s
|
||||||
@snapshot_id,
|
AND organization_id = @organization_id
|
||||||
o.id,
|
AND snapshot_id IS NULL
|
||||||
o.organization_id,
|
ORDER BY
|
||||||
o.area,
|
created_at ASC
|
||||||
o.source,
|
`
|
||||||
o.requirement,
|
|
||||||
o.actions_to_be_implemented,
|
|
||||||
o.regulator,
|
|
||||||
o.owner_profile_id,
|
|
||||||
o.last_review_date,
|
|
||||||
o.due_date,
|
|
||||||
o.status,
|
|
||||||
o.type,
|
|
||||||
o.created_at,
|
|
||||||
o.updated_at
|
|
||||||
FROM obligations o
|
|
||||||
WHERE %s AND o.organization_id = @organization_id AND o.snapshot_id IS NULL
|
|
||||||
`
|
|
||||||
|
|
||||||
query = fmt.Sprintf(query, scope.SQLFragment())
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||||
|
|
||||||
args := pgx.StrictNamedArgs{
|
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
||||||
"tenant_id": scope.GetTenantID(),
|
|
||||||
"snapshot_id": snapshotID,
|
|
||||||
"organization_id": organizationID,
|
|
||||||
"obligation_entity_type": ObligationEntityType,
|
|
||||||
}
|
|
||||||
maps.Copy(args, scope.SQLArguments())
|
maps.Copy(args, scope.SQLArguments())
|
||||||
|
|
||||||
_, err := conn.Exec(ctx, query, args)
|
rows, err := conn.Query(ctx, q, args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot insert obligation snapshots: %w", err)
|
return fmt.Errorf("cannot query obligations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
obligations, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Obligation])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot collect obligations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
*os = obligations
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o Obligation) GetGeneratedDocumentID(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Querier,
|
||||||
|
organizationID gid.GID,
|
||||||
|
) (*gid.GID, error) {
|
||||||
|
var documentID *gid.GID
|
||||||
|
|
||||||
|
err := conn.QueryRow(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
obligations_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 obligation list document ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return documentID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o Obligation) 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,
|
||||||
|
obligations_document_id,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
) VALUES (
|
||||||
|
@organization_id,
|
||||||
|
@tenant_id,
|
||||||
|
@obligations_document_id,
|
||||||
|
@created_at,
|
||||||
|
@updated_at
|
||||||
|
)
|
||||||
|
ON CONFLICT (organization_id) DO UPDATE
|
||||||
|
SET
|
||||||
|
obligations_document_id = @obligations_document_id,
|
||||||
|
updated_at = @updated_at
|
||||||
|
`,
|
||||||
|
pgx.NamedArgs{
|
||||||
|
"organization_id": organizationID,
|
||||||
|
"tenant_id": tenantID,
|
||||||
|
"obligations_document_id": documentID,
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot upsert obligation list document ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o Obligation) 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
|
||||||
|
obligations_document_id = NULL,
|
||||||
|
updated_at = @now
|
||||||
|
WHERE
|
||||||
|
obligations_document_id = ANY(@ids)
|
||||||
|
`,
|
||||||
|
pgx.NamedArgs{
|
||||||
|
"ids": ids,
|
||||||
|
"now": time.Now(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot clear obligation list document references: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|
||||||
package coredata
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"go.probo.inc/probo/pkg/gid"
|
|
||||||
)
|
|
||||||
|
|
||||||
type (
|
|
||||||
ObligationFilter struct {
|
|
||||||
snapshotID **gid.GID
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
func NewObligationFilter(snapshotID **gid.GID) *ObligationFilter {
|
|
||||||
return &ObligationFilter{
|
|
||||||
snapshotID: snapshotID,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *ObligationFilter) SQLArguments() pgx.NamedArgs {
|
|
||||||
args := pgx.NamedArgs{}
|
|
||||||
|
|
||||||
if f.snapshotID != nil && *f.snapshotID != nil {
|
|
||||||
args["filter_snapshot_id"] = **f.snapshotID
|
|
||||||
}
|
|
||||||
|
|
||||||
return args
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *ObligationFilter) SQLFragment() string {
|
|
||||||
if f.snapshotID == nil {
|
|
||||||
return "TRUE"
|
|
||||||
}
|
|
||||||
|
|
||||||
if *f.snapshotID == nil {
|
|
||||||
return "snapshot_id IS NULL"
|
|
||||||
} else {
|
|
||||||
return "snapshot_id = @filter_snapshot_id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -38,8 +38,6 @@ func SnapshotsTypes() []SnapshotsType {
|
|||||||
return []SnapshotsType{
|
return []SnapshotsType{
|
||||||
SnapshotsTypeRisks,
|
SnapshotsTypeRisks,
|
||||||
SnapshotsTypeVendors,
|
SnapshotsTypeVendors,
|
||||||
SnapshotsTypeFindings,
|
|
||||||
SnapshotsTypeObligations,
|
|
||||||
SnapshotsTypeProcessingActivities,
|
SnapshotsTypeProcessingActivities,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,10 +30,6 @@ func GetSnapshottable(snapshotType SnapshotsType) (Snapshottable, error) {
|
|||||||
switch snapshotType {
|
switch snapshotType {
|
||||||
case SnapshotsTypeRisks:
|
case SnapshotsTypeRisks:
|
||||||
return Risks{}, nil
|
return Risks{}, nil
|
||||||
case SnapshotsTypeFindings:
|
|
||||||
return Findings{}, nil
|
|
||||||
case SnapshotsTypeObligations:
|
|
||||||
return Obligations{}, nil
|
|
||||||
case SnapshotsTypeProcessingActivities:
|
case SnapshotsTypeProcessingActivities:
|
||||||
return ProcessingActivities{}, nil
|
return ProcessingActivities{}, nil
|
||||||
case SnapshotsTypeVendors:
|
case SnapshotsTypeVendors:
|
||||||
|
|||||||
@@ -338,6 +338,49 @@ type (
|
|||||||
Owner string
|
Owner string
|
||||||
Vendors string
|
Vendors string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FindingListData struct {
|
||||||
|
Title string
|
||||||
|
OrganizationName string
|
||||||
|
CreatedAt time.Time
|
||||||
|
TotalFindings int
|
||||||
|
Rows []FindingListRow
|
||||||
|
}
|
||||||
|
|
||||||
|
FindingListRow struct {
|
||||||
|
ReferenceID string
|
||||||
|
Kind string
|
||||||
|
Description string
|
||||||
|
Source string
|
||||||
|
IdentifiedOn string
|
||||||
|
RootCause string
|
||||||
|
CorrectiveAction string
|
||||||
|
EffectivenessCheck string
|
||||||
|
Status string
|
||||||
|
Priority string
|
||||||
|
Owner string
|
||||||
|
DueDate string
|
||||||
|
}
|
||||||
|
|
||||||
|
ObligationListData struct {
|
||||||
|
Title string
|
||||||
|
OrganizationName string
|
||||||
|
CreatedAt time.Time
|
||||||
|
TotalObligations int
|
||||||
|
Rows []ObligationListRow
|
||||||
|
}
|
||||||
|
|
||||||
|
ObligationListRow struct {
|
||||||
|
Area string
|
||||||
|
Source string
|
||||||
|
Requirement string
|
||||||
|
ActionsToBeImplemented string
|
||||||
|
Status string
|
||||||
|
Type string
|
||||||
|
Regulator string
|
||||||
|
Owner string
|
||||||
|
DueDate string
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func BoolLabel(v bool) string {
|
func BoolLabel(v bool) string {
|
||||||
|
|||||||
@@ -264,13 +264,15 @@ const (
|
|||||||
ActionFindingDelete = "core:finding:delete"
|
ActionFindingDelete = "core:finding:delete"
|
||||||
ActionFindingAuditMappingCreate = "core:finding:create-audit-mapping"
|
ActionFindingAuditMappingCreate = "core:finding:create-audit-mapping"
|
||||||
ActionFindingAuditMappingDelete = "core:finding:delete-audit-mapping"
|
ActionFindingAuditMappingDelete = "core:finding:delete-audit-mapping"
|
||||||
|
ActionFindingPublish = "core:finding:publish"
|
||||||
|
|
||||||
// Obligation actions
|
// Obligation actions
|
||||||
ActionObligationGet = "core:obligation:get"
|
ActionObligationGet = "core:obligation:get"
|
||||||
ActionObligationList = "core:obligation:list"
|
ActionObligationList = "core:obligation:list"
|
||||||
ActionObligationCreate = "core:obligation:create"
|
ActionObligationCreate = "core:obligation:create"
|
||||||
ActionObligationUpdate = "core:obligation:update"
|
ActionObligationUpdate = "core:obligation:update"
|
||||||
ActionObligationDelete = "core:obligation:delete"
|
ActionObligationDelete = "core:obligation:delete"
|
||||||
|
ActionObligationPublish = "core:obligation:publish"
|
||||||
|
|
||||||
// ProcessingActivity actions
|
// ProcessingActivity actions
|
||||||
ActionProcessingActivityList = "core:processing-activity:list"
|
ActionProcessingActivityList = "core:processing-activity:list"
|
||||||
|
|||||||
@@ -1295,6 +1295,16 @@ func (s *DocumentService) clearDocumentReferences(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
finding := coredata.Finding{}
|
||||||
|
if err := finding.ClearGeneratedDocumentID(ctx, tx, documentIDs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
obligation := coredata.Obligation{}
|
||||||
|
if err := obligation.ClearGeneratedDocumentID(ctx, tx, documentIDs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
soa := coredata.StatementOfApplicability{}
|
soa := coredata.StatementOfApplicability{}
|
||||||
if err := soa.ClearDocumentIDByDocumentIDs(ctx, tx, documentIDs); err != nil {
|
if err := soa.ClearDocumentIDByDocumentIDs(ctx, tx, documentIDs); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -936,3 +936,683 @@ func BuildStatementOfApplicabilityDocument(data docgen.StatementOfApplicabilityD
|
|||||||
}
|
}
|
||||||
return buf.String(), nil
|
return buf.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *GeneratedDocumentService) PublishFindingList(
|
||||||
|
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.buildFindingListDocumentData(ctx, tx, organization)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot build document data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
prosemirrorJSON, err := BuildFindingListDocument(documentData)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot build prosemirror document: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
finding := coredata.Finding{}
|
||||||
|
findingDocumentID, err := finding.GetGeneratedDocumentID(ctx, tx, organizationID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot query generated documents: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingDoc *coredata.Document
|
||||||
|
if findingDocumentID != nil {
|
||||||
|
doc := &coredata.Document{}
|
||||||
|
err = doc.LoadByID(ctx, tx, s.svc.scope, *findingDocumentID)
|
||||||
|
if err != nil && !errors.Is(err, coredata.ErrResourceNotFound) {
|
||||||
|
return fmt.Errorf("cannot load finding list document: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == nil && doc.ArchivedAt == nil {
|
||||||
|
existingDoc = doc
|
||||||
|
} else {
|
||||||
|
if err := finding.ClearGeneratedDocumentID(ctx, tx, []gid.GID{*findingDocumentID}); 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 := finding.UpsertGeneratedDocumentID(ctx, tx, organizationID, s.svc.scope.GetTenantID(), documentID); err != nil {
|
||||||
|
return fmt.Errorf("cannot upsert generated documents: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
document = existingDoc
|
||||||
|
}
|
||||||
|
|
||||||
|
var newMajor int
|
||||||
|
if document.CurrentPublishedMajor != nil {
|
||||||
|
newMajor = *document.CurrentPublishedMajor + 1
|
||||||
|
} else {
|
||||||
|
newMajor = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
versionStatus := coredata.DocumentVersionStatusPublished
|
||||||
|
var publishedAt *time.Time
|
||||||
|
if hasApprovers {
|
||||||
|
versionStatus = coredata.DocumentVersionStatusDraft
|
||||||
|
} else {
|
||||||
|
publishedAt = &now
|
||||||
|
}
|
||||||
|
|
||||||
|
documentVersionID := gid.New(s.svc.scope.GetTenantID(), coredata.DocumentVersionEntityType)
|
||||||
|
documentVersion = &coredata.DocumentVersion{
|
||||||
|
ID: documentVersionID,
|
||||||
|
OrganizationID: organizationID,
|
||||||
|
DocumentID: document.ID,
|
||||||
|
Title: "Finding List",
|
||||||
|
Major: newMajor,
|
||||||
|
Minor: 0,
|
||||||
|
Content: prosemirrorJSON,
|
||||||
|
Status: versionStatus,
|
||||||
|
Classification: coredata.DocumentClassificationConfidential,
|
||||||
|
DocumentType: coredata.DocumentTypeRegister,
|
||||||
|
Orientation: coredata.DocumentVersionOrientationLandscape,
|
||||||
|
PublishedAt: publishedAt,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := documentVersion.Insert(ctx, tx, s.svc.scope); err != nil {
|
||||||
|
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
|
||||||
|
return fmt.Errorf("a version is pending approval, approve or reject it before publishing a new one: %w", err)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("cannot insert document version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasApprovers {
|
||||||
|
defaultApprovers := &coredata.DocumentDefaultApprovers{}
|
||||||
|
if err := defaultApprovers.MergeByDocumentID(ctx, tx, s.svc.scope, document.ID, organizationID, approverIDs); err != nil {
|
||||||
|
return fmt.Errorf("cannot save default approvers: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := s.svc.DocumentApprovals.RequestApprovalInTx(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
document,
|
||||||
|
documentVersion,
|
||||||
|
approverIDs,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot request approval: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
document.CurrentPublishedMajor = &newMajor
|
||||||
|
document.CurrentPublishedMinor = new(0)
|
||||||
|
document.UpdatedAt = now
|
||||||
|
|
||||||
|
if err := document.Update(ctx, tx, s.svc.scope); err != nil {
|
||||||
|
return fmt.Errorf("cannot update document: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return document, documentVersion, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GeneratedDocumentService) GetFindingsDocumentID(
|
||||||
|
ctx context.Context,
|
||||||
|
organizationID gid.GID,
|
||||||
|
) (*gid.GID, error) {
|
||||||
|
var findingDocumentID *gid.GID
|
||||||
|
|
||||||
|
err := s.svc.pg.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
|
||||||
|
finding := coredata.Finding{}
|
||||||
|
var err error
|
||||||
|
findingDocumentID, err = finding.GetGeneratedDocumentID(ctx, conn, organizationID)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot get finding list document ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return findingDocumentID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GeneratedDocumentService) buildFindingListDocumentData(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Querier,
|
||||||
|
organization *coredata.Organization,
|
||||||
|
) (docgen.FindingListData, error) {
|
||||||
|
var findings coredata.Findings
|
||||||
|
if err := findings.LoadAllByOrganizationID(ctx, conn, s.svc.scope, organization.ID); err != nil {
|
||||||
|
return docgen.FindingListData{}, fmt.Errorf("cannot load findings: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(findings) == 0 {
|
||||||
|
return docgen.FindingListData{
|
||||||
|
Title: "Finding List",
|
||||||
|
OrganizationName: organization.Name,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
TotalFindings: 0,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ownerIDs := make([]gid.GID, 0, len(findings))
|
||||||
|
ownerIDSet := make(map[gid.GID]struct{})
|
||||||
|
for _, f := range findings {
|
||||||
|
if f.OwnerID != nil {
|
||||||
|
if _, ok := ownerIDSet[*f.OwnerID]; !ok {
|
||||||
|
ownerIDs = append(ownerIDs, *f.OwnerID)
|
||||||
|
ownerIDSet[*f.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.FindingListData{}, fmt.Errorf("cannot load profiles: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range profiles {
|
||||||
|
profileMap[p.ID] = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := make([]docgen.FindingListRow, 0, len(findings))
|
||||||
|
for _, f := range findings {
|
||||||
|
ownerName := "-"
|
||||||
|
if f.OwnerID != nil {
|
||||||
|
if p, ok := profileMap[*f.OwnerID]; ok {
|
||||||
|
ownerName = p.FullName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
description := "-"
|
||||||
|
if f.Description != nil && *f.Description != "" {
|
||||||
|
description = *f.Description
|
||||||
|
}
|
||||||
|
|
||||||
|
source := "-"
|
||||||
|
if f.Source != nil && *f.Source != "" {
|
||||||
|
source = *f.Source
|
||||||
|
}
|
||||||
|
|
||||||
|
identifiedOn := "-"
|
||||||
|
if f.IdentifiedOn != nil {
|
||||||
|
identifiedOn = f.IdentifiedOn.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
|
rootCause := "-"
|
||||||
|
if f.RootCause != nil && *f.RootCause != "" {
|
||||||
|
rootCause = *f.RootCause
|
||||||
|
}
|
||||||
|
|
||||||
|
correctiveAction := "-"
|
||||||
|
if f.CorrectiveAction != nil && *f.CorrectiveAction != "" {
|
||||||
|
correctiveAction = *f.CorrectiveAction
|
||||||
|
}
|
||||||
|
|
||||||
|
effectivenessCheck := "-"
|
||||||
|
if f.EffectivenessCheck != nil && *f.EffectivenessCheck != "" {
|
||||||
|
effectivenessCheck = *f.EffectivenessCheck
|
||||||
|
}
|
||||||
|
|
||||||
|
dueDate := "-"
|
||||||
|
if f.DueDate != nil {
|
||||||
|
dueDate = f.DueDate.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
|
rows = append(rows, docgen.FindingListRow{
|
||||||
|
ReferenceID: f.ReferenceID,
|
||||||
|
Kind: formatFindingKind(f.Kind),
|
||||||
|
Description: description,
|
||||||
|
Source: source,
|
||||||
|
IdentifiedOn: identifiedOn,
|
||||||
|
RootCause: rootCause,
|
||||||
|
CorrectiveAction: correctiveAction,
|
||||||
|
EffectivenessCheck: effectivenessCheck,
|
||||||
|
Status: formatFindingStatus(f.Status),
|
||||||
|
Priority: formatFindingPriority(f.Priority),
|
||||||
|
Owner: ownerName,
|
||||||
|
DueDate: dueDate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return docgen.FindingListData{
|
||||||
|
Title: "Finding List",
|
||||||
|
OrganizationName: organization.Name,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
TotalFindings: len(findings),
|
||||||
|
Rows: rows,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatFindingKind(k coredata.FindingKind) string {
|
||||||
|
switch k {
|
||||||
|
case coredata.FindingKindMinorNonconformity:
|
||||||
|
return "Minor Nonconformity"
|
||||||
|
case coredata.FindingKindMajorNonconformity:
|
||||||
|
return "Major Nonconformity"
|
||||||
|
case coredata.FindingKindObservation:
|
||||||
|
return "Observation"
|
||||||
|
case coredata.FindingKindException:
|
||||||
|
return "Exception"
|
||||||
|
default:
|
||||||
|
return string(k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatFindingStatus(s coredata.FindingStatus) string {
|
||||||
|
switch s {
|
||||||
|
case coredata.FindingStatusOpen:
|
||||||
|
return "Open"
|
||||||
|
case coredata.FindingStatusInProgress:
|
||||||
|
return "In Progress"
|
||||||
|
case coredata.FindingStatusClosed:
|
||||||
|
return "Closed"
|
||||||
|
case coredata.FindingStatusRiskAccepted:
|
||||||
|
return "Risk Accepted"
|
||||||
|
case coredata.FindingStatusMitigated:
|
||||||
|
return "Mitigated"
|
||||||
|
case coredata.FindingStatusFalsePositive:
|
||||||
|
return "False Positive"
|
||||||
|
default:
|
||||||
|
return string(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatFindingPriority(p coredata.FindingPriority) string {
|
||||||
|
switch p {
|
||||||
|
case coredata.FindingPriorityLow:
|
||||||
|
return "Low"
|
||||||
|
case coredata.FindingPriorityMedium:
|
||||||
|
return "Medium"
|
||||||
|
case coredata.FindingPriorityHigh:
|
||||||
|
return "High"
|
||||||
|
default:
|
||||||
|
return string(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var findingListTemplate = template.Must(
|
||||||
|
template.New("finding_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
|
||||||
|
},
|
||||||
|
}).
|
||||||
|
ParseFS(Templates, "templates/finding_list.json.tmpl"),
|
||||||
|
)
|
||||||
|
|
||||||
|
func BuildFindingListDocument(data docgen.FindingListData) (string, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := findingListTemplate.Execute(&buf, data); err != nil {
|
||||||
|
return "", fmt.Errorf("cannot execute finding list template: %w", err)
|
||||||
|
}
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GeneratedDocumentService) PublishObligationList(
|
||||||
|
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.buildObligationListDocumentData(ctx, tx, organization)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot build document data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
prosemirrorJSON, err := BuildObligationListDocument(documentData)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot build prosemirror document: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
obligation := coredata.Obligation{}
|
||||||
|
obligationDocumentID, err := obligation.GetGeneratedDocumentID(ctx, tx, organizationID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot query generated documents: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingDoc *coredata.Document
|
||||||
|
if obligationDocumentID != nil {
|
||||||
|
doc := &coredata.Document{}
|
||||||
|
err = doc.LoadByID(ctx, tx, s.svc.scope, *obligationDocumentID)
|
||||||
|
if err != nil && !errors.Is(err, coredata.ErrResourceNotFound) {
|
||||||
|
return fmt.Errorf("cannot load obligation list document: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == nil && doc.ArchivedAt == nil {
|
||||||
|
existingDoc = doc
|
||||||
|
} else {
|
||||||
|
if err := obligation.ClearGeneratedDocumentID(ctx, tx, []gid.GID{*obligationDocumentID}); 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 := obligation.UpsertGeneratedDocumentID(ctx, tx, organizationID, s.svc.scope.GetTenantID(), documentID); err != nil {
|
||||||
|
return fmt.Errorf("cannot upsert generated documents: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
document = existingDoc
|
||||||
|
}
|
||||||
|
|
||||||
|
var newMajor int
|
||||||
|
if document.CurrentPublishedMajor != nil {
|
||||||
|
newMajor = *document.CurrentPublishedMajor + 1
|
||||||
|
} else {
|
||||||
|
newMajor = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
versionStatus := coredata.DocumentVersionStatusPublished
|
||||||
|
var publishedAt *time.Time
|
||||||
|
if hasApprovers {
|
||||||
|
versionStatus = coredata.DocumentVersionStatusDraft
|
||||||
|
} else {
|
||||||
|
publishedAt = &now
|
||||||
|
}
|
||||||
|
|
||||||
|
documentVersionID := gid.New(s.svc.scope.GetTenantID(), coredata.DocumentVersionEntityType)
|
||||||
|
documentVersion = &coredata.DocumentVersion{
|
||||||
|
ID: documentVersionID,
|
||||||
|
OrganizationID: organizationID,
|
||||||
|
DocumentID: document.ID,
|
||||||
|
Title: "Obligation List",
|
||||||
|
Major: newMajor,
|
||||||
|
Minor: 0,
|
||||||
|
Content: prosemirrorJSON,
|
||||||
|
Status: versionStatus,
|
||||||
|
Classification: coredata.DocumentClassificationConfidential,
|
||||||
|
DocumentType: coredata.DocumentTypeRegister,
|
||||||
|
Orientation: coredata.DocumentVersionOrientationLandscape,
|
||||||
|
PublishedAt: publishedAt,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := documentVersion.Insert(ctx, tx, s.svc.scope); err != nil {
|
||||||
|
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
|
||||||
|
return fmt.Errorf("a version is pending approval, approve or reject it before publishing a new one: %w", err)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("cannot insert document version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasApprovers {
|
||||||
|
defaultApprovers := &coredata.DocumentDefaultApprovers{}
|
||||||
|
if err := defaultApprovers.MergeByDocumentID(ctx, tx, s.svc.scope, document.ID, organizationID, approverIDs); err != nil {
|
||||||
|
return fmt.Errorf("cannot save default approvers: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := s.svc.DocumentApprovals.RequestApprovalInTx(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
document,
|
||||||
|
documentVersion,
|
||||||
|
approverIDs,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot request approval: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
document.CurrentPublishedMajor = &newMajor
|
||||||
|
document.CurrentPublishedMinor = new(0)
|
||||||
|
document.UpdatedAt = now
|
||||||
|
|
||||||
|
if err := document.Update(ctx, tx, s.svc.scope); err != nil {
|
||||||
|
return fmt.Errorf("cannot update document: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return document, documentVersion, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GeneratedDocumentService) GetObligationsDocumentID(
|
||||||
|
ctx context.Context,
|
||||||
|
organizationID gid.GID,
|
||||||
|
) (*gid.GID, error) {
|
||||||
|
var obligationDocumentID *gid.GID
|
||||||
|
|
||||||
|
err := s.svc.pg.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
|
||||||
|
obligation := coredata.Obligation{}
|
||||||
|
var err error
|
||||||
|
obligationDocumentID, err = obligation.GetGeneratedDocumentID(ctx, conn, organizationID)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot get obligation list document ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return obligationDocumentID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GeneratedDocumentService) buildObligationListDocumentData(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Querier,
|
||||||
|
organization *coredata.Organization,
|
||||||
|
) (docgen.ObligationListData, error) {
|
||||||
|
var obligations coredata.Obligations
|
||||||
|
if err := obligations.LoadAllByOrganizationID(ctx, conn, s.svc.scope, organization.ID); err != nil {
|
||||||
|
return docgen.ObligationListData{}, fmt.Errorf("cannot load obligations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(obligations) == 0 {
|
||||||
|
return docgen.ObligationListData{
|
||||||
|
Title: "Obligation List",
|
||||||
|
OrganizationName: organization.Name,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
TotalObligations: 0,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ownerIDs := make([]gid.GID, 0, len(obligations))
|
||||||
|
ownerIDSet := make(map[gid.GID]struct{})
|
||||||
|
for _, o := range obligations {
|
||||||
|
if o.OwnerID == gid.Nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := ownerIDSet[o.OwnerID]; !ok {
|
||||||
|
ownerIDs = append(ownerIDs, o.OwnerID)
|
||||||
|
ownerIDSet[o.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.ObligationListData{}, fmt.Errorf("cannot load profiles: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range profiles {
|
||||||
|
profileMap[p.ID] = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := make([]docgen.ObligationListRow, 0, len(obligations))
|
||||||
|
for _, o := range obligations {
|
||||||
|
ownerName := "-"
|
||||||
|
if p, ok := profileMap[o.OwnerID]; ok {
|
||||||
|
ownerName = p.FullName
|
||||||
|
}
|
||||||
|
|
||||||
|
area := "-"
|
||||||
|
if o.Area != nil && *o.Area != "" {
|
||||||
|
area = *o.Area
|
||||||
|
}
|
||||||
|
|
||||||
|
source := "-"
|
||||||
|
if o.Source != nil && *o.Source != "" {
|
||||||
|
source = *o.Source
|
||||||
|
}
|
||||||
|
|
||||||
|
requirement := "-"
|
||||||
|
if o.Requirement != nil && *o.Requirement != "" {
|
||||||
|
requirement = *o.Requirement
|
||||||
|
}
|
||||||
|
|
||||||
|
actionsToBeImplemented := "-"
|
||||||
|
if o.ActionsToBeImplemented != nil && *o.ActionsToBeImplemented != "" {
|
||||||
|
actionsToBeImplemented = *o.ActionsToBeImplemented
|
||||||
|
}
|
||||||
|
|
||||||
|
regulator := "-"
|
||||||
|
if o.Regulator != nil && *o.Regulator != "" {
|
||||||
|
regulator = *o.Regulator
|
||||||
|
}
|
||||||
|
|
||||||
|
dueDate := "-"
|
||||||
|
if o.DueDate != nil {
|
||||||
|
dueDate = o.DueDate.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
|
rows = append(rows, docgen.ObligationListRow{
|
||||||
|
Area: area,
|
||||||
|
Source: source,
|
||||||
|
Requirement: requirement,
|
||||||
|
ActionsToBeImplemented: actionsToBeImplemented,
|
||||||
|
Status: formatObligationStatus(o.Status),
|
||||||
|
Type: formatObligationType(o.Type),
|
||||||
|
Regulator: regulator,
|
||||||
|
Owner: ownerName,
|
||||||
|
DueDate: dueDate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return docgen.ObligationListData{
|
||||||
|
Title: "Obligation List",
|
||||||
|
OrganizationName: organization.Name,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
TotalObligations: len(obligations),
|
||||||
|
Rows: rows,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatObligationStatus(s coredata.ObligationStatus) string {
|
||||||
|
switch s {
|
||||||
|
case coredata.ObligationStatusNonCompliant:
|
||||||
|
return "Non Compliant"
|
||||||
|
case coredata.ObligationStatusPartiallyCompliant:
|
||||||
|
return "Partially Compliant"
|
||||||
|
case coredata.ObligationStatusCompliant:
|
||||||
|
return "Compliant"
|
||||||
|
default:
|
||||||
|
return string(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatObligationType(t coredata.ObligationType) string {
|
||||||
|
switch t {
|
||||||
|
case coredata.ObligationTypeLegal:
|
||||||
|
return "Legal"
|
||||||
|
case coredata.ObligationTypeContractual:
|
||||||
|
return "Contractual"
|
||||||
|
default:
|
||||||
|
return string(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var obligationListTemplate = template.Must(
|
||||||
|
template.New("obligation_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
|
||||||
|
},
|
||||||
|
}).
|
||||||
|
ParseFS(Templates, "templates/obligation_list.json.tmpl"),
|
||||||
|
)
|
||||||
|
|
||||||
|
func BuildObligationListDocument(data docgen.ObligationListData) (string, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := obligationListTemplate.Execute(&buf, data); err != nil {
|
||||||
|
return "", fmt.Errorf("cannot execute obligation list template: %w", err)
|
||||||
|
}
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -289,7 +289,6 @@ func (s *ObligationService) Delete(
|
|||||||
func (s ObligationService) CountForOrganizationID(
|
func (s ObligationService) CountForOrganizationID(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
organizationID gid.GID,
|
organizationID gid.GID,
|
||||||
filter *coredata.ObligationFilter,
|
|
||||||
) (int, error) {
|
) (int, error) {
|
||||||
var count int
|
var count int
|
||||||
|
|
||||||
@@ -297,7 +296,7 @@ func (s ObligationService) CountForOrganizationID(
|
|||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Querier) (err error) {
|
func(ctx context.Context, conn pg.Querier) (err error) {
|
||||||
obligations := coredata.Obligations{}
|
obligations := coredata.Obligations{}
|
||||||
count, err = obligations.CountByOrganizationID(ctx, conn, s.svc.scope, organizationID, filter)
|
count, err = obligations.CountByOrganizationID(ctx, conn, s.svc.scope, organizationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot count obligations: %w", err)
|
return fmt.Errorf("cannot count obligations: %w", err)
|
||||||
}
|
}
|
||||||
@@ -317,7 +316,6 @@ func (s ObligationService) ListForControlID(
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
controlID gid.GID,
|
controlID gid.GID,
|
||||||
cursor *page.Cursor[coredata.ObligationOrderField],
|
cursor *page.Cursor[coredata.ObligationOrderField],
|
||||||
filter *coredata.ObligationFilter,
|
|
||||||
) (*page.Page[*coredata.Obligation, coredata.ObligationOrderField], error) {
|
) (*page.Page[*coredata.Obligation, coredata.ObligationOrderField], error) {
|
||||||
var obligations coredata.Obligations
|
var obligations coredata.Obligations
|
||||||
control := &coredata.Control{}
|
control := &coredata.Control{}
|
||||||
@@ -329,7 +327,7 @@ func (s ObligationService) ListForControlID(
|
|||||||
return fmt.Errorf("cannot load control: %w", err)
|
return fmt.Errorf("cannot load control: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err := obligations.LoadByControlID(ctx, conn, s.svc.scope, control.ID, cursor, filter)
|
err := obligations.LoadByControlID(ctx, conn, s.svc.scope, control.ID, cursor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot load obligations: %w", err)
|
return fmt.Errorf("cannot load obligations: %w", err)
|
||||||
}
|
}
|
||||||
@@ -349,14 +347,13 @@ func (s ObligationService) ListForOrganizationID(
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
organizationID gid.GID,
|
organizationID gid.GID,
|
||||||
cursor *page.Cursor[coredata.ObligationOrderField],
|
cursor *page.Cursor[coredata.ObligationOrderField],
|
||||||
filter *coredata.ObligationFilter,
|
|
||||||
) (*page.Page[*coredata.Obligation, coredata.ObligationOrderField], error) {
|
) (*page.Page[*coredata.Obligation, coredata.ObligationOrderField], error) {
|
||||||
var obligations coredata.Obligations
|
var obligations coredata.Obligations
|
||||||
|
|
||||||
err := s.svc.pg.WithConn(
|
err := s.svc.pg.WithConn(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Querier) error {
|
func(ctx context.Context, conn pg.Querier) error {
|
||||||
err := obligations.LoadByOrganizationID(ctx, conn, s.svc.scope, organizationID, cursor, filter)
|
err := obligations.LoadByOrganizationID(ctx, conn, s.svc.scope, organizationID, cursor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot load obligations: %w", err)
|
return fmt.Errorf("cannot load obligations: %w", err)
|
||||||
}
|
}
|
||||||
@@ -375,7 +372,6 @@ func (s ObligationService) ListForOrganizationID(
|
|||||||
func (s ObligationService) CountForRiskID(
|
func (s ObligationService) CountForRiskID(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
riskID gid.GID,
|
riskID gid.GID,
|
||||||
filter *coredata.ObligationFilter,
|
|
||||||
) (int, error) {
|
) (int, error) {
|
||||||
var count int
|
var count int
|
||||||
|
|
||||||
@@ -383,7 +379,7 @@ func (s ObligationService) CountForRiskID(
|
|||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Querier) (err error) {
|
func(ctx context.Context, conn pg.Querier) (err error) {
|
||||||
obligations := &coredata.Obligations{}
|
obligations := &coredata.Obligations{}
|
||||||
count, err = obligations.CountByRiskID(ctx, conn, s.svc.scope, riskID, filter)
|
count, err = obligations.CountByRiskID(ctx, conn, s.svc.scope, riskID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot count obligations: %w", err)
|
return fmt.Errorf("cannot count obligations: %w", err)
|
||||||
}
|
}
|
||||||
@@ -403,14 +399,13 @@ func (s ObligationService) ListForRiskID(
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
riskID gid.GID,
|
riskID gid.GID,
|
||||||
cursor *page.Cursor[coredata.ObligationOrderField],
|
cursor *page.Cursor[coredata.ObligationOrderField],
|
||||||
filter *coredata.ObligationFilter,
|
|
||||||
) (*page.Page[*coredata.Obligation, coredata.ObligationOrderField], error) {
|
) (*page.Page[*coredata.Obligation, coredata.ObligationOrderField], error) {
|
||||||
var obligations coredata.Obligations
|
var obligations coredata.Obligations
|
||||||
|
|
||||||
err := s.svc.pg.WithConn(
|
err := s.svc.pg.WithConn(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Querier) error {
|
func(ctx context.Context, conn pg.Querier) error {
|
||||||
err := obligations.LoadByRiskID(ctx, conn, s.svc.scope, riskID, cursor, filter)
|
err := obligations.LoadByRiskID(ctx, conn, s.svc.scope, riskID, cursor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot load obligations: %w", err)
|
return fmt.Errorf("cannot load obligations: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,10 +171,6 @@ var AuditorPolicy = policy.NewPolicy(
|
|||||||
ActionEmployeeDocumentGet, ActionEmployeeDocumentList,
|
ActionEmployeeDocumentGet, ActionEmployeeDocumentList,
|
||||||
ActionEmployeeDocumentVersionExportPDF,
|
ActionEmployeeDocumentVersionExportPDF,
|
||||||
).WithSID("employee-document-access").When(organizationCondition),
|
).WithSID("employee-document-access").When(organizationCondition),
|
||||||
|
|
||||||
policy.Allow(
|
|
||||||
ActionStatementOfApplicabilityPublish,
|
|
||||||
).WithSID("soa-publish").When(organizationCondition),
|
|
||||||
).WithDescription("Read-only probo access for auditors (excludes internal/employee content)")
|
).WithDescription("Read-only probo access for auditors (excludes internal/employee content)")
|
||||||
|
|
||||||
// EmployeePolicy defines permissions for employee role.
|
// EmployeePolicy defines permissions for employee role.
|
||||||
|
|||||||
101
pkg/probo/templates/finding_list.json.tmpl
Normal file
101
pkg/probo/templates/finding_list.json.tmpl
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
{
|
||||||
|
"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 list of findings identified within the organization. It serves as a record of all findings, their classification, status, ownership, and remediation details." }]
|
||||||
|
},
|
||||||
|
{ "type": "horizontalRule" },
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"attrs": { "level": 1 },
|
||||||
|
"content": [{ "type": "text", "text": "2. Finding List" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "table",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "tableRow",
|
||||||
|
"content": [
|
||||||
|
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [70] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Reference", "marks": [{ "type": "bold" }] }] }] },
|
||||||
|
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [90] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Kind", "marks": [{ "type": "bold" }] }] }] },
|
||||||
|
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [130] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Description", "marks": [{ "type": "bold" }] }] }] },
|
||||||
|
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [80] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Source", "marks": [{ "type": "bold" }] }] }] },
|
||||||
|
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [75] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Identified On", "marks": [{ "type": "bold" }] }] }] },
|
||||||
|
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [110] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Root Cause", "marks": [{ "type": "bold" }] }] }] },
|
||||||
|
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [110] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Corrective Action", "marks": [{ "type": "bold" }] }] }] },
|
||||||
|
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [110] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Effectiveness Check", "marks": [{ "type": "bold" }] }] }] },
|
||||||
|
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [80] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Status", "marks": [{ "type": "bold" }] }] }] },
|
||||||
|
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [60] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Priority", "marks": [{ "type": "bold" }] }] }] },
|
||||||
|
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [90] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Owner", "marks": [{ "type": "bold" }] }] }] },
|
||||||
|
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [75] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Due Date", "marks": [{ "type": "bold" }] }] }] }
|
||||||
|
]
|
||||||
|
}{{range .Rows}},
|
||||||
|
{
|
||||||
|
"type": "tableRow",
|
||||||
|
"content": [
|
||||||
|
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [70] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .ReferenceID}} }] }] },
|
||||||
|
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [90] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .Kind}} }] }] },
|
||||||
|
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [130] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .Description}} }] }] },
|
||||||
|
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [80] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .Source}} }] }] },
|
||||||
|
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [75] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .IdentifiedOn}} }] }] },
|
||||||
|
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [110] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .RootCause}} }] }] },
|
||||||
|
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [110] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .CorrectiveAction}} }] }] },
|
||||||
|
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [110] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .EffectivenessCheck}} }] }] },
|
||||||
|
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [80] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .Status}} }] }] },
|
||||||
|
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [60] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .Priority}} }] }] },
|
||||||
|
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [90] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .Owner}} }] }] },
|
||||||
|
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [75] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .DueDate}} }] }] }
|
||||||
|
]
|
||||||
|
}{{end}}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ "type": "horizontalRule" },
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"attrs": { "level": 1 },
|
||||||
|
"content": [{ "type": "text", "text": "3. Definitions" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"attrs": { "level": 3 },
|
||||||
|
"content": [{ "type": "text", "text": "Kind" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bulletList",
|
||||||
|
"content": [
|
||||||
|
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Minor Nonconformity: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "A nonconformity that does not significantly affect the management system's ability to achieve its intended outcomes." }] }] },
|
||||||
|
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Major Nonconformity: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "A nonconformity that significantly affects the management system's ability to achieve its intended outcomes." }] }] },
|
||||||
|
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Observation: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "A noted issue that does not constitute a nonconformity but may warrant attention." }] }] },
|
||||||
|
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Exception: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "A deviation from a requirement that has been formally approved." }] }] }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"attrs": { "level": 3 },
|
||||||
|
"content": [{ "type": "text", "text": "Priority" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bulletList",
|
||||||
|
"content": [
|
||||||
|
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Low: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Finding has minimal impact and can be addressed in the normal course of operations." }] }] },
|
||||||
|
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Medium: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Finding has moderate impact and should be addressed in a timely manner." }] }] },
|
||||||
|
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "High: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Finding has significant impact and requires urgent attention." }] }] }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"attrs": { "level": 3 },
|
||||||
|
"content": [{ "type": "text", "text": "Owner" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "paragraph",
|
||||||
|
"content": [{ "type": "text", "text": "The individual responsible for addressing the finding, including implementing corrective actions and tracking remediation progress." }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
93
pkg/probo/templates/obligation_list.json.tmpl
Normal file
93
pkg/probo/templates/obligation_list.json.tmpl
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
{
|
||||||
|
"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 list of obligations managed by the organization. It serves as a record of all legal and contractual obligations, their compliance status, ownership, and regulatory details." }]
|
||||||
|
},
|
||||||
|
{ "type": "horizontalRule" },
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"attrs": { "level": 1 },
|
||||||
|
"content": [{ "type": "text", "text": "2. Obligation List" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "table",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "tableRow",
|
||||||
|
"content": [
|
||||||
|
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [80] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Area", "marks": [{ "type": "bold" }] }] }] },
|
||||||
|
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [80] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Source", "marks": [{ "type": "bold" }] }] }] },
|
||||||
|
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [130] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Requirement", "marks": [{ "type": "bold" }] }] }] },
|
||||||
|
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [130] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Actions to be Implemented", "marks": [{ "type": "bold" }] }] }] },
|
||||||
|
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [70] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Type", "marks": [{ "type": "bold" }] }] }] },
|
||||||
|
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [80] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Status", "marks": [{ "type": "bold" }] }] }] },
|
||||||
|
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [70] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Regulator", "marks": [{ "type": "bold" }] }] }] },
|
||||||
|
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [90] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Owner", "marks": [{ "type": "bold" }] }] }] },
|
||||||
|
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [80] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Due Date", "marks": [{ "type": "bold" }] }] }] }
|
||||||
|
]
|
||||||
|
}{{range .Rows}},
|
||||||
|
{
|
||||||
|
"type": "tableRow",
|
||||||
|
"content": [
|
||||||
|
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [80] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .Area}} }] }] },
|
||||||
|
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [80] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .Source}} }] }] },
|
||||||
|
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [130] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .Requirement}} }] }] },
|
||||||
|
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [130] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .ActionsToBeImplemented}} }] }] },
|
||||||
|
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [70] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .Type}} }] }] },
|
||||||
|
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [80] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .Status}} }] }] },
|
||||||
|
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [70] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .Regulator}} }] }] },
|
||||||
|
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [90] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .Owner}} }] }] },
|
||||||
|
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [80] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .DueDate}} }] }] }
|
||||||
|
]
|
||||||
|
}{{end}}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ "type": "horizontalRule" },
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"attrs": { "level": 1 },
|
||||||
|
"content": [{ "type": "text", "text": "3. Definitions" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"attrs": { "level": 3 },
|
||||||
|
"content": [{ "type": "text", "text": "Type" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bulletList",
|
||||||
|
"content": [
|
||||||
|
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Legal: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Obligations arising from laws, regulations, and statutory requirements applicable to the organization." }] }] },
|
||||||
|
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Contractual: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Obligations arising from contracts, agreements, and other binding commitments with third parties." }] }] }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"attrs": { "level": 3 },
|
||||||
|
"content": [{ "type": "text", "text": "Status" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bulletList",
|
||||||
|
"content": [
|
||||||
|
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Non Compliant: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The organization does not meet the obligation requirements." }] }] },
|
||||||
|
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Partially Compliant: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The organization partially meets the obligation requirements but gaps remain." }] }] },
|
||||||
|
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Compliant: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The organization fully meets the obligation requirements." }] }] }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"attrs": { "level": 3 },
|
||||||
|
"content": [{ "type": "text", "text": "Owner" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "paragraph",
|
||||||
|
"content": [{ "type": "text", "text": "The individual responsible for ensuring the obligation is met, including monitoring compliance and coordinating necessary actions." }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -181,10 +181,7 @@ func (r *auditResolver) Findings(ctx context.Context, obj *types.Audit, first *i
|
|||||||
ownerID = filter.OwnerID
|
ownerID = filter.OwnerID
|
||||||
}
|
}
|
||||||
|
|
||||||
findingFilter := coredata.NewFindingFilter(nil, kind, status, priority, ownerID)
|
findingFilter := coredata.NewFindingFilter(kind, status, priority, ownerID)
|
||||||
if filter != nil {
|
|
||||||
findingFilter = coredata.NewFindingFilter(&filter.SnapshotID, kind, status, priority, ownerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
p, err := prb.Findings.ListForAuditID(ctx, obj.ID, cursor, findingFilter)
|
p, err := prb.Findings.ListForAuditID(ctx, obj.ID, cursor, findingFilter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -363,10 +360,7 @@ func (r *findingConnectionResolver) TotalCount(ctx context.Context, obj *types.F
|
|||||||
ownerID = obj.Filter.OwnerID
|
ownerID = obj.Filter.OwnerID
|
||||||
}
|
}
|
||||||
|
|
||||||
findingFilter := coredata.NewFindingFilter(nil, kind, status, priority, ownerID)
|
findingFilter := coredata.NewFindingFilter(kind, status, priority, ownerID)
|
||||||
if obj.Filter != nil {
|
|
||||||
findingFilter = coredata.NewFindingFilter(&obj.Filter.SnapshotID, kind, status, priority, ownerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch obj.Resolver.(type) {
|
switch obj.Resolver.(type) {
|
||||||
case *organizationResolver:
|
case *organizationResolver:
|
||||||
@@ -677,6 +671,29 @@ func (r *mutationResolver) DeleteFindingAuditMapping(ctx context.Context, input
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PublishFindingList is the resolver for the publishFindingList field.
|
||||||
|
func (r *mutationResolver) PublishFindingList(ctx context.Context, input types.PublishFindingListInput) (*types.PublishFindingListPayload, error) {
|
||||||
|
if err := r.authorize(ctx, input.OrganizationID, probo.ActionFindingPublish); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
prb := r.ProboService(ctx, input.OrganizationID.TenantID())
|
||||||
|
|
||||||
|
document, documentVersion, err := prb.GeneratedDocuments.PublishFindingList(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 finding list", log.Error(err))
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.PublishFindingListPayload{
|
||||||
|
DocumentEdge: types.NewDocumentEdge(document, coredata.DocumentOrderFieldCreatedAt),
|
||||||
|
DocumentVersionEdge: types.NewDocumentVersionEdge(documentVersion, coredata.DocumentVersionOrderFieldCreatedAt),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// DownloadURL is the resolver for the downloadUrl field.
|
// DownloadURL is the resolver for the downloadUrl field.
|
||||||
func (r *reportResolver) DownloadURL(ctx context.Context, obj *types.Report) (*string, error) {
|
func (r *reportResolver) DownloadURL(ctx context.Context, obj *types.Report) (*string, error) {
|
||||||
if err := r.authorize(ctx, obj.ID, probo.ActionReportDownloadUrlGet); err != nil {
|
if err := r.authorize(ctx, obj.ID, probo.ActionReportDownloadUrlGet); err != nil {
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
"github.com/vikstrous/dataloadgen"
|
"github.com/vikstrous/dataloadgen"
|
||||||
"go.gearno.de/kit/log"
|
"go.gearno.de/kit/log"
|
||||||
"go.probo.inc/probo/pkg/coredata"
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
"go.probo.inc/probo/pkg/gid"
|
|
||||||
"go.probo.inc/probo/pkg/page"
|
"go.probo.inc/probo/pkg/page"
|
||||||
"go.probo.inc/probo/pkg/probo"
|
"go.probo.inc/probo/pkg/probo"
|
||||||
"go.probo.inc/probo/pkg/server/api/console/v1/dataloader"
|
"go.probo.inc/probo/pkg/server/api/console/v1/dataloader"
|
||||||
@@ -272,7 +271,7 @@ func (r *controlResolver) Audits(ctx context.Context, obj *types.Control, first
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Obligations is the resolver for the obligations field.
|
// Obligations is the resolver for the obligations field.
|
||||||
func (r *controlResolver) Obligations(ctx context.Context, obj *types.Control, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ObligationOrderBy, filter *types.ObligationFilter) (*types.ObligationConnection, error) {
|
func (r *controlResolver) Obligations(ctx context.Context, obj *types.Control, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ObligationOrderBy) (*types.ObligationConnection, error) {
|
||||||
if err := r.authorize(ctx, obj.ID, probo.ActionObligationList); err != nil {
|
if err := r.authorize(ctx, obj.ID, probo.ActionObligationList); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -292,18 +291,13 @@ func (r *controlResolver) Obligations(ctx context.Context, obj *types.Control, f
|
|||||||
|
|
||||||
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
||||||
|
|
||||||
var snapshotID **gid.GID
|
page, err := prb.Obligations.ListForControlID(ctx, obj.ID, cursor)
|
||||||
if filter != nil {
|
|
||||||
snapshotID = &filter.SnapshotID
|
|
||||||
}
|
|
||||||
obligationFilter := coredata.NewObligationFilter(snapshotID)
|
|
||||||
page, err := prb.Obligations.ListForControlID(ctx, obj.ID, cursor, obligationFilter)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.logger.ErrorCtx(ctx, "cannot list control obligations", log.Error(err))
|
r.logger.ErrorCtx(ctx, "cannot list control obligations", log.Error(err))
|
||||||
return nil, gqlutils.Internal(ctx)
|
return nil, gqlutils.Internal(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
return types.NewObligationConnection(page, r, obj.ID, filter), nil
|
return types.NewObligationConnection(page, r, obj.ID), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snapshots is the resolver for the snapshots field.
|
// Snapshots is the resolver for the snapshots field.
|
||||||
|
|||||||
@@ -138,7 +138,6 @@ input FindingOrder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
input FindingFilter {
|
input FindingFilter {
|
||||||
snapshotId: ID
|
|
||||||
kind: FindingKind
|
kind: FindingKind
|
||||||
status: FindingStatus
|
status: FindingStatus
|
||||||
priority: FindingPriority
|
priority: FindingPriority
|
||||||
@@ -171,7 +170,7 @@ type Audit implements Node {
|
|||||||
last: Int
|
last: Int
|
||||||
before: CursorKey
|
before: CursorKey
|
||||||
orderBy: FindingOrder
|
orderBy: FindingOrder
|
||||||
filter: FindingFilter = { snapshotId: null }
|
filter: FindingFilter
|
||||||
): FindingConnection @goField(forceResolver: true)
|
): FindingConnection @goField(forceResolver: true)
|
||||||
|
|
||||||
trustCenterVisibility: TrustCenterVisibility!
|
trustCenterVisibility: TrustCenterVisibility!
|
||||||
@@ -183,7 +182,6 @@ type Audit implements Node {
|
|||||||
|
|
||||||
type Finding implements Node {
|
type Finding implements Node {
|
||||||
id: ID!
|
id: ID!
|
||||||
snapshotId: ID
|
|
||||||
organization: Organization @goField(forceResolver: true)
|
organization: Organization @goField(forceResolver: true)
|
||||||
kind: FindingKind!
|
kind: FindingKind!
|
||||||
referenceId: String!
|
referenceId: String!
|
||||||
@@ -270,6 +268,19 @@ extend type Mutation {
|
|||||||
deleteFindingAuditMapping(
|
deleteFindingAuditMapping(
|
||||||
input: DeleteFindingAuditMappingInput!
|
input: DeleteFindingAuditMappingInput!
|
||||||
): DeleteFindingAuditMappingPayload
|
): DeleteFindingAuditMappingPayload
|
||||||
|
publishFindingList(
|
||||||
|
input: PublishFindingListInput!
|
||||||
|
): PublishFindingListPayload!
|
||||||
|
}
|
||||||
|
|
||||||
|
input PublishFindingListInput {
|
||||||
|
organizationId: ID!
|
||||||
|
approverIds: [ID!]
|
||||||
|
}
|
||||||
|
|
||||||
|
type PublishFindingListPayload {
|
||||||
|
documentEdge: DocumentEdge!
|
||||||
|
documentVersionEdge: DocumentVersionEdge!
|
||||||
}
|
}
|
||||||
|
|
||||||
input CreateAuditInput {
|
input CreateAuditInput {
|
||||||
|
|||||||
@@ -139,7 +139,6 @@ type Control implements Node {
|
|||||||
last: Int
|
last: Int
|
||||||
before: CursorKey
|
before: CursorKey
|
||||||
orderBy: ObligationOrder
|
orderBy: ObligationOrder
|
||||||
filter: ObligationFilter
|
|
||||||
): ObligationConnection! @goField(forceResolver: true)
|
): ObligationConnection! @goField(forceResolver: true)
|
||||||
|
|
||||||
snapshots(
|
snapshots(
|
||||||
|
|||||||
@@ -51,14 +51,8 @@ input ObligationOrder
|
|||||||
field: ObligationOrderField!
|
field: ObligationOrderField!
|
||||||
}
|
}
|
||||||
|
|
||||||
input ObligationFilter {
|
|
||||||
snapshotId: ID
|
|
||||||
}
|
|
||||||
|
|
||||||
type Obligation implements Node {
|
type Obligation implements Node {
|
||||||
id: ID!
|
id: ID!
|
||||||
snapshotId: ID
|
|
||||||
sourceId: ID
|
|
||||||
organization: Organization! @goField(forceResolver: true)
|
organization: Organization! @goField(forceResolver: true)
|
||||||
area: String
|
area: String
|
||||||
source: String
|
source: String
|
||||||
@@ -94,6 +88,19 @@ extend type Mutation {
|
|||||||
createObligation(input: CreateObligationInput!): CreateObligationPayload!
|
createObligation(input: CreateObligationInput!): CreateObligationPayload!
|
||||||
updateObligation(input: UpdateObligationInput!): UpdateObligationPayload!
|
updateObligation(input: UpdateObligationInput!): UpdateObligationPayload!
|
||||||
deleteObligation(input: DeleteObligationInput!): DeleteObligationPayload!
|
deleteObligation(input: DeleteObligationInput!): DeleteObligationPayload!
|
||||||
|
publishObligationList(
|
||||||
|
input: PublishObligationListInput!
|
||||||
|
): PublishObligationListPayload!
|
||||||
|
}
|
||||||
|
|
||||||
|
input PublishObligationListInput {
|
||||||
|
organizationId: ID!
|
||||||
|
approverIds: [ID!]
|
||||||
|
}
|
||||||
|
|
||||||
|
type PublishObligationListPayload {
|
||||||
|
documentEdge: DocumentEdge!
|
||||||
|
documentVersionEdge: DocumentVersionEdge!
|
||||||
}
|
}
|
||||||
|
|
||||||
input CreateObligationInput {
|
input CreateObligationInput {
|
||||||
|
|||||||
@@ -150,13 +150,15 @@ type Organization implements Node {
|
|||||||
orderBy: AuditOrder
|
orderBy: AuditOrder
|
||||||
): AuditConnection! @goField(forceResolver: true)
|
): AuditConnection! @goField(forceResolver: true)
|
||||||
|
|
||||||
|
findingsDocument: Document @goField(forceResolver: true)
|
||||||
|
|
||||||
findings(
|
findings(
|
||||||
first: Int
|
first: Int
|
||||||
after: CursorKey
|
after: CursorKey
|
||||||
last: Int
|
last: Int
|
||||||
before: CursorKey
|
before: CursorKey
|
||||||
orderBy: FindingOrder
|
orderBy: FindingOrder
|
||||||
filter: FindingFilter = { snapshotId: null }
|
filter: FindingFilter
|
||||||
): FindingConnection @goField(forceResolver: true)
|
): FindingConnection @goField(forceResolver: true)
|
||||||
|
|
||||||
auditLogEntries(
|
auditLogEntries(
|
||||||
@@ -247,13 +249,14 @@ type Organization implements Node {
|
|||||||
filter: MeasureFilter
|
filter: MeasureFilter
|
||||||
): MeasureConnection! @goField(forceResolver: true)
|
): MeasureConnection! @goField(forceResolver: true)
|
||||||
|
|
||||||
|
obligationsDocument: Document @goField(forceResolver: true)
|
||||||
|
|
||||||
obligations(
|
obligations(
|
||||||
first: Int
|
first: Int
|
||||||
after: CursorKey
|
after: CursorKey
|
||||||
last: Int
|
last: Int
|
||||||
before: CursorKey
|
before: CursorKey
|
||||||
orderBy: ObligationOrder
|
orderBy: ObligationOrder
|
||||||
filter: ObligationFilter = { snapshotId: null }
|
|
||||||
): ObligationConnection! @goField(forceResolver: true)
|
): ObligationConnection! @goField(forceResolver: true)
|
||||||
|
|
||||||
processingActivities(
|
processingActivities(
|
||||||
|
|||||||
@@ -107,7 +107,6 @@ type Risk implements Node {
|
|||||||
last: Int
|
last: Int
|
||||||
before: CursorKey
|
before: CursorKey
|
||||||
orderBy: ObligationOrder
|
orderBy: ObligationOrder
|
||||||
filter: ObligationFilter
|
|
||||||
): ObligationConnection! @goField(forceResolver: true)
|
): ObligationConnection! @goField(forceResolver: true)
|
||||||
|
|
||||||
createdAt: Datetime!
|
createdAt: Datetime!
|
||||||
|
|||||||
@@ -3,14 +3,6 @@ enum SnapshotsType
|
|||||||
RISKS @goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeRisks")
|
RISKS @goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeRisks")
|
||||||
VENDORS
|
VENDORS
|
||||||
@goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeVendors")
|
@goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeVendors")
|
||||||
FINDINGS
|
|
||||||
@goEnum(
|
|
||||||
value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeFindings"
|
|
||||||
)
|
|
||||||
OBLIGATIONS
|
|
||||||
@goEnum(
|
|
||||||
value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeObligations"
|
|
||||||
)
|
|
||||||
PROCESSING_ACTIVITIES
|
PROCESSING_ACTIVITIES
|
||||||
@goEnum(
|
@goEnum(
|
||||||
value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeProcessingActivities"
|
value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeProcessingActivities"
|
||||||
|
|||||||
@@ -112,6 +112,29 @@ func (r *mutationResolver) DeleteObligation(ctx context.Context, input types.Del
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PublishObligationList is the resolver for the publishObligationList field.
|
||||||
|
func (r *mutationResolver) PublishObligationList(ctx context.Context, input types.PublishObligationListInput) (*types.PublishObligationListPayload, error) {
|
||||||
|
if err := r.authorize(ctx, input.OrganizationID, probo.ActionObligationPublish); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
prb := r.ProboService(ctx, input.OrganizationID.TenantID())
|
||||||
|
|
||||||
|
document, documentVersion, err := prb.GeneratedDocuments.PublishObligationList(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 obligation list", log.Error(err))
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.PublishObligationListPayload{
|
||||||
|
DocumentEdge: types.NewDocumentEdge(document, coredata.DocumentOrderFieldCreatedAt),
|
||||||
|
DocumentVersionEdge: types.NewDocumentVersionEdge(documentVersion, coredata.DocumentVersionOrderFieldCreatedAt),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Organization is the resolver for the organization field.
|
// Organization is the resolver for the organization field.
|
||||||
func (r *obligationResolver) Organization(ctx context.Context, obj *types.Obligation) (*types.Organization, error) {
|
func (r *obligationResolver) Organization(ctx context.Context, obj *types.Obligation) (*types.Organization, error) {
|
||||||
if err := r.authorize(ctx, obj.ID, probo.ActionOrganizationGet); err != nil {
|
if err := r.authorize(ctx, obj.ID, probo.ActionOrganizationGet); err != nil {
|
||||||
@@ -169,24 +192,14 @@ func (r *obligationConnectionResolver) TotalCount(ctx context.Context, obj *type
|
|||||||
|
|
||||||
switch obj.Resolver.(type) {
|
switch obj.Resolver.(type) {
|
||||||
case *organizationResolver:
|
case *organizationResolver:
|
||||||
obligationFilter := coredata.NewObligationFilter(nil)
|
count, err := prb.Obligations.CountForOrganizationID(ctx, obj.ParentID)
|
||||||
if obj.Filter != nil {
|
|
||||||
obligationFilter = coredata.NewObligationFilter(&obj.Filter.SnapshotID)
|
|
||||||
}
|
|
||||||
|
|
||||||
count, err := prb.Obligations.CountForOrganizationID(ctx, obj.ParentID, obligationFilter)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.logger.ErrorCtx(ctx, "cannot count obligations", log.Error(err))
|
r.logger.ErrorCtx(ctx, "cannot count obligations", log.Error(err))
|
||||||
return 0, gqlutils.Internal(ctx)
|
return 0, gqlutils.Internal(ctx)
|
||||||
}
|
}
|
||||||
return count, nil
|
return count, nil
|
||||||
case *riskResolver:
|
case *riskResolver:
|
||||||
obligationFilter := coredata.NewObligationFilter(nil)
|
count, err := prb.Obligations.CountForRiskID(ctx, obj.ParentID)
|
||||||
if obj.Filter != nil {
|
|
||||||
obligationFilter = coredata.NewObligationFilter(&obj.Filter.SnapshotID)
|
|
||||||
}
|
|
||||||
|
|
||||||
count, err := prb.Obligations.CountForRiskID(ctx, obj.ParentID, obligationFilter)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.logger.ErrorCtx(ctx, "cannot count risk obligations", log.Error(err))
|
r.logger.ErrorCtx(ctx, "cannot count risk obligations", log.Error(err))
|
||||||
return 0, gqlutils.Internal(ctx)
|
return 0, gqlutils.Internal(ctx)
|
||||||
|
|||||||
@@ -362,6 +362,30 @@ func (r *organizationResolver) Audits(ctx context.Context, obj *types.Organizati
|
|||||||
return types.NewAuditConnection(page, r, obj.ID), nil
|
return types.NewAuditConnection(page, r, obj.ID), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FindingsDocument is the resolver for the findingsDocument field.
|
||||||
|
func (r *organizationResolver) FindingsDocument(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())
|
||||||
|
|
||||||
|
findingDocumentID, err := prb.GeneratedDocuments.GetFindingsDocumentID(ctx, obj.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot get finding list document ID: %w", err)
|
||||||
|
}
|
||||||
|
if findingDocumentID == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
doc, err := prb.Documents.Get(ctx, *findingDocumentID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot get finding list document: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return types.NewDocument(doc), nil
|
||||||
|
}
|
||||||
|
|
||||||
// Findings is the resolver for the findings field.
|
// Findings is the resolver for the findings field.
|
||||||
func (r *organizationResolver) Findings(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.FindingOrder, filter *types.FindingFilter) (*types.FindingConnection, error) {
|
func (r *organizationResolver) Findings(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.FindingOrder, filter *types.FindingFilter) (*types.FindingConnection, error) {
|
||||||
if err := r.authorize(ctx, obj.ID, probo.ActionFindingList); err != nil {
|
if err := r.authorize(ctx, obj.ID, probo.ActionFindingList); err != nil {
|
||||||
@@ -397,10 +421,7 @@ func (r *organizationResolver) Findings(ctx context.Context, obj *types.Organiza
|
|||||||
ownerID = filter.OwnerID
|
ownerID = filter.OwnerID
|
||||||
}
|
}
|
||||||
|
|
||||||
findingFilter := coredata.NewFindingFilter(nil, kind, status, priority, ownerID)
|
findingFilter := coredata.NewFindingFilter(kind, status, priority, ownerID)
|
||||||
if filter != nil {
|
|
||||||
findingFilter = coredata.NewFindingFilter(&filter.SnapshotID, kind, status, priority, ownerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
page, err := prb.Findings.ListForOrganizationID(ctx, obj.ID, cursor, findingFilter)
|
page, err := prb.Findings.ListForOrganizationID(ctx, obj.ID, cursor, findingFilter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -791,8 +812,32 @@ func (r *organizationResolver) Measures(ctx context.Context, obj *types.Organiza
|
|||||||
return types.NewMeasureConnection(page, r, obj.ID, measureFilter), nil
|
return types.NewMeasureConnection(page, r, obj.ID, measureFilter), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ObligationsDocument is the resolver for the obligationsDocument field.
|
||||||
|
func (r *organizationResolver) ObligationsDocument(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())
|
||||||
|
|
||||||
|
obligationDocumentID, err := prb.GeneratedDocuments.GetObligationsDocumentID(ctx, obj.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot get obligation list document ID: %w", err)
|
||||||
|
}
|
||||||
|
if obligationDocumentID == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
doc, err := prb.Documents.Get(ctx, *obligationDocumentID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot get obligation list document: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return types.NewDocument(doc), nil
|
||||||
|
}
|
||||||
|
|
||||||
// Obligations is the resolver for the obligations field.
|
// Obligations is the resolver for the obligations field.
|
||||||
func (r *organizationResolver) Obligations(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ObligationOrderBy, filter *types.ObligationFilter) (*types.ObligationConnection, error) {
|
func (r *organizationResolver) Obligations(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ObligationOrderBy) (*types.ObligationConnection, error) {
|
||||||
if err := r.authorize(ctx, obj.ID, probo.ActionObligationList); err != nil {
|
if err := r.authorize(ctx, obj.ID, probo.ActionObligationList); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -813,18 +858,13 @@ func (r *organizationResolver) Obligations(ctx context.Context, obj *types.Organ
|
|||||||
|
|
||||||
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
||||||
|
|
||||||
obligationFilter := coredata.NewObligationFilter(nil)
|
page, err := prb.Obligations.ListForOrganizationID(ctx, obj.ID, cursor)
|
||||||
if filter != nil {
|
|
||||||
obligationFilter = coredata.NewObligationFilter(&filter.SnapshotID)
|
|
||||||
}
|
|
||||||
|
|
||||||
page, err := prb.Obligations.ListForOrganizationID(ctx, obj.ID, cursor, obligationFilter)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.logger.ErrorCtx(ctx, "cannot list organization obligations", log.Error(err))
|
r.logger.ErrorCtx(ctx, "cannot list organization obligations", log.Error(err))
|
||||||
return nil, gqlutils.Internal(ctx)
|
return nil, gqlutils.Internal(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
return types.NewObligationConnection(page, r, obj.ID, filter), nil
|
return types.NewObligationConnection(page, r, obj.ID), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProcessingActivities is the resolver for the processingActivities field.
|
// ProcessingActivities is the resolver for the processingActivities field.
|
||||||
|
|||||||
@@ -393,7 +393,7 @@ func (r *riskResolver) Controls(ctx context.Context, obj *types.Risk, first *int
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Obligations is the resolver for the obligations field.
|
// Obligations is the resolver for the obligations field.
|
||||||
func (r *riskResolver) Obligations(ctx context.Context, obj *types.Risk, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ObligationOrderBy, filter *types.ObligationFilter) (*types.ObligationConnection, error) {
|
func (r *riskResolver) Obligations(ctx context.Context, obj *types.Risk, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ObligationOrderBy) (*types.ObligationConnection, error) {
|
||||||
if err := r.authorize(ctx, obj.ID, probo.ActionObligationList); err != nil {
|
if err := r.authorize(ctx, obj.ID, probo.ActionObligationList); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -413,18 +413,13 @@ func (r *riskResolver) Obligations(ctx context.Context, obj *types.Risk, first *
|
|||||||
|
|
||||||
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
||||||
|
|
||||||
var obligationFilter = coredata.NewObligationFilter(nil)
|
page, err := prb.Obligations.ListForRiskID(ctx, obj.ID, cursor)
|
||||||
if filter != nil {
|
|
||||||
obligationFilter = coredata.NewObligationFilter(&filter.SnapshotID)
|
|
||||||
}
|
|
||||||
|
|
||||||
page, err := prb.Obligations.ListForRiskID(ctx, obj.ID, cursor, obligationFilter)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.logger.ErrorCtx(ctx, "cannot list risk obligations", log.Error(err))
|
r.logger.ErrorCtx(ctx, "cannot list risk obligations", log.Error(err))
|
||||||
return nil, gqlutils.Internal(ctx)
|
return nil, gqlutils.Internal(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
return types.NewObligationConnection(page, r, obj.ID, filter), nil
|
return types.NewObligationConnection(page, r, obj.ID), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Permission is the resolver for the permission field.
|
// Permission is the resolver for the permission field.
|
||||||
|
|||||||
@@ -67,8 +67,7 @@ func NewFindingEdge(f *coredata.Finding, orderField coredata.FindingOrderField)
|
|||||||
|
|
||||||
func NewFinding(f *coredata.Finding) *Finding {
|
func NewFinding(f *coredata.Finding) *Finding {
|
||||||
finding := &Finding{
|
finding := &Finding{
|
||||||
ID: f.ID,
|
ID: f.ID,
|
||||||
SnapshotID: f.SnapshotID,
|
|
||||||
Organization: &Organization{
|
Organization: &Organization{
|
||||||
ID: f.OrganizationID,
|
ID: f.OrganizationID,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ type (
|
|||||||
|
|
||||||
Resolver any
|
Resolver any
|
||||||
ParentID gid.GID
|
ParentID gid.GID
|
||||||
Filter *ObligationFilter
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,7 +37,6 @@ func NewObligationConnection(
|
|||||||
p *page.Page[*coredata.Obligation, coredata.ObligationOrderField],
|
p *page.Page[*coredata.Obligation, coredata.ObligationOrderField],
|
||||||
parentType any,
|
parentType any,
|
||||||
parentID gid.GID,
|
parentID gid.GID,
|
||||||
filter *ObligationFilter,
|
|
||||||
) *ObligationConnection {
|
) *ObligationConnection {
|
||||||
edges := make([]*ObligationEdge, len(p.Data))
|
edges := make([]*ObligationEdge, len(p.Data))
|
||||||
for i, obligation := range p.Data {
|
for i, obligation := range p.Data {
|
||||||
@@ -51,15 +49,12 @@ func NewObligationConnection(
|
|||||||
|
|
||||||
Resolver: parentType,
|
Resolver: parentType,
|
||||||
ParentID: parentID,
|
ParentID: parentID,
|
||||||
Filter: filter,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewObligation(cr *coredata.Obligation) *Obligation {
|
func NewObligation(cr *coredata.Obligation) *Obligation {
|
||||||
return &Obligation{
|
return &Obligation{
|
||||||
ID: cr.ID,
|
ID: cr.ID,
|
||||||
SnapshotID: cr.SnapshotID,
|
|
||||||
SourceID: cr.SourceID,
|
|
||||||
Organization: &Organization{
|
Organization: &Organization{
|
||||||
ID: cr.OrganizationID,
|
ID: cr.OrganizationID,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -741,11 +741,9 @@ func (r *Resolver) ListFindingsTool(ctx context.Context, req *mcp.CallToolReques
|
|||||||
|
|
||||||
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
|
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
|
||||||
|
|
||||||
noSnapshot := (*gid.GID)(nil)
|
findingFilter := coredata.NewFindingFilter(nil, nil, nil, nil)
|
||||||
findingFilter := coredata.NewFindingFilter(&noSnapshot, nil, nil, nil, nil)
|
|
||||||
if input.Filter != nil {
|
if input.Filter != nil {
|
||||||
findingFilter = coredata.NewFindingFilter(
|
findingFilter = coredata.NewFindingFilter(
|
||||||
&input.Filter.SnapshotID,
|
|
||||||
input.Filter.Kind,
|
input.Filter.Kind,
|
||||||
input.Filter.Status,
|
input.Filter.Status,
|
||||||
input.Filter.Priority,
|
input.Filter.Priority,
|
||||||
@@ -857,13 +855,7 @@ func (r *Resolver) ListObligationsTool(ctx context.Context, req *mcp.CallToolReq
|
|||||||
|
|
||||||
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
|
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
|
||||||
|
|
||||||
noSnapshot := (*gid.GID)(nil)
|
page, err := prb.Obligations.ListForOrganizationID(ctx, input.OrganizationID, cursor)
|
||||||
obligationFilter := coredata.NewObligationFilter(&noSnapshot)
|
|
||||||
if input.Filter != nil {
|
|
||||||
obligationFilter = coredata.NewObligationFilter(&input.Filter.SnapshotID)
|
|
||||||
}
|
|
||||||
|
|
||||||
page, err := prb.Obligations.ListForOrganizationID(ctx, input.OrganizationID, cursor, obligationFilter)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(fmt.Errorf("cannot list organization obligations: %w", err))
|
panic(fmt.Errorf("cannot list organization obligations: %w", err))
|
||||||
}
|
}
|
||||||
@@ -1610,7 +1602,7 @@ func (r *Resolver) ListControlObligationsTool(ctx context.Context, req *mcp.Call
|
|||||||
|
|
||||||
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
|
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
|
||||||
|
|
||||||
obligationPage, err := prb.Obligations.ListForControlID(ctx, input.ControlID, cursor, coredata.NewObligationFilter(nil))
|
obligationPage, err := prb.Obligations.ListForControlID(ctx, input.ControlID, cursor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, types.ListControlObligationsOutput{}, fmt.Errorf("failed to list control obligations: %w", err)
|
return nil, types.ListControlObligationsOutput{}, fmt.Errorf("failed to list control obligations: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1740,7 +1732,7 @@ func (r *Resolver) ListRiskObligationsTool(ctx context.Context, req *mcp.CallToo
|
|||||||
|
|
||||||
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
|
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
|
||||||
|
|
||||||
obligationPage, err := prb.Obligations.ListForRiskID(ctx, input.RiskID, cursor, coredata.NewObligationFilter(nil))
|
obligationPage, err := prb.Obligations.ListForRiskID(ctx, input.RiskID, cursor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, types.ListRiskObligationsOutput{}, fmt.Errorf("failed to list risk obligations: %w", err)
|
return nil, types.ListRiskObligationsOutput{}, fmt.Errorf("failed to list risk obligations: %w", err)
|
||||||
}
|
}
|
||||||
@@ -4786,3 +4778,35 @@ func (r *Resolver) AssessVendorTool(ctx context.Context, req *mcp.CallToolReques
|
|||||||
|
|
||||||
return nil, types.NewAssessVendorOutput(result), nil
|
return nil, types.NewAssessVendorOutput(result), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Resolver) PublishFindingListTool(ctx context.Context, req *mcp.CallToolRequest, input *types.PublishFindingListInput) (*mcp.CallToolResult, types.PublishFindingListOutput, error) {
|
||||||
|
r.MustAuthorize(ctx, input.OrganizationID, probo.ActionFindingPublish)
|
||||||
|
|
||||||
|
svc := r.ProboService(ctx, input.OrganizationID)
|
||||||
|
|
||||||
|
document, documentVersion, err := svc.GeneratedDocuments.PublishFindingList(ctx, input.OrganizationID, input.ApproverIds)
|
||||||
|
if err != nil {
|
||||||
|
return nil, types.PublishFindingListOutput{}, fmt.Errorf("cannot publish finding list: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, types.PublishFindingListOutput{
|
||||||
|
DocumentID: document.ID,
|
||||||
|
DocumentVersionID: documentVersion.ID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Resolver) PublishObligationListTool(ctx context.Context, req *mcp.CallToolRequest, input *types.PublishObligationListInput) (*mcp.CallToolResult, types.PublishObligationListOutput, error) {
|
||||||
|
r.MustAuthorize(ctx, input.OrganizationID, probo.ActionObligationPublish)
|
||||||
|
|
||||||
|
svc := r.ProboService(ctx, input.OrganizationID)
|
||||||
|
|
||||||
|
document, documentVersion, err := svc.GeneratedDocuments.PublishObligationList(ctx, input.OrganizationID, input.ApproverIds)
|
||||||
|
if err != nil {
|
||||||
|
return nil, types.PublishObligationListOutput{}, fmt.Errorf("cannot publish obligation list: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, types.PublishObligationListOutput{
|
||||||
|
DocumentID: document.ID,
|
||||||
|
DocumentVersionID: documentVersion.ID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -2827,18 +2827,6 @@ components:
|
|||||||
organization_id:
|
organization_id:
|
||||||
$ref: "#/components/schemas/GID"
|
$ref: "#/components/schemas/GID"
|
||||||
description: Organization ID
|
description: Organization ID
|
||||||
snapshot_id:
|
|
||||||
anyOf:
|
|
||||||
- $ref: "#/components/schemas/GID"
|
|
||||||
description: Snapshot ID
|
|
||||||
- type: "null"
|
|
||||||
description: No snapshot
|
|
||||||
description: Snapshot ID
|
|
||||||
source_id:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- "null"
|
|
||||||
description: Source ID
|
|
||||||
kind:
|
kind:
|
||||||
$ref: "#/components/schemas/FindingKind"
|
$ref: "#/components/schemas/FindingKind"
|
||||||
description: Finding kind
|
description: Finding kind
|
||||||
@@ -2929,12 +2917,6 @@ components:
|
|||||||
filter:
|
filter:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
snapshot_id:
|
|
||||||
anyOf:
|
|
||||||
- $ref: "#/components/schemas/GID"
|
|
||||||
- type: "null"
|
|
||||||
description: Filter by snapshot ID. Defaults to null, which returns only findings with no snapshot (current live data). Pass a specific snapshot ID to retrieve findings as they were at that snapshot.
|
|
||||||
default: null
|
|
||||||
kind:
|
kind:
|
||||||
anyOf:
|
anyOf:
|
||||||
- $ref: "#/components/schemas/FindingKind"
|
- $ref: "#/components/schemas/FindingKind"
|
||||||
@@ -3279,18 +3261,6 @@ components:
|
|||||||
organization_id:
|
organization_id:
|
||||||
$ref: "#/components/schemas/GID"
|
$ref: "#/components/schemas/GID"
|
||||||
description: Organization ID
|
description: Organization ID
|
||||||
snapshot_id:
|
|
||||||
anyOf:
|
|
||||||
- $ref: "#/components/schemas/GID"
|
|
||||||
description: Snapshot ID
|
|
||||||
- type: "null"
|
|
||||||
description: No snapshot
|
|
||||||
description: Snapshot ID
|
|
||||||
source_id:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- "null"
|
|
||||||
description: Source ID
|
|
||||||
area:
|
area:
|
||||||
type:
|
type:
|
||||||
- string
|
- string
|
||||||
@@ -3363,16 +3333,6 @@ components:
|
|||||||
cursor:
|
cursor:
|
||||||
$ref: "#/components/schemas/CursorKey"
|
$ref: "#/components/schemas/CursorKey"
|
||||||
description: Page cursor
|
description: Page cursor
|
||||||
filter:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
snapshot_id:
|
|
||||||
anyOf:
|
|
||||||
- $ref: "#/components/schemas/GID"
|
|
||||||
- type: "null"
|
|
||||||
description: Filter by snapshot ID. Defaults to null, which returns only obligations with no snapshot (current live data). Pass a specific snapshot ID to retrieve obligations as they were at that snapshot.
|
|
||||||
default: null
|
|
||||||
|
|
||||||
ListObligationsOutput:
|
ListObligationsOutput:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
@@ -7051,6 +7011,60 @@ components:
|
|||||||
$ref: "#/components/schemas/GID"
|
$ref: "#/components/schemas/GID"
|
||||||
description: Created document version ID
|
description: Created document version ID
|
||||||
|
|
||||||
|
PublishFindingListInput:
|
||||||
|
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.
|
||||||
|
|
||||||
|
PublishFindingListOutput:
|
||||||
|
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
|
||||||
|
|
||||||
|
PublishObligationListInput:
|
||||||
|
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.
|
||||||
|
|
||||||
|
PublishObligationListOutput:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- document_id
|
||||||
|
- document_version_id
|
||||||
|
properties:
|
||||||
|
document_id:
|
||||||
|
$ref: "#/components/schemas/GID"
|
||||||
|
description: Created or updated document ID
|
||||||
|
document_version_id:
|
||||||
|
$ref: "#/components/schemas/GID"
|
||||||
|
description: Created document version ID
|
||||||
|
|
||||||
PublishStatementOfApplicabilityInput:
|
PublishStatementOfApplicabilityInput:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
@@ -10288,6 +10302,22 @@ tools:
|
|||||||
$ref: "#/components/schemas/PublishAssetListInput"
|
$ref: "#/components/schemas/PublishAssetListInput"
|
||||||
outputSchema:
|
outputSchema:
|
||||||
$ref: "#/components/schemas/PublishAssetListOutput"
|
$ref: "#/components/schemas/PublishAssetListOutput"
|
||||||
|
- name: publishFindingList
|
||||||
|
description: Publish the finding register for an organization as a document. If a document already exists, a new version is created.
|
||||||
|
hints:
|
||||||
|
readonly: false
|
||||||
|
inputSchema:
|
||||||
|
$ref: "#/components/schemas/PublishFindingListInput"
|
||||||
|
outputSchema:
|
||||||
|
$ref: "#/components/schemas/PublishFindingListOutput"
|
||||||
|
- name: publishObligationList
|
||||||
|
description: Publish the obligation register for an organization as a document. If a document already exists, a new version is created.
|
||||||
|
hints:
|
||||||
|
readonly: false
|
||||||
|
inputSchema:
|
||||||
|
$ref: "#/components/schemas/PublishObligationListInput"
|
||||||
|
outputSchema:
|
||||||
|
$ref: "#/components/schemas/PublishObligationListOutput"
|
||||||
- name: publishStatementOfApplicability
|
- name: publishStatementOfApplicability
|
||||||
description: Publish a statement of applicability as a document. If a document already exists, a new version is created.
|
description: Publish a statement of applicability as a document. If a document already exists, a new version is created.
|
||||||
hints:
|
hints:
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ func NewFinding(f *coredata.Finding) *Finding {
|
|||||||
finding := &Finding{
|
finding := &Finding{
|
||||||
ID: f.ID,
|
ID: f.ID,
|
||||||
OrganizationID: f.OrganizationID,
|
OrganizationID: f.OrganizationID,
|
||||||
SnapshotID: f.SnapshotID,
|
|
||||||
Kind: f.Kind,
|
Kind: f.Kind,
|
||||||
ReferenceID: f.ReferenceID,
|
ReferenceID: f.ReferenceID,
|
||||||
Description: f.Description,
|
Description: f.Description,
|
||||||
@@ -41,11 +40,6 @@ func NewFinding(f *coredata.Finding) *Finding {
|
|||||||
UpdatedAt: f.UpdatedAt,
|
UpdatedAt: f.UpdatedAt,
|
||||||
}
|
}
|
||||||
|
|
||||||
if f.SourceID != nil {
|
|
||||||
s := f.SourceID.String()
|
|
||||||
finding.SourceID = &s
|
|
||||||
}
|
|
||||||
|
|
||||||
return finding
|
return finding
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ func NewObligation(o *coredata.Obligation) *Obligation {
|
|||||||
obligation := &Obligation{
|
obligation := &Obligation{
|
||||||
ID: o.ID,
|
ID: o.ID,
|
||||||
OrganizationID: o.OrganizationID,
|
OrganizationID: o.OrganizationID,
|
||||||
SnapshotID: o.SnapshotID,
|
|
||||||
Area: o.Area,
|
Area: o.Area,
|
||||||
Source: o.Source,
|
Source: o.Source,
|
||||||
Requirement: o.Requirement,
|
Requirement: o.Requirement,
|
||||||
@@ -38,11 +37,6 @@ func NewObligation(o *coredata.Obligation) *Obligation {
|
|||||||
UpdatedAt: o.UpdatedAt,
|
UpdatedAt: o.UpdatedAt,
|
||||||
}
|
}
|
||||||
|
|
||||||
if o.SourceID != nil {
|
|
||||||
s := o.SourceID.String()
|
|
||||||
obligation.SourceID = &s
|
|
||||||
}
|
|
||||||
|
|
||||||
return obligation
|
return obligation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user