- {isSnapshotMode && snapshotId && (
-
- )}
- {!isSnapshotMode && obligation.canDelete && (
+ {obligation.canDelete && (
- {!isSnapshotMode && (
-
- {obligation.canUpdate && (
-
- )}
-
- )}
+
+ {obligation.canUpdate && (
+
+ )}
+
diff --git a/apps/console/src/pages/organizations/obligations/ObligationsPage.tsx b/apps/console/src/pages/organizations/obligations/ObligationsPage.tsx
index f5079f499..5a9ead175 100644
--- a/apps/console/src/pages/organizations/obligations/ObligationsPage.tsx
+++ b/apps/console/src/pages/organizations/obligations/ObligationsPage.tsx
@@ -26,8 +26,10 @@ import {
Button,
Card,
DropdownItem,
+ IconPageTextLine,
IconPlusLarge,
IconTrashCan,
+ IconUpload,
PageHeader,
Table,
Tbody,
@@ -44,7 +46,7 @@ import {
usePaginationFragment,
usePreloadedQuery,
} from "react-relay";
-import { useParams } from "react-router";
+import { Link, useNavigate } from "react-router";
import type { ObligationGraphDeleteMutation } from "#/__generated__/core/ObligationGraphDeleteMutation.graphql";
import type { ObligationGraphListQuery } from "#/__generated__/core/ObligationGraphListQuery.graphql";
@@ -53,7 +55,6 @@ import type {
ObligationsPageFragment$key,
} from "#/__generated__/core/ObligationsPageFragment.graphql";
import type { ObligationsPageRefetchQuery } from "#/__generated__/core/ObligationsPageRefetchQuery.graphql";
-import { SnapshotBanner } from "#/components/SnapshotBanner";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import {
@@ -62,6 +63,7 @@ import {
} from "../../../hooks/graph/ObligationGraph";
import { CreateObligationDialog } from "./dialogs/CreateObligationDialog";
+import { PublishObligationListDialog } from "./dialogs/PublishObligationListDialog";
type Obligation
= ObligationsPageFragment$data["obligations"]["edges"][number]["node"];
@@ -76,19 +78,16 @@ const obligationsPageFragment = graphql`
@argumentDefinitions(
first: { type: "Int", defaultValue: 500 }
after: { type: "CursorKey" }
- snapshotId: { type: "ID", defaultValue: null }
) {
id
obligations(
first: $first
after: $after
- filter: { snapshotId: $snapshotId }
- ) @connection(key: "ObligationsPage_obligations", filters: ["filter"]) {
+ ) @connection(key: "ObligationsPage_obligations") {
__id
edges {
node {
id
- snapshotId
area
source
status
@@ -112,12 +111,12 @@ const obligationsPageFragment = graphql`
export default function ObligationsPage({ queryRef }: ObligationsPageProps) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
- const { snapshotId } = useParams<{ snapshotId?: string }>();
- const isSnapshotMode = Boolean(snapshotId);
+ const navigate = useNavigate();
usePageTitle(__("Obligations"));
const organization = usePreloadedQuery(obligationsQuery, queryRef);
+ const defaultApproverIds = (organization.node.obligationsDocument?.defaultApprovers ?? []).map(a => a.id);
const {
data: obligationsData,
@@ -133,26 +132,49 @@ export default function ObligationsPage({ queryRef }: ObligationsPageProps) {
= obligationsData?.obligations?.edges?.map(edge => edge.node) ?? [];
const hasAnyAction
- = !isSnapshotMode
- && obligations.some(({ canUpdate, canDelete }) => canDelete || canUpdate);
+ = obligations.some(({ canUpdate, canDelete }) => canDelete || canUpdate);
return (
- {isSnapshotMode && snapshotId && (
-
- )}
- {!snapshotId && organization.node.canCreateObligation && (
-
-
-
- )}
+
+ {organization.node.obligationsDocument?.id && (
+
+ )}
+ {organization.node.canPublishObligations && (
+
{
+ void navigate(
+ `/organizations/${organizationId}/documents/${documentId}`,
+ );
+ }}
+ >
+
+
+ )}
+ {organization.node.canCreateObligation && (
+
+
+
+ )}
+
{obligations.length === 0
@@ -187,7 +209,6 @@ export default function ObligationsPage({ queryRef }: ObligationsPageProps) {
key={obligation.id}
obligation={obligation}
connectionId={connectionId}
- snapshotId={snapshotId}
hasAnyAction={hasAnyAction}
/>
))}
@@ -214,19 +235,16 @@ export default function ObligationsPage({ queryRef }: ObligationsPageProps) {
function ObligationRow({
obligation,
connectionId,
- snapshotId,
hasAnyAction,
}: {
obligation: Obligation;
connectionId: string;
- snapshotId?: string;
hasAnyAction: boolean;
}) {
const organizationId = useOrganizationId();
const { __ } = useTranslate();
const [deleteObligation] = useMutation
(deleteObligationMutation);
const confirm = useConfirm();
- const isSnapshotMode = Boolean(snapshotId);
const handleDelete = () => {
confirm(
@@ -247,9 +265,7 @@ function ObligationRow({
);
};
- const detailsUrl = isSnapshotMode
- ? `/organizations/${organizationId}/snapshots/${snapshotId}/obligations/${obligation.id}`
- : `/organizations/${organizationId}/obligations/${obligation.id}`;
+ const detailsUrl = `/organizations/${organizationId}/obligations/${obligation.id}`;
return (
diff --git a/apps/console/src/pages/organizations/obligations/dialogs/PublishObligationListDialog.tsx b/apps/console/src/pages/organizations/obligations/dialogs/PublishObligationListDialog.tsx
new file mode 100644
index 000000000..4e965e64a
--- /dev/null
+++ b/apps/console/src/pages/organizations/obligations/dialogs/PublishObligationListDialog.tsx
@@ -0,0 +1,159 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// 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(publishMutation);
+
+ const approverIds = watch("approverIds");
+ const hasApprovers = approverIds.length > 0;
+
+ const onSubmit = (data: z.infer) => {
+ 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 (
+
+ );
+}
diff --git a/apps/console/src/routes/findingRoutes.ts b/apps/console/src/routes/findingRoutes.ts
index 24c201825..f3f4a0710 100644
--- a/apps/console/src/routes/findingRoutes.ts
+++ b/apps/console/src/routes/findingRoutes.ts
@@ -26,14 +26,6 @@ export const findingRoutes = [
import("#/pages/organizations/findings/FindingsPageLoader"),
),
},
- {
- path: "snapshots/:snapshotId/findings",
- Fallback: PageSkeleton,
- Component: lazy(
- () =>
- import("#/pages/organizations/findings/FindingsPageLoader"),
- ),
- },
{
path: "findings/:findingId",
Fallback: PageSkeleton,
@@ -42,12 +34,4 @@ export const findingRoutes = [
import("#/pages/organizations/findings/FindingDetailsPageLoader"),
),
},
- {
- path: "snapshots/:snapshotId/findings/:findingId",
- Fallback: PageSkeleton,
- Component: lazy(
- () =>
- import("#/pages/organizations/findings/FindingDetailsPageLoader"),
- ),
- },
] satisfies AppRoute[];
diff --git a/apps/console/src/routes/obligationRoutes.ts b/apps/console/src/routes/obligationRoutes.ts
index d315c1cb8..b24886347 100644
--- a/apps/console/src/routes/obligationRoutes.ts
+++ b/apps/console/src/routes/obligationRoutes.ts
@@ -36,20 +36,6 @@ export const obligationRoutes = [
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery(coreEnvironment, obligationsQuery, {
organizationId,
- snapshotId: null,
- }),
- ),
- Component: withQueryRef(
- lazy(() => import("#/pages/organizations/obligations/ObligationsPage")),
- ),
- },
- {
- path: "snapshots/:snapshotId/obligations",
- Fallback: PageSkeleton,
- loader: loaderFromQueryLoader(({ organizationId, snapshotId }) =>
- loadQuery(coreEnvironment, obligationsQuery, {
- organizationId,
- snapshotId,
}),
),
Component: withQueryRef(
@@ -74,22 +60,4 @@ export const obligationRoutes = [
),
),
},
- {
- path: "snapshots/:snapshotId/obligations/:obligationId",
- Fallback: PageSkeleton,
- loader: loaderFromQueryLoader(({ obligationId }) =>
- loadQuery(
- coreEnvironment,
- obligationNodeQuery,
- {
- obligationId,
- },
- ),
- ),
- Component: withQueryRef(
- lazy(
- () => import("#/pages/organizations/obligations/ObligationDetailsPage"),
- ),
- ),
- },
] satisfies AppRoute[];
diff --git a/cmd/migrate-finding-snapshots-to-documents/main.go b/cmd/migrate-finding-snapshots-to-documents/main.go
new file mode 100644
index 000000000..c884abf56
--- /dev/null
+++ b/cmd/migrate-finding-snapshots-to-documents/main.go
@@ -0,0 +1,487 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// 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...)
+}
diff --git a/cmd/migrate-obligation-snapshots-to-documents/main.go b/cmd/migrate-obligation-snapshots-to-documents/main.go
new file mode 100644
index 000000000..203b40852
--- /dev/null
+++ b/cmd/migrate-obligation-snapshots-to-documents/main.go
@@ -0,0 +1,450 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// 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...)
+}
diff --git a/e2e/console/finding_publish_test.go b/e2e/console/finding_publish_test.go
new file mode 100644
index 000000000..5bf9ad077
--- /dev/null
+++ b/e2e/console/finding_publish_test.go
@@ -0,0 +1,400 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// 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
+}
diff --git a/e2e/console/obligation_publish_test.go b/e2e/console/obligation_publish_test.go
new file mode 100644
index 000000000..80bd8a7af
--- /dev/null
+++ b/e2e/console/obligation_publish_test.go
@@ -0,0 +1,400 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// 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
+}
diff --git a/packages/helpers/src/snapshots.ts b/packages/helpers/src/snapshots.ts
index f96d76ba9..4dc6e9826 100644
--- a/packages/helpers/src/snapshots.ts
+++ b/packages/helpers/src/snapshots.ts
@@ -17,8 +17,6 @@ type Translator = (s: string) => string;
export const snapshotTypes = [
"RISKS",
"VENDORS",
- "FINDINGS",
- "OBLIGATIONS",
"PROCESSING_ACTIVITIES",
] as const;
@@ -32,12 +30,6 @@ export function getSnapshotTypeLabel(__: Translator, type: string | null | undef
return __("Risks");
case "VENDORS":
return __("Vendors");
- case "FINDINGS":
- case "NONCONFORMITIES":
- case "CONTINUAL_IMPROVEMENTS":
- return __("Findings");
- case "OBLIGATIONS":
- return __("Obligations");
case "PROCESSING_ACTIVITIES":
return __("Processing Activities");
default:
@@ -51,12 +43,6 @@ export function getSnapshotTypeUrlPath(type?: string): string {
return "/risks";
case "VENDORS":
return "/vendors";
- case "FINDINGS":
- case "NONCONFORMITIES":
- case "CONTINUAL_IMPROVEMENTS":
- return "/findings";
- case "OBLIGATIONS":
- return "/obligations";
case "PROCESSING_ACTIVITIES":
return "/processing-activities";
default:
diff --git a/packages/n8n-node/nodes/Probo/actions/finding/index.ts b/packages/n8n-node/nodes/Probo/actions/finding/index.ts
index b8c338801..e3ed7bcc8 100644
--- a/packages/n8n-node/nodes/Probo/actions/finding/index.ts
+++ b/packages/n8n-node/nodes/Probo/actions/finding/index.ts
@@ -19,6 +19,7 @@ import * as deleteOp from './delete.operation';
import * as getOp from './get.operation';
import * as getAllOp from './getAll.operation';
import * as linkAuditOp from './linkAudit.operation';
+import * as publishOp from './publish.operation';
import * as unlinkAuditOp from './unlinkAudit.operation';
export const description: INodeProperties[] = [
@@ -63,6 +64,12 @@ export const description: INodeProperties[] = [
description: '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',
value: 'unlinkAudit',
@@ -84,6 +91,7 @@ export const description: INodeProperties[] = [
...getOp.description,
...getAllOp.description,
...linkAuditOp.description,
+ ...publishOp.description,
...unlinkAuditOp.description,
];
@@ -94,5 +102,6 @@ export {
getOp as get,
getAllOp as getAll,
linkAuditOp as linkAudit,
+ publishOp as publish,
unlinkAuditOp as unlinkAudit,
};
diff --git a/packages/n8n-node/nodes/Probo/actions/finding/publish.operation.ts b/packages/n8n-node/nodes/Probo/actions/finding/publish.operation.ts
new file mode 100644
index 000000000..c6cdd0ec4
--- /dev/null
+++ b/packages/n8n-node/nodes/Probo/actions/finding/publish.operation.ts
@@ -0,0 +1,101 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// 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 {
+ 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 = { 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 },
+ };
+}
diff --git a/packages/n8n-node/nodes/Probo/actions/obligation/index.ts b/packages/n8n-node/nodes/Probo/actions/obligation/index.ts
index fcd28db34..6e77d2ee6 100644
--- a/packages/n8n-node/nodes/Probo/actions/obligation/index.ts
+++ b/packages/n8n-node/nodes/Probo/actions/obligation/index.ts
@@ -18,6 +18,7 @@ import * as updateOp from './update.operation';
import * as deleteOp from './delete.operation';
import * as getOp from './get.operation';
import * as getAllOp from './getAll.operation';
+import * as publishOp from './publish.operation';
export const description: INodeProperties[] = [
{
@@ -55,6 +56,12 @@ export const description: INodeProperties[] = [
description: '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',
value: 'update',
@@ -69,6 +76,7 @@ export const description: INodeProperties[] = [
...deleteOp.description,
...getOp.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 };
diff --git a/packages/n8n-node/nodes/Probo/actions/obligation/publish.operation.ts b/packages/n8n-node/nodes/Probo/actions/obligation/publish.operation.ts
new file mode 100644
index 000000000..e2fac72fc
--- /dev/null
+++ b/packages/n8n-node/nodes/Probo/actions/obligation/publish.operation.ts
@@ -0,0 +1,101 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// 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 {
+ 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 = { 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 },
+ };
+}
diff --git a/pkg/cmd/finding/finding.go b/pkg/cmd/finding/finding.go
index 9a638ad16..d2a8b6bec 100644
--- a/pkg/cmd/finding/finding.go
+++ b/pkg/cmd/finding/finding.go
@@ -20,6 +20,7 @@ import (
"go.probo.inc/probo/pkg/cmd/finding/create"
"go.probo.inc/probo/pkg/cmd/finding/delete"
"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/view"
)
@@ -35,6 +36,7 @@ func NewCmdFinding(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(view.NewCmdView(f))
cmd.AddCommand(update.NewCmdUpdate(f))
cmd.AddCommand(delete.NewCmdDelete(f))
+ cmd.AddCommand(publish.NewCmdPublish(f))
return cmd
}
diff --git a/pkg/cmd/finding/publish/publish.go b/pkg/cmd/finding/publish/publish.go
new file mode 100644
index 000000000..fadfd05f1
--- /dev/null
+++ b/pkg/cmd/finding/publish/publish.go
@@ -0,0 +1,147 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// 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
+}
diff --git a/pkg/cmd/obligation/list/list.go b/pkg/cmd/obligation/list/list.go
index fc0b73324..a50f7f345 100644
--- a/pkg/cmd/obligation/list/list.go
+++ b/pkg/cmd/obligation/list/list.go
@@ -24,11 +24,11 @@ import (
)
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) {
__typename
... on Organization {
- obligations(first: $first, after: $after, orderBy: $orderBy, filter: $filter) {
+ obligations(first: $first, after: $after, orderBy: $orderBy) {
totalCount
edges {
node {
diff --git a/pkg/cmd/obligation/obligation.go b/pkg/cmd/obligation/obligation.go
index b8b0c36f9..a977e236d 100644
--- a/pkg/cmd/obligation/obligation.go
+++ b/pkg/cmd/obligation/obligation.go
@@ -20,6 +20,7 @@ import (
"go.probo.inc/probo/pkg/cmd/obligation/create"
"go.probo.inc/probo/pkg/cmd/obligation/delete"
"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/view"
)
@@ -35,6 +36,7 @@ func NewCmdObligation(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(view.NewCmdView(f))
cmd.AddCommand(update.NewCmdUpdate(f))
cmd.AddCommand(delete.NewCmdDelete(f))
+ cmd.AddCommand(publish.NewCmdPublish(f))
return cmd
}
diff --git a/pkg/cmd/obligation/publish/publish.go b/pkg/cmd/obligation/publish/publish.go
new file mode 100644
index 000000000..c3a497fdd
--- /dev/null
+++ b/pkg/cmd/obligation/publish/publish.go
@@ -0,0 +1,147 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// 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
+}
diff --git a/pkg/coredata/finding.go b/pkg/coredata/finding.go
index a48bb5864..31b694025 100644
--- a/pkg/coredata/finding.go
+++ b/pkg/coredata/finding.go
@@ -120,6 +120,7 @@ FROM
WHERE
%s
AND id = @finding_id
+ AND snapshot_id IS NULL
LIMIT 1;
`
@@ -158,6 +159,7 @@ FROM
WHERE
%s
AND organization_id = @organization_id
+ AND snapshot_id IS NULL
AND %s
`
@@ -212,6 +214,7 @@ FROM
WHERE
%s
AND organization_id = @organization_id
+ AND snapshot_id IS NULL
AND %s
AND %s
`
@@ -412,95 +415,6 @@ WHERE
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(
ctx context.Context,
conn pg.Querier,
@@ -538,6 +452,7 @@ WITH f AS (
findings_audits fa ON fi.id = fa.finding_id
WHERE
fa.audit_id = @audit_id
+ AND fi.snapshot_id IS NULL
)
SELECT
id,
@@ -605,6 +520,7 @@ WITH f AS (
findings_audits fa ON fi.id = fa.finding_id
WHERE
fa.audit_id = @audit_id
+ AND fi.snapshot_id IS NULL
)
SELECT
COUNT(id)
@@ -631,3 +547,167 @@ WHERE
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
+}
diff --git a/pkg/coredata/finding_filter.go b/pkg/coredata/finding_filter.go
index 5f9393a0d..106b88ce4 100644
--- a/pkg/coredata/finding_filter.go
+++ b/pkg/coredata/finding_filter.go
@@ -21,34 +21,29 @@ import (
type (
FindingFilter struct {
- snapshotID **gid.GID
- kind *FindingKind
- status *FindingStatus
- priority *FindingPriority
- ownerID *gid.GID
+ kind *FindingKind
+ status *FindingStatus
+ priority *FindingPriority
+ ownerID *gid.GID
}
)
func NewFindingFilter(
- snapshotID **gid.GID,
kind *FindingKind,
status *FindingStatus,
priority *FindingPriority,
ownerID *gid.GID,
) *FindingFilter {
return &FindingFilter{
- snapshotID: snapshotID,
- kind: kind,
- status: status,
- priority: priority,
- ownerID: ownerID,
+ kind: kind,
+ status: status,
+ priority: priority,
+ ownerID: ownerID,
}
}
func (f *FindingFilter) SQLArguments() pgx.StrictNamedArgs {
args := pgx.StrictNamedArgs{
- "has_snapshot_filter": false,
- "filter_snapshot_id": nil,
"has_kind_filter": false,
"filter_kind": nil,
"has_status_filter": false,
@@ -59,13 +54,6 @@ func (f *FindingFilter) SQLArguments() pgx.StrictNamedArgs {
"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 {
args["has_kind_filter"] = true
args["filter_kind"] = string(*f.kind)
@@ -92,15 +80,6 @@ func (f *FindingFilter) SQLArguments() pgx.StrictNamedArgs {
func (f *FindingFilter) SQLFragment() string {
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
WHEN @has_kind_filter::boolean = false THEN TRUE
WHEN @has_kind_filter::boolean = true THEN
diff --git a/pkg/coredata/migrations/20260422T130000Z.sql b/pkg/coredata/migrations/20260422T130000Z.sql
new file mode 100644
index 000000000..477a82437
--- /dev/null
+++ b/pkg/coredata/migrations/20260422T130000Z.sql
@@ -0,0 +1,17 @@
+-- Copyright (c) 2026 Probo Inc .
+--
+-- 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;
diff --git a/pkg/coredata/obligation.go b/pkg/coredata/obligation.go
index 4e176c761..d78c9be2e 100644
--- a/pkg/coredata/obligation.go
+++ b/pkg/coredata/obligation.go
@@ -108,6 +108,7 @@ FROM
WHERE
%s
AND id = @obligation_id
+ AND snapshot_id IS NULL
LIMIT 1;
`
@@ -136,7 +137,6 @@ func (os *Obligations) CountByOrganizationID(
conn pg.Querier,
scope Scoper,
organizationID gid.GID,
- filter *ObligationFilter,
) (int, error) {
q := `
SELECT
@@ -146,14 +146,13 @@ FROM
WHERE
%s
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}
maps.Copy(args, scope.SQLArguments())
- maps.Copy(args, filter.SQLArguments())
row := conn.QueryRow(ctx, q, args)
@@ -171,7 +170,6 @@ func (os *Obligations) CountByRiskID(
conn pg.Querier,
scope Scoper,
riskID gid.GID,
- filter *ObligationFilter,
) (int, error) {
q := `
WITH obls AS (
@@ -186,20 +184,19 @@ WITH obls AS (
risks_obligations ro ON o.id = ro.obligation_id
WHERE
ro.risk_id = @risk_id
+ AND o.snapshot_id IS NULL
)
SELECT
COUNT(id)
FROM
obls
WHERE %s
- AND %s
`
- q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
+ q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"risk_id": riskID}
maps.Copy(args, scope.SQLArguments())
- maps.Copy(args, filter.SQLArguments())
row := conn.QueryRow(ctx, q, args)
@@ -218,7 +215,6 @@ func (os *Obligations) LoadByOrganizationID(
scope Scoper,
organizationID gid.GID,
cursor *page.Cursor[ObligationOrderField],
- filter *ObligationFilter,
) error {
q := `
SELECT
@@ -243,15 +239,14 @@ FROM
WHERE
%s
AND organization_id = @organization_id
- AND %s
+ AND snapshot_id IS NULL
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}
maps.Copy(args, scope.SQLArguments())
- maps.Copy(args, filter.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
@@ -275,7 +270,6 @@ func (os *Obligations) LoadByRiskID(
scope Scoper,
riskID gid.GID,
cursor *page.Cursor[ObligationOrderField],
- filter *ObligationFilter,
) error {
q := `
WITH obls AS (
@@ -304,6 +298,7 @@ WITH obls AS (
risks_obligations ro ON o.id = ro.obligation_id
WHERE
ro.risk_id = @risk_id
+ AND o.snapshot_id IS NULL
)
SELECT
id,
@@ -326,14 +321,12 @@ FROM
obls
WHERE %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}
maps.Copy(args, scope.SQLArguments())
- maps.Copy(args, filter.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
@@ -356,7 +349,6 @@ func (os *Obligations) CountByControlID(
conn pg.Querier,
scope Scoper,
controlID gid.GID,
- filter *ObligationFilter,
) (int, error) {
q := `
WITH obls AS (
@@ -370,20 +362,19 @@ WITH obls AS (
controls_obligations co ON o.id = co.obligation_id
WHERE
co.control_id = @control_id
+ AND o.snapshot_id IS NULL
)
SELECT
COUNT(id)
FROM
obls
WHERE %s
- AND %s
`
- q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
+ q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"control_id": controlID}
maps.Copy(args, scope.SQLArguments())
- maps.Copy(args, filter.SQLArguments())
row := conn.QueryRow(ctx, q, args)
@@ -402,7 +393,6 @@ func (os *Obligations) LoadByControlID(
scope Scoper,
controlID gid.GID,
cursor *page.Cursor[ObligationOrderField],
- filter *ObligationFilter,
) error {
q := `
WITH obls AS (
@@ -430,6 +420,7 @@ WITH obls AS (
controls_obligations co ON o.id = co.obligation_id
WHERE
co.control_id = @control_id
+ AND o.snapshot_id IS NULL
)
SELECT
id,
@@ -452,13 +443,11 @@ FROM
obls
WHERE %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}
maps.Copy(args, scope.SQLArguments())
- maps.Copy(args, filter.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
@@ -625,13 +614,15 @@ WHERE
return nil
}
-func (os Obligations) Snapshot(ctx context.Context, conn pg.Tx, scope Scoper, organizationID, snapshotID gid.GID) error {
- query := `
-INSERT INTO obligations (
+func (os *Obligations) LoadAllByOrganizationID(
+ ctx context.Context,
+ conn pg.Querier,
+ scope Scoper,
+ organizationID gid.GID,
+) error {
+ q := `
+SELECT
id,
- tenant_id,
- snapshot_id,
- source_id,
organization_id,
area,
source,
@@ -643,44 +634,142 @@ INSERT INTO obligations (
due_date,
status,
type,
+ snapshot_id,
+ source_id,
created_at,
updated_at
-)
-SELECT
- generate_gid(decode_base64_unpadded(@tenant_id), @obligation_entity_type),
- @tenant_id,
- @snapshot_id,
- o.id,
- o.organization_id,
- o.area,
- 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
- `
+FROM
+ obligations
+WHERE
+ %s
+ AND organization_id = @organization_id
+ AND snapshot_id IS NULL
+ORDER BY
+ created_at ASC
+`
- query = fmt.Sprintf(query, scope.SQLFragment())
+ q = fmt.Sprintf(q, scope.SQLFragment())
- args := pgx.StrictNamedArgs{
- "tenant_id": scope.GetTenantID(),
- "snapshot_id": snapshotID,
- "organization_id": organizationID,
- "obligation_entity_type": ObligationEntityType,
- }
+ args := pgx.StrictNamedArgs{"organization_id": organizationID}
maps.Copy(args, scope.SQLArguments())
- _, err := conn.Exec(ctx, query, args)
+ rows, err := conn.Query(ctx, q, args)
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
diff --git a/pkg/coredata/obligation_filter.go b/pkg/coredata/obligation_filter.go
deleted file mode 100644
index 36b1b8f44..000000000
--- a/pkg/coredata/obligation_filter.go
+++ /dev/null
@@ -1,54 +0,0 @@
-// Copyright (c) 2025-2026 Probo Inc .
-//
-// 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"
- }
-}
diff --git a/pkg/coredata/snapshots_type.go b/pkg/coredata/snapshots_type.go
index 4972714e1..eaaf457a6 100644
--- a/pkg/coredata/snapshots_type.go
+++ b/pkg/coredata/snapshots_type.go
@@ -38,8 +38,6 @@ func SnapshotsTypes() []SnapshotsType {
return []SnapshotsType{
SnapshotsTypeRisks,
SnapshotsTypeVendors,
- SnapshotsTypeFindings,
- SnapshotsTypeObligations,
SnapshotsTypeProcessingActivities,
}
}
diff --git a/pkg/coredata/snapshottable.go b/pkg/coredata/snapshottable.go
index 91855a7ac..8436d17c1 100644
--- a/pkg/coredata/snapshottable.go
+++ b/pkg/coredata/snapshottable.go
@@ -30,10 +30,6 @@ func GetSnapshottable(snapshotType SnapshotsType) (Snapshottable, error) {
switch snapshotType {
case SnapshotsTypeRisks:
return Risks{}, nil
- case SnapshotsTypeFindings:
- return Findings{}, nil
- case SnapshotsTypeObligations:
- return Obligations{}, nil
case SnapshotsTypeProcessingActivities:
return ProcessingActivities{}, nil
case SnapshotsTypeVendors:
diff --git a/pkg/docgen/generator.go b/pkg/docgen/generator.go
index f567f9bd8..6fc086533 100644
--- a/pkg/docgen/generator.go
+++ b/pkg/docgen/generator.go
@@ -338,6 +338,49 @@ type (
Owner 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 {
diff --git a/pkg/probo/actions.go b/pkg/probo/actions.go
index 8d6d7ae3d..71ab9744a 100644
--- a/pkg/probo/actions.go
+++ b/pkg/probo/actions.go
@@ -264,13 +264,15 @@ const (
ActionFindingDelete = "core:finding:delete"
ActionFindingAuditMappingCreate = "core:finding:create-audit-mapping"
ActionFindingAuditMappingDelete = "core:finding:delete-audit-mapping"
+ ActionFindingPublish = "core:finding:publish"
// Obligation actions
- ActionObligationGet = "core:obligation:get"
- ActionObligationList = "core:obligation:list"
- ActionObligationCreate = "core:obligation:create"
- ActionObligationUpdate = "core:obligation:update"
- ActionObligationDelete = "core:obligation:delete"
+ ActionObligationGet = "core:obligation:get"
+ ActionObligationList = "core:obligation:list"
+ ActionObligationCreate = "core:obligation:create"
+ ActionObligationUpdate = "core:obligation:update"
+ ActionObligationDelete = "core:obligation:delete"
+ ActionObligationPublish = "core:obligation:publish"
// ProcessingActivity actions
ActionProcessingActivityList = "core:processing-activity:list"
diff --git a/pkg/probo/document_service.go b/pkg/probo/document_service.go
index 10b1b628f..0b01dc585 100644
--- a/pkg/probo/document_service.go
+++ b/pkg/probo/document_service.go
@@ -1295,6 +1295,16 @@ func (s *DocumentService) clearDocumentReferences(
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{}
if err := soa.ClearDocumentIDByDocumentIDs(ctx, tx, documentIDs); err != nil {
return err
diff --git a/pkg/probo/generated_document_service.go b/pkg/probo/generated_document_service.go
index 99eb15fcb..617b75dd3 100644
--- a/pkg/probo/generated_document_service.go
+++ b/pkg/probo/generated_document_service.go
@@ -936,3 +936,683 @@ func BuildStatementOfApplicabilityDocument(data docgen.StatementOfApplicabilityD
}
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
+}
diff --git a/pkg/probo/obligation_service.go b/pkg/probo/obligation_service.go
index c5f877143..4e02791b1 100644
--- a/pkg/probo/obligation_service.go
+++ b/pkg/probo/obligation_service.go
@@ -289,7 +289,6 @@ func (s *ObligationService) Delete(
func (s ObligationService) CountForOrganizationID(
ctx context.Context,
organizationID gid.GID,
- filter *coredata.ObligationFilter,
) (int, error) {
var count int
@@ -297,7 +296,7 @@ func (s ObligationService) CountForOrganizationID(
ctx,
func(ctx context.Context, conn pg.Querier) (err error) {
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 {
return fmt.Errorf("cannot count obligations: %w", err)
}
@@ -317,7 +316,6 @@ func (s ObligationService) ListForControlID(
ctx context.Context,
controlID gid.GID,
cursor *page.Cursor[coredata.ObligationOrderField],
- filter *coredata.ObligationFilter,
) (*page.Page[*coredata.Obligation, coredata.ObligationOrderField], error) {
var obligations coredata.Obligations
control := &coredata.Control{}
@@ -329,7 +327,7 @@ func (s ObligationService) ListForControlID(
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 {
return fmt.Errorf("cannot load obligations: %w", err)
}
@@ -349,14 +347,13 @@ func (s ObligationService) ListForOrganizationID(
ctx context.Context,
organizationID gid.GID,
cursor *page.Cursor[coredata.ObligationOrderField],
- filter *coredata.ObligationFilter,
) (*page.Page[*coredata.Obligation, coredata.ObligationOrderField], error) {
var obligations coredata.Obligations
err := s.svc.pg.WithConn(
ctx,
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 {
return fmt.Errorf("cannot load obligations: %w", err)
}
@@ -375,7 +372,6 @@ func (s ObligationService) ListForOrganizationID(
func (s ObligationService) CountForRiskID(
ctx context.Context,
riskID gid.GID,
- filter *coredata.ObligationFilter,
) (int, error) {
var count int
@@ -383,7 +379,7 @@ func (s ObligationService) CountForRiskID(
ctx,
func(ctx context.Context, conn pg.Querier) (err error) {
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 {
return fmt.Errorf("cannot count obligations: %w", err)
}
@@ -403,14 +399,13 @@ func (s ObligationService) ListForRiskID(
ctx context.Context,
riskID gid.GID,
cursor *page.Cursor[coredata.ObligationOrderField],
- filter *coredata.ObligationFilter,
) (*page.Page[*coredata.Obligation, coredata.ObligationOrderField], error) {
var obligations coredata.Obligations
err := s.svc.pg.WithConn(
ctx,
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 {
return fmt.Errorf("cannot load obligations: %w", err)
}
diff --git a/pkg/probo/policies.go b/pkg/probo/policies.go
index 1db81980d..e00d8cc96 100644
--- a/pkg/probo/policies.go
+++ b/pkg/probo/policies.go
@@ -171,10 +171,6 @@ var AuditorPolicy = policy.NewPolicy(
ActionEmployeeDocumentGet, ActionEmployeeDocumentList,
ActionEmployeeDocumentVersionExportPDF,
).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)")
// EmployeePolicy defines permissions for employee role.
diff --git a/pkg/probo/templates/finding_list.json.tmpl b/pkg/probo/templates/finding_list.json.tmpl
new file mode 100644
index 000000000..b6b3d07c1
--- /dev/null
+++ b/pkg/probo/templates/finding_list.json.tmpl
@@ -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." }]
+ }
+ ]
+}
diff --git a/pkg/probo/templates/obligation_list.json.tmpl b/pkg/probo/templates/obligation_list.json.tmpl
new file mode 100644
index 000000000..b7cfc8b89
--- /dev/null
+++ b/pkg/probo/templates/obligation_list.json.tmpl
@@ -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." }]
+ }
+ ]
+}
diff --git a/pkg/server/api/console/v1/audit_resolvers.go b/pkg/server/api/console/v1/audit_resolvers.go
index f9e5b2181..c0c5d93ff 100644
--- a/pkg/server/api/console/v1/audit_resolvers.go
+++ b/pkg/server/api/console/v1/audit_resolvers.go
@@ -181,10 +181,7 @@ func (r *auditResolver) Findings(ctx context.Context, obj *types.Audit, first *i
ownerID = filter.OwnerID
}
- findingFilter := coredata.NewFindingFilter(nil, kind, status, priority, ownerID)
- if filter != nil {
- findingFilter = coredata.NewFindingFilter(&filter.SnapshotID, kind, status, priority, ownerID)
- }
+ findingFilter := coredata.NewFindingFilter(kind, status, priority, ownerID)
p, err := prb.Findings.ListForAuditID(ctx, obj.ID, cursor, findingFilter)
if err != nil {
@@ -363,10 +360,7 @@ func (r *findingConnectionResolver) TotalCount(ctx context.Context, obj *types.F
ownerID = obj.Filter.OwnerID
}
- findingFilter := coredata.NewFindingFilter(nil, kind, status, priority, ownerID)
- if obj.Filter != nil {
- findingFilter = coredata.NewFindingFilter(&obj.Filter.SnapshotID, kind, status, priority, ownerID)
- }
+ findingFilter := coredata.NewFindingFilter(kind, status, priority, ownerID)
switch obj.Resolver.(type) {
case *organizationResolver:
@@ -677,6 +671,29 @@ func (r *mutationResolver) DeleteFindingAuditMapping(ctx context.Context, input
}, 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.
func (r *reportResolver) DownloadURL(ctx context.Context, obj *types.Report) (*string, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionReportDownloadUrlGet); err != nil {
diff --git a/pkg/server/api/console/v1/control_resolvers.go b/pkg/server/api/console/v1/control_resolvers.go
index 4c375e1be..064732786 100644
--- a/pkg/server/api/console/v1/control_resolvers.go
+++ b/pkg/server/api/console/v1/control_resolvers.go
@@ -13,7 +13,6 @@ import (
"github.com/vikstrous/dataloadgen"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/coredata"
- "go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/probo"
"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.
-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 {
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)
- var snapshotID **gid.GID
- if filter != nil {
- snapshotID = &filter.SnapshotID
- }
- obligationFilter := coredata.NewObligationFilter(snapshotID)
- page, err := prb.Obligations.ListForControlID(ctx, obj.ID, cursor, obligationFilter)
+ page, err := prb.Obligations.ListForControlID(ctx, obj.ID, cursor)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list control obligations", log.Error(err))
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.
diff --git a/pkg/server/api/console/v1/graphql/audit.graphql b/pkg/server/api/console/v1/graphql/audit.graphql
index 00bd0189e..76b678ee2 100644
--- a/pkg/server/api/console/v1/graphql/audit.graphql
+++ b/pkg/server/api/console/v1/graphql/audit.graphql
@@ -138,7 +138,6 @@ input FindingOrder {
}
input FindingFilter {
- snapshotId: ID
kind: FindingKind
status: FindingStatus
priority: FindingPriority
@@ -171,7 +170,7 @@ type Audit implements Node {
last: Int
before: CursorKey
orderBy: FindingOrder
- filter: FindingFilter = { snapshotId: null }
+ filter: FindingFilter
): FindingConnection @goField(forceResolver: true)
trustCenterVisibility: TrustCenterVisibility!
@@ -183,7 +182,6 @@ type Audit implements Node {
type Finding implements Node {
id: ID!
- snapshotId: ID
organization: Organization @goField(forceResolver: true)
kind: FindingKind!
referenceId: String!
@@ -270,6 +268,19 @@ extend type Mutation {
deleteFindingAuditMapping(
input: DeleteFindingAuditMappingInput!
): DeleteFindingAuditMappingPayload
+ publishFindingList(
+ input: PublishFindingListInput!
+ ): PublishFindingListPayload!
+}
+
+input PublishFindingListInput {
+ organizationId: ID!
+ approverIds: [ID!]
+}
+
+type PublishFindingListPayload {
+ documentEdge: DocumentEdge!
+ documentVersionEdge: DocumentVersionEdge!
}
input CreateAuditInput {
diff --git a/pkg/server/api/console/v1/graphql/control.graphql b/pkg/server/api/console/v1/graphql/control.graphql
index 780ebfa75..00b557dc3 100644
--- a/pkg/server/api/console/v1/graphql/control.graphql
+++ b/pkg/server/api/console/v1/graphql/control.graphql
@@ -139,7 +139,6 @@ type Control implements Node {
last: Int
before: CursorKey
orderBy: ObligationOrder
- filter: ObligationFilter
): ObligationConnection! @goField(forceResolver: true)
snapshots(
diff --git a/pkg/server/api/console/v1/graphql/obligation.graphql b/pkg/server/api/console/v1/graphql/obligation.graphql
index 5546db8eb..e24cae389 100644
--- a/pkg/server/api/console/v1/graphql/obligation.graphql
+++ b/pkg/server/api/console/v1/graphql/obligation.graphql
@@ -51,14 +51,8 @@ input ObligationOrder
field: ObligationOrderField!
}
-input ObligationFilter {
- snapshotId: ID
-}
-
type Obligation implements Node {
id: ID!
- snapshotId: ID
- sourceId: ID
organization: Organization! @goField(forceResolver: true)
area: String
source: String
@@ -94,6 +88,19 @@ extend type Mutation {
createObligation(input: CreateObligationInput!): CreateObligationPayload!
updateObligation(input: UpdateObligationInput!): UpdateObligationPayload!
deleteObligation(input: DeleteObligationInput!): DeleteObligationPayload!
+ publishObligationList(
+ input: PublishObligationListInput!
+ ): PublishObligationListPayload!
+}
+
+input PublishObligationListInput {
+ organizationId: ID!
+ approverIds: [ID!]
+}
+
+type PublishObligationListPayload {
+ documentEdge: DocumentEdge!
+ documentVersionEdge: DocumentVersionEdge!
}
input CreateObligationInput {
diff --git a/pkg/server/api/console/v1/graphql/organization.graphql b/pkg/server/api/console/v1/graphql/organization.graphql
index 3ac8e326a..fa6e61dc8 100644
--- a/pkg/server/api/console/v1/graphql/organization.graphql
+++ b/pkg/server/api/console/v1/graphql/organization.graphql
@@ -150,13 +150,15 @@ type Organization implements Node {
orderBy: AuditOrder
): AuditConnection! @goField(forceResolver: true)
+ findingsDocument: Document @goField(forceResolver: true)
+
findings(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: FindingOrder
- filter: FindingFilter = { snapshotId: null }
+ filter: FindingFilter
): FindingConnection @goField(forceResolver: true)
auditLogEntries(
@@ -247,13 +249,14 @@ type Organization implements Node {
filter: MeasureFilter
): MeasureConnection! @goField(forceResolver: true)
+ obligationsDocument: Document @goField(forceResolver: true)
+
obligations(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: ObligationOrder
- filter: ObligationFilter = { snapshotId: null }
): ObligationConnection! @goField(forceResolver: true)
processingActivities(
diff --git a/pkg/server/api/console/v1/graphql/risk.graphql b/pkg/server/api/console/v1/graphql/risk.graphql
index 3c7006640..35a6feeb7 100644
--- a/pkg/server/api/console/v1/graphql/risk.graphql
+++ b/pkg/server/api/console/v1/graphql/risk.graphql
@@ -107,7 +107,6 @@ type Risk implements Node {
last: Int
before: CursorKey
orderBy: ObligationOrder
- filter: ObligationFilter
): ObligationConnection! @goField(forceResolver: true)
createdAt: Datetime!
diff --git a/pkg/server/api/console/v1/graphql/snapshot.graphql b/pkg/server/api/console/v1/graphql/snapshot.graphql
index 858fcc1c0..b2680c8f0 100644
--- a/pkg/server/api/console/v1/graphql/snapshot.graphql
+++ b/pkg/server/api/console/v1/graphql/snapshot.graphql
@@ -3,14 +3,6 @@ enum SnapshotsType
RISKS @goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeRisks")
VENDORS
@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
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeProcessingActivities"
diff --git a/pkg/server/api/console/v1/obligation_resolvers.go b/pkg/server/api/console/v1/obligation_resolvers.go
index e2d0c1ac1..abe434740 100644
--- a/pkg/server/api/console/v1/obligation_resolvers.go
+++ b/pkg/server/api/console/v1/obligation_resolvers.go
@@ -112,6 +112,29 @@ func (r *mutationResolver) DeleteObligation(ctx context.Context, input types.Del
}, 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.
func (r *obligationResolver) Organization(ctx context.Context, obj *types.Obligation) (*types.Organization, error) {
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) {
case *organizationResolver:
- obligationFilter := coredata.NewObligationFilter(nil)
- if obj.Filter != nil {
- obligationFilter = coredata.NewObligationFilter(&obj.Filter.SnapshotID)
- }
-
- count, err := prb.Obligations.CountForOrganizationID(ctx, obj.ParentID, obligationFilter)
+ count, err := prb.Obligations.CountForOrganizationID(ctx, obj.ParentID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot count obligations", log.Error(err))
return 0, gqlutils.Internal(ctx)
}
return count, nil
case *riskResolver:
- obligationFilter := coredata.NewObligationFilter(nil)
- if obj.Filter != nil {
- obligationFilter = coredata.NewObligationFilter(&obj.Filter.SnapshotID)
- }
-
- count, err := prb.Obligations.CountForRiskID(ctx, obj.ParentID, obligationFilter)
+ count, err := prb.Obligations.CountForRiskID(ctx, obj.ParentID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot count risk obligations", log.Error(err))
return 0, gqlutils.Internal(ctx)
diff --git a/pkg/server/api/console/v1/organization_resolvers.go b/pkg/server/api/console/v1/organization_resolvers.go
index 2ec1039be..6f62b7228 100644
--- a/pkg/server/api/console/v1/organization_resolvers.go
+++ b/pkg/server/api/console/v1/organization_resolvers.go
@@ -362,6 +362,30 @@ func (r *organizationResolver) Audits(ctx context.Context, obj *types.Organizati
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.
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 {
@@ -397,10 +421,7 @@ func (r *organizationResolver) Findings(ctx context.Context, obj *types.Organiza
ownerID = filter.OwnerID
}
- findingFilter := coredata.NewFindingFilter(nil, kind, status, priority, ownerID)
- if filter != nil {
- findingFilter = coredata.NewFindingFilter(&filter.SnapshotID, kind, status, priority, ownerID)
- }
+ findingFilter := coredata.NewFindingFilter(kind, status, priority, ownerID)
page, err := prb.Findings.ListForOrganizationID(ctx, obj.ID, cursor, findingFilter)
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
}
+// 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.
-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 {
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)
- obligationFilter := coredata.NewObligationFilter(nil)
- if filter != nil {
- obligationFilter = coredata.NewObligationFilter(&filter.SnapshotID)
- }
-
- page, err := prb.Obligations.ListForOrganizationID(ctx, obj.ID, cursor, obligationFilter)
+ page, err := prb.Obligations.ListForOrganizationID(ctx, obj.ID, cursor)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list organization obligations", log.Error(err))
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.
diff --git a/pkg/server/api/console/v1/risk_resolvers.go b/pkg/server/api/console/v1/risk_resolvers.go
index e7eaad287..5712a0f28 100644
--- a/pkg/server/api/console/v1/risk_resolvers.go
+++ b/pkg/server/api/console/v1/risk_resolvers.go
@@ -393,7 +393,7 @@ func (r *riskResolver) Controls(ctx context.Context, obj *types.Risk, first *int
}
// 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 {
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)
- var obligationFilter = coredata.NewObligationFilter(nil)
- if filter != nil {
- obligationFilter = coredata.NewObligationFilter(&filter.SnapshotID)
- }
-
- page, err := prb.Obligations.ListForRiskID(ctx, obj.ID, cursor, obligationFilter)
+ page, err := prb.Obligations.ListForRiskID(ctx, obj.ID, cursor)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list risk obligations", log.Error(err))
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.
diff --git a/pkg/server/api/console/v1/types/finding.go b/pkg/server/api/console/v1/types/finding.go
index 1838902ba..bcb9535e2 100644
--- a/pkg/server/api/console/v1/types/finding.go
+++ b/pkg/server/api/console/v1/types/finding.go
@@ -67,8 +67,7 @@ func NewFindingEdge(f *coredata.Finding, orderField coredata.FindingOrderField)
func NewFinding(f *coredata.Finding) *Finding {
finding := &Finding{
- ID: f.ID,
- SnapshotID: f.SnapshotID,
+ ID: f.ID,
Organization: &Organization{
ID: f.OrganizationID,
},
diff --git a/pkg/server/api/console/v1/types/obligation.go b/pkg/server/api/console/v1/types/obligation.go
index 94b6c75e8..bd238770a 100644
--- a/pkg/server/api/console/v1/types/obligation.go
+++ b/pkg/server/api/console/v1/types/obligation.go
@@ -30,7 +30,6 @@ type (
Resolver any
ParentID gid.GID
- Filter *ObligationFilter
}
)
@@ -38,7 +37,6 @@ func NewObligationConnection(
p *page.Page[*coredata.Obligation, coredata.ObligationOrderField],
parentType any,
parentID gid.GID,
- filter *ObligationFilter,
) *ObligationConnection {
edges := make([]*ObligationEdge, len(p.Data))
for i, obligation := range p.Data {
@@ -51,15 +49,12 @@ func NewObligationConnection(
Resolver: parentType,
ParentID: parentID,
- Filter: filter,
}
}
func NewObligation(cr *coredata.Obligation) *Obligation {
return &Obligation{
- ID: cr.ID,
- SnapshotID: cr.SnapshotID,
- SourceID: cr.SourceID,
+ ID: cr.ID,
Organization: &Organization{
ID: cr.OrganizationID,
},
diff --git a/pkg/server/api/mcp/v1/schema.resolvers.go b/pkg/server/api/mcp/v1/schema.resolvers.go
index 0b510785d..17a7e2876 100644
--- a/pkg/server/api/mcp/v1/schema.resolvers.go
+++ b/pkg/server/api/mcp/v1/schema.resolvers.go
@@ -741,11 +741,9 @@ func (r *Resolver) ListFindingsTool(ctx context.Context, req *mcp.CallToolReques
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
- noSnapshot := (*gid.GID)(nil)
- findingFilter := coredata.NewFindingFilter(&noSnapshot, nil, nil, nil, nil)
+ findingFilter := coredata.NewFindingFilter(nil, nil, nil, nil)
if input.Filter != nil {
findingFilter = coredata.NewFindingFilter(
- &input.Filter.SnapshotID,
input.Filter.Kind,
input.Filter.Status,
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)
- noSnapshot := (*gid.GID)(nil)
- obligationFilter := coredata.NewObligationFilter(&noSnapshot)
- if input.Filter != nil {
- obligationFilter = coredata.NewObligationFilter(&input.Filter.SnapshotID)
- }
-
- page, err := prb.Obligations.ListForOrganizationID(ctx, input.OrganizationID, cursor, obligationFilter)
+ page, err := prb.Obligations.ListForOrganizationID(ctx, input.OrganizationID, cursor)
if err != nil {
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)
- obligationPage, err := prb.Obligations.ListForControlID(ctx, input.ControlID, cursor, coredata.NewObligationFilter(nil))
+ obligationPage, err := prb.Obligations.ListForControlID(ctx, input.ControlID, cursor)
if err != nil {
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)
- obligationPage, err := prb.Obligations.ListForRiskID(ctx, input.RiskID, cursor, coredata.NewObligationFilter(nil))
+ obligationPage, err := prb.Obligations.ListForRiskID(ctx, input.RiskID, cursor)
if err != nil {
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
}
+
+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
+}
diff --git a/pkg/server/api/mcp/v1/specification.yaml b/pkg/server/api/mcp/v1/specification.yaml
index b89272f0b..9512a568a 100644
--- a/pkg/server/api/mcp/v1/specification.yaml
+++ b/pkg/server/api/mcp/v1/specification.yaml
@@ -2827,18 +2827,6 @@ components:
organization_id:
$ref: "#/components/schemas/GID"
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:
$ref: "#/components/schemas/FindingKind"
description: Finding kind
@@ -2929,12 +2917,6 @@ components:
filter:
type: object
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:
anyOf:
- $ref: "#/components/schemas/FindingKind"
@@ -3279,18 +3261,6 @@ components:
organization_id:
$ref: "#/components/schemas/GID"
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:
type:
- string
@@ -3363,16 +3333,6 @@ components:
cursor:
$ref: "#/components/schemas/CursorKey"
description: Page cursor
- filter:
- type: object
- properties:
- snapshot_id:
- anyOf:
- - $ref: "#/components/schemas/GID"
- - type: "null"
- description: Filter by snapshot ID. Defaults to null, which returns only obligations with no snapshot (current live data). Pass a specific snapshot ID to retrieve obligations as they were at that snapshot.
- default: null
-
ListObligationsOutput:
type: object
required:
@@ -7051,6 +7011,60 @@ components:
$ref: "#/components/schemas/GID"
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:
type: object
required:
@@ -10288,6 +10302,22 @@ tools:
$ref: "#/components/schemas/PublishAssetListInput"
outputSchema:
$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
description: Publish a statement of applicability as a document. If a document already exists, a new version is created.
hints:
diff --git a/pkg/server/api/mcp/v1/types/finding.go b/pkg/server/api/mcp/v1/types/finding.go
index 7629d61a3..60430804a 100644
--- a/pkg/server/api/mcp/v1/types/finding.go
+++ b/pkg/server/api/mcp/v1/types/finding.go
@@ -23,7 +23,6 @@ func NewFinding(f *coredata.Finding) *Finding {
finding := &Finding{
ID: f.ID,
OrganizationID: f.OrganizationID,
- SnapshotID: f.SnapshotID,
Kind: f.Kind,
ReferenceID: f.ReferenceID,
Description: f.Description,
@@ -41,11 +40,6 @@ func NewFinding(f *coredata.Finding) *Finding {
UpdatedAt: f.UpdatedAt,
}
- if f.SourceID != nil {
- s := f.SourceID.String()
- finding.SourceID = &s
- }
-
return finding
}
diff --git a/pkg/server/api/mcp/v1/types/obligation.go b/pkg/server/api/mcp/v1/types/obligation.go
index a3fb91494..8349c6614 100644
--- a/pkg/server/api/mcp/v1/types/obligation.go
+++ b/pkg/server/api/mcp/v1/types/obligation.go
@@ -23,7 +23,6 @@ func NewObligation(o *coredata.Obligation) *Obligation {
obligation := &Obligation{
ID: o.ID,
OrganizationID: o.OrganizationID,
- SnapshotID: o.SnapshotID,
Area: o.Area,
Source: o.Source,
Requirement: o.Requirement,
@@ -38,11 +37,6 @@ func NewObligation(o *coredata.Obligation) *Obligation {
UpdatedAt: o.UpdatedAt,
}
- if o.SourceID != nil {
- s := o.SourceID.String()
- obligation.SourceID = &s
- }
-
return obligation
}