Data as document: replace snapshot with publish workflow

Mirror the SOA-to-document migration for the data list. Remove data
from the snapshot system and add a publish workflow that generates a
ProseMirror document for the full organization data inventory.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-04-16 13:50:30 +02:00
parent 472ca703b5
commit 55563226cb
37 changed files with 2021 additions and 467 deletions

View File

@@ -21,11 +21,18 @@ import { graphql } from "relay-runtime";
/* eslint-disable relay/unused-fields, relay/must-colocate-fragment-spreads */
export const dataQuery = graphql`
query DatumGraphListQuery($organizationId: ID!, $snapshotId: ID = null) {
query DatumGraphListQuery($organizationId: ID!) {
node(id: $organizationId) {
... on Organization {
canCreateDatum: permission(action: "core:datum:create")
...DataPageFragment @arguments(snapshotId: $snapshotId)
canPublishData: permission(action: "core:datum:publish")
dataListDocument {
id
defaultApprovers {
id
}
}
...DataPageFragment
}
}
}
@@ -36,7 +43,6 @@ export const datumNodeQuery = graphql`
node(id: $dataId) {
... on Datum {
id
snapshotId
name
dataClassification
owner {

View File

@@ -21,8 +21,10 @@ import {
Badge,
Button,
DropdownItem,
IconPageTextLine,
IconPlusLarge,
IconTrashCan,
IconUpload,
PageHeader,
Tbody,
Td,
@@ -36,7 +38,7 @@ import {
usePaginationFragment,
usePreloadedQuery,
} from "react-relay";
import { useParams } from "react-router";
import { Link, useNavigate } from "react-router";
import type { DataListQuery } from "#/__generated__/core/DataListQuery.graphql";
import type {
@@ -44,7 +46,6 @@ import type {
DataPageFragment$key,
} from "#/__generated__/core/DataPageFragment.graphql";
import type { DatumGraphListQuery } from "#/__generated__/core/DatumGraphListQuery.graphql";
import { SnapshotBanner } from "#/components/SnapshotBanner";
import { SortableTable } from "#/components/SortableTable";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import type { NodeOf } from "#/types";
@@ -52,6 +53,7 @@ import type { NodeOf } from "#/types";
import { dataQuery, useDeleteDatum } from "../../../hooks/graph/DatumGraph";
import { CreateDatumDialog } from "./dialogs/CreateDatumDialog";
import { PublishDataListDialog } from "./dialogs/PublishDataListDialog";
const paginatedDataFragment = graphql`
fragment DataPageFragment on Organization
@@ -62,7 +64,6 @@ const paginatedDataFragment = graphql`
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
snapshotId: { type: "ID", defaultValue: null }
) {
data(
first: $first
@@ -70,8 +71,7 @@ const paginatedDataFragment = graphql`
last: $last
before: $before
orderBy: $order
filter: { snapshotId: $snapshotId }
) @connection(key: "DataPage_data", filters: ["filter"]) {
) @connection(key: "DataPage_data") {
__id
edges {
node {
@@ -107,8 +107,7 @@ type Props = {
export default function DataPage(props: Props) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
const navigate = useNavigate();
const { node: data } = usePreloadedQuery<DatumGraphListQuery>(
dataQuery,
@@ -122,6 +121,7 @@ export default function DataPage(props: Props) {
const dataEntries = pagination.data.data.edges.map(edge => edge.node);
const connectionId = pagination.data.data.__id;
const defaultApproverIds = (data.dataListDocument?.defaultApprovers ?? []).map(a => a.id);
const refetch = ({
order,
@@ -129,7 +129,6 @@ export default function DataPage(props: Props) {
order: { direction: string; field: string };
}) => {
pagination.refetch({
snapshotId,
order: {
direction: order.direction as "ASC" | "DESC",
field: order.field as "CREATED_AT" | "DATA_CLASSIFICATION" | "NAME",
@@ -140,29 +139,52 @@ export default function DataPage(props: Props) {
usePageTitle(__("Data"));
const hasAnyAction
= !isSnapshotMode
&& dataEntries.some(({ canDelete, canUpdate }) => canUpdate || canDelete);
= dataEntries.some(({ canDelete, canUpdate }) => canUpdate || canDelete);
return (
<div className="space-y-6">
{isSnapshotMode && snapshotId && (
<SnapshotBanner snapshotId={snapshotId} />
)}
<PageHeader
title={__("Data")}
description={__(
"Manage your organization's data assets and their classifications.",
)}
>
{!snapshotId && data.canCreateDatum && (
<CreateDatumDialog
connection={connectionId}
organizationId={organizationId}
onCreated={() => pagination.refetch({ snapshotId })}
>
<Button icon={IconPlusLarge}>{__("Add data")}</Button>
</CreateDatumDialog>
)}
<div className="flex gap-2">
{data.dataListDocument?.id && (
<Button variant="secondary" asChild>
<Link
to={`/organizations/${organizationId}/documents/${data.dataListDocument.id}`}
>
<IconPageTextLine size={16} />
{__("Document")}
</Link>
</Button>
)}
{data.canPublishData && (
<PublishDataListDialog
organizationId={organizationId}
defaultApproverIds={defaultApproverIds}
onPublished={(documentId) => {
void navigate(
`/organizations/${organizationId}/documents/${documentId}`,
);
}}
>
<Button variant="secondary" icon={IconUpload}>
{__("Publish")}
</Button>
</PublishDataListDialog>
)}
{data.canCreateDatum && (
<CreateDatumDialog
connection={connectionId}
organizationId={organizationId}
onCreated={() => pagination.refetch({})}
>
<Button icon={IconPlusLarge}>{__("Add data")}</Button>
</CreateDatumDialog>
)}
</div>
</PageHeader>
<SortableTable {...pagination} refetch={refetch} pageSize={10}>
<Thead>
@@ -180,7 +202,6 @@ export default function DataPage(props: Props) {
key={entry.id}
entry={entry}
connectionId={connectionId}
snapshotId={snapshotId}
hasAnyAction={hasAnyAction}
/>
))}
@@ -193,21 +214,17 @@ export default function DataPage(props: Props) {
function DataRow({
entry,
connectionId,
snapshotId,
hasAnyAction,
}: {
entry: DataEntry;
connectionId: string;
snapshotId?: string;
hasAnyAction: boolean;
}) {
const organizationId = useOrganizationId();
const { __ } = useTranslate();
const deleteDatum = useDeleteDatum(entry, connectionId);
const vendors = entry.vendors?.edges.map(edge => edge.node) ?? [];
const detailUrl = snapshotId
? `/organizations/${organizationId}/snapshots/${snapshotId}/data/${entry.id}`
: `/organizations/${organizationId}/data/${entry.id}`;
const detailUrl = `/organizations/${organizationId}/data/${entry.id}`;
return (
<Tr to={detailUrl}>

View File

@@ -12,7 +12,6 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { validateSnapshotConsistency } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
ActionDropdown,
@@ -29,14 +28,12 @@ import {
type PreloadedQuery,
usePreloadedQuery,
} from "react-relay";
import { useParams } from "react-router";
import { z } from "zod";
import type { DatumGraphNodeQuery } from "#/__generated__/core/DatumGraphNodeQuery.graphql";
import { ControlledField } from "#/components/form/ControlledField";
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
import { VendorsMultiSelectField } from "#/components/form/VendorsMultiSelectField";
import { SnapshotBanner } from "#/components/SnapshotBanner";
import {
datumNodeQuery,
useDeleteDatum,
@@ -57,9 +54,6 @@ type Props = {
};
export default function DatumDetailsPage(props: Props) {
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
const queryData = usePreloadedQuery<DatumGraphNodeQuery>(
datumNodeQuery,
props.queryRef,
@@ -67,16 +61,12 @@ export default function DatumDetailsPage(props: Props) {
const datumEntry = queryData.node;
validateSnapshotConsistency(datumEntry, snapshotId);
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const deleteDatum = useDeleteDatum(
datumEntry,
ConnectionHandler.getConnectionID(organizationId, "DataPage_data", {
filter: { snapshotId: snapshotId || null },
}),
ConnectionHandler.getConnectionID(organizationId, "DataPage_data"),
);
const vendors = datumEntry?.vendors?.edges.map(edge => edge.node) ?? [];
@@ -110,27 +100,18 @@ export default function DatumDetailsPage(props: Props) {
}
});
const breadcrumbDataUrl = isSnapshotMode
? `/organizations/${organizationId}/snapshots/${snapshotId}/data`
: `/organizations/${organizationId}/data`;
const breadcrumbItems = [
{
label: __("Data"),
to: breadcrumbDataUrl,
to: `/organizations/${organizationId}/data`,
},
{
label: datumEntry?.name || "",
},
];
const disabled = !isSnapshotMode && datumEntry.canUpdate;
return (
<div className="space-y-6">
{isSnapshotMode && snapshotId && (
<SnapshotBanner snapshotId={snapshotId} />
)}
<Breadcrumb items={breadcrumbItems} />
<div className="flex justify-between items-start">
@@ -138,7 +119,7 @@ export default function DatumDetailsPage(props: Props) {
<div className="text-2xl">{datumEntry?.name}</div>
<Badge variant="info">{datumEntry?.dataClassification}</Badge>
</div>
{!isSnapshotMode && datumEntry.canDelete && (
{datumEntry.canDelete && (
<ActionDropdown variant="secondary">
<DropdownItem
variant="danger"
@@ -156,7 +137,7 @@ export default function DatumDetailsPage(props: Props) {
label={__("Name")}
{...register("name")}
type="text"
disabled={!disabled}
disabled={!datumEntry.canUpdate}
/>
<ControlledField
@@ -164,7 +145,7 @@ export default function DatumDetailsPage(props: Props) {
name="dataClassification"
type="select"
label={__("Classification")}
disabled={!disabled}
disabled={!datumEntry.canUpdate}
>
<Option value="PUBLIC">{__("Public")}</Option>
<Option value="INTERNAL">{__("Internal")}</Option>
@@ -177,7 +158,7 @@ export default function DatumDetailsPage(props: Props) {
control={control}
name="ownerId"
label={__("Owner")}
disabled={!disabled}
disabled={!datumEntry.canUpdate}
/>
<VendorsMultiSelectField
@@ -185,19 +166,17 @@ export default function DatumDetailsPage(props: Props) {
control={control}
name="vendorIds"
label={__("Vendors")}
disabled={!disabled}
disabled={!datumEntry.canUpdate}
selectedVendors={vendors}
/>
{!isSnapshotMode && (
<div className="flex justify-end">
{formState.isDirty && datumEntry.canUpdate && (
<Button type="submit" disabled={formState.isSubmitting}>
{formState.isSubmitting ? __("Updating...") : __("Update")}
</Button>
)}
</div>
)}
<div className="flex justify-end">
{formState.isDirty && datumEntry.canUpdate && (
<Button type="submit" disabled={formState.isSubmitting}>
{formState.isSubmitting ? __("Updating...") : __("Update")}
</Button>
)}
</div>
</form>
</div>
);

View File

@@ -0,0 +1,159 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { formatError, type GraphQLError } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
IconSend,
IconUpload,
useDialogRef,
useToast,
} from "@probo/ui";
import type { ReactNode } from "react";
import { useMemo } from "react";
import { useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import { z } from "zod";
import type { PublishDataListDialogMutation } from "#/__generated__/core/PublishDataListDialogMutation.graphql";
import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
const publishMutation = graphql`
mutation PublishDataListDialogMutation(
$input: PublishDataListInput!
) {
publishDataList(input: $input) {
documentEdge {
node {
id
}
}
}
}
`;
type Props = {
children: ReactNode;
organizationId: string;
defaultApproverIds?: string[];
onPublished?: (documentId: string) => void;
};
export function PublishDataListDialog({
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<PublishDataListDialogMutation>(publishMutation);
const approverIds = watch("approverIds");
const hasApprovers = approverIds.length > 0;
const onSubmit = (data: z.infer<typeof schema>) => {
publish({
variables: {
input: {
organizationId,
approverIds: data.approverIds.length > 0 ? data.approverIds : undefined,
},
},
onCompleted(response) {
const documentId = response.publishDataList?.documentEdge?.node?.id;
if (documentId) {
toast({
title: __("Success"),
description: hasApprovers
? __("Approval requested successfully.")
: __("Data list published successfully."),
variant: "success",
});
dialogRef.current?.close();
reset();
onPublished?.(documentId);
}
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to publish data list"),
error as GraphQLError,
),
variant: "error",
});
},
});
};
return (
<Dialog
className="max-w-xl"
ref={dialogRef}
trigger={children}
title={__("Publish Data List")}
>
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
<DialogContent padded>
<div className="space-y-4">
<p className="text-sm text-txt-secondary">
{__("Select approvers to request approval before publishing, or publish directly without approvers.")}
</p>
<PeopleMultiSelectField
name="approverIds"
label={__("Approvers")}
control={control}
organizationId={organizationId}
placeholder={__("Add approvers...")}
/>
</div>
</DialogContent>
<DialogFooter>
<Button
type="submit"
icon={hasApprovers ? IconSend : IconUpload}
disabled={isPublishing}
>
{hasApprovers ? __("Request approval") : __("Publish")}
</Button>
</DialogFooter>
</form>
</Dialog>
);
}

View File

@@ -85,7 +85,7 @@ export default function SnapshotFormDialog(props: Props) {
defaultValues: {
name: "",
description: "",
type: "DATA",
type: "RISKS",
},
});

View File

@@ -32,22 +32,8 @@ export const dataRoutes = [
path: "data",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery<DatumGraphListQuery>(coreEnvironment, dataQuery, {
organizationId: organizationId,
snapshotId: null,
}),
),
Component: withQueryRef(
lazy(() => import("#/pages/organizations/data/DataPage")),
),
},
{
path: "snapshots/:snapshotId/data",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId, snapshotId }) =>
loadQuery<DatumGraphListQuery>(coreEnvironment, dataQuery, {
organizationId,
snapshotId,
}),
),
Component: withQueryRef(
@@ -66,16 +52,4 @@ export const dataRoutes = [
lazy(() => import("../pages/organizations/data/DatumDetailsPage")),
),
},
{
path: "snapshots/:snapshotId/data/:dataId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ dataId }) =>
loadQuery<DatumGraphNodeQuery>(coreEnvironment, datumNodeQuery, {
dataId,
}),
),
Component: withQueryRef(
lazy(() => import("../pages/organizations/data/DatumDetailsPage")),
),
},
] satisfies AppRoute[];

View File

@@ -0,0 +1,433 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
// Command migrate-data-snapshots-to-documents creates documents and document
// versions from existing data snapshots. For each organization that has data
// snapshots, it generates a data list document using the same ProseMirror
// builder as the publish flow.
package main
import (
"context"
"flag"
"fmt"
"net/url"
"os"
"strings"
"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 orgWithDataSnapshots struct {
organizationID gid.GID
tenantID gid.TenantID
organizationName string
}
type dataSnapshot struct {
snapshotID string
publishedAt time.Time
}
func migrate(ctx context.Context, tx pg.Tx, dryRun bool) error {
orgs, err := loadOrgsWithDataSnapshots(ctx, tx)
if err != nil {
return err
}
if len(orgs) == 0 {
fmt.Println("no organizations with data snapshots to migrate")
return nil
}
var stats struct {
documents, versions int
}
for _, org := range orgs {
snapshots, err := loadDataSnapshots(ctx, tx, org.organizationID)
if err != nil {
return err
}
if dryRun {
fmt.Printf("would migrate org %s (%s) — %d data 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, data_document_id, created_at, updated_at)
VALUES (@organization_id, @tenant_id, @data_document_id, @created_at, @updated_at)
ON CONFLICT (organization_id) DO UPDATE SET data_document_id = @data_document_id, updated_at = @updated_at`,
pgx.NamedArgs{
"organization_id": org.organizationID,
"tenant_id": org.tenantID,
"data_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,
'PORTRAIT'::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": "Data 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 loadOrgsWithDataSnapshots(ctx context.Context, tx pg.Tx) ([]orgWithDataSnapshots, 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.data_document_id IS NOT NULL
)
AND EXISTS (
SELECT 1 FROM data d
WHERE d.organization_id = o.id AND d.snapshot_id IS NOT NULL
)
ORDER BY o.created_at;
`,
)
if err != nil {
return nil, fmt.Errorf("cannot query organizations with data snapshots: %w", err)
}
defer rows.Close()
var result []orgWithDataSnapshots
for rows.Next() {
var o orgWithDataSnapshots
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 loadDataSnapshots(ctx context.Context, tx pg.Tx, organizationID gid.GID) ([]dataSnapshot, 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 = 'DATA'
ORDER BY s.created_at ASC;
`,
pgx.NamedArgs{"organization_id": organizationID},
)
if err != nil {
return nil, fmt.Errorf("cannot query data snapshots for org %s: %w", organizationID, err)
}
defer rows.Close()
var result []dataSnapshot
for rows.Next() {
var s dataSnapshot
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
d.id,
d.name,
d.data_classification,
COALESCE(p.full_name, '-')
FROM data d
LEFT JOIN iam_membership_profiles p ON p.id = d.owner_profile_id
WHERE d.snapshot_id = @snapshot_id
ORDER BY d.name ASC;
`,
pgx.NamedArgs{"snapshot_id": snapshotID},
)
if err != nil {
return "", fmt.Errorf("cannot load snapshot data: %w", err)
}
defer rows.Close()
type datumInfo struct {
id string
name string
classification string
ownerName string
}
var data []datumInfo
for rows.Next() {
var d datumInfo
if err := rows.Scan(&d.id, &d.name, &d.classification, &d.ownerName); err != nil {
return "", fmt.Errorf("cannot scan datum: %w", err)
}
data = append(data, d)
}
if err := rows.Err(); err != nil {
return "", err
}
// Load vendors for each datum in this snapshot.
vendorRows, err := tx.Query(
ctx,
`
SELECT
dv.datum_id,
v.name
FROM data_vendors dv
JOIN vendors v ON v.id = dv.vendor_id
WHERE dv.snapshot_id = @snapshot_id
ORDER BY v.name ASC;
`,
pgx.NamedArgs{"snapshot_id": snapshotID},
)
if err != nil {
return "", fmt.Errorf("cannot load snapshot data vendors: %w", err)
}
defer vendorRows.Close()
vendorsByDatum := make(map[string][]string)
for vendorRows.Next() {
var datumID, vendorName string
if err := vendorRows.Scan(&datumID, &vendorName); err != nil {
return "", fmt.Errorf("cannot scan vendor: %w", err)
}
vendorsByDatum[datumID] = append(vendorsByDatum[datumID], vendorName)
}
if err := vendorRows.Err(); err != nil {
return "", err
}
dataRows := make([]docgen.DataListRow, len(data))
for i, d := range data {
vendors := "-"
if v, ok := vendorsByDatum[d.id]; ok && len(v) > 0 {
vendors = strings.Join(v, ", ")
}
dataRows[i] = docgen.DataListRow{
Name: d.name,
Classification: formatClassificationString(d.classification),
Owner: d.ownerName,
Vendors: vendors,
}
}
docData := docgen.DataListData{
Title: "Data List",
OrganizationName: orgName,
CreatedAt: publishedAt,
TotalData: len(dataRows),
Rows: dataRows,
}
return probo.BuildDataListDocument(docData)
}
func formatClassificationString(c string) string {
switch c {
case "PUBLIC":
return "Public"
case "INTERNAL":
return "Internal"
case "CONFIDENTIAL":
return "Confidential"
case "SECRET":
return "Secret"
default:
return c
}
}
func newPgClientFromDSN(dsn string) (*pg.Client, error) {
u, err := url.Parse(dsn)
if err != nil {
return nil, fmt.Errorf("cannot parse DSN")
}
var opts []pg.Option
if u.Host != "" {
opts = append(opts, pg.WithAddr(u.Host))
}
if u.User != nil {
opts = append(opts, pg.WithUser(u.User.Username()))
if password, ok := u.User.Password(); ok {
opts = append(opts, pg.WithPassword(password))
}
}
if len(u.Path) > 1 {
opts = append(opts, pg.WithDatabase(u.Path[1:]))
}
return pg.NewClient(opts...)
}

View File

@@ -0,0 +1,370 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package console_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.probo.inc/probo/e2e/internal/factory"
"go.probo.inc/probo/e2e/internal/testutil"
)
func TestDatum_PublishDataList(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)
factory.NewDatum(owner, owner.GetProfileID().String()).
WithName("Test Data Item").
Create()
const query = `
mutation($input: PublishDataListInput!) {
publishDataList(input: $input) {
documentEdge {
node {
id
writeMode
status
}
}
documentVersionEdge {
node {
id
title
documentType
status
major
minor
content
}
}
}
}
`
var result struct {
PublishDataList 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:"publishDataList"`
}
err := owner.Execute(
query,
map[string]any{
"input": map[string]any{
"organizationId": owner.GetOrganizationID(),
},
},
&result,
)
require.NoError(t, err)
doc := result.PublishDataList.DocumentEdge.Node
assert.NotEmpty(t, doc.ID)
assert.Equal(t, "GENERATED", doc.WriteMode)
assert.Equal(t, "ACTIVE", doc.Status)
ver := result.PublishDataList.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 Data Item")
},
)
t.Run(
"publish with approvers creates draft with quorum",
func(t *testing.T) {
t.Parallel()
const query = `
mutation($input: PublishDataListInput!) {
publishDataList(input: $input) {
documentEdge {
node {
id
writeMode
}
}
documentVersionEdge {
node {
id
status
major
}
}
}
}
`
var result struct {
PublishDataList 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:"publishDataList"`
}
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.PublishDataList.DocumentEdge.Node
assert.NotEmpty(t, doc.ID)
assert.Equal(t, "GENERATED", doc.WriteMode)
ver := result.PublishDataList.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)
factory.NewDatum(secondOwner, secondOwner.GetProfileID().String()).
WithName("Reuse Test Data").
Create()
const query = `
mutation($input: PublishDataListInput!) {
publishDataList(input: $input) {
documentEdge {
node { id }
}
documentVersionEdge {
node { id major }
}
}
}
`
var result1, result2 struct {
PublishDataList 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:"publishDataList"`
}
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.PublishDataList.DocumentEdge.Node.ID
doc2 := result2.PublishDataList.DocumentEdge.Node.ID
assert.Equal(t, doc1, doc2, "should reuse same document")
ver1Major := result1.PublishDataList.DocumentVersionEdge.Node.Major
ver2Major := result2.PublishDataList.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)
factory.NewDatum(thirdOwner, thirdOwner.GetProfileID().String()).
WithName("Link Test Data").
Create()
const publishQuery = `
mutation($input: PublishDataListInput!) {
publishDataList(input: $input) {
documentEdge {
node { id }
}
documentVersionEdge {
node { id }
}
}
}
`
var publishResult struct {
PublishDataList 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:"publishDataList"`
}
err := thirdOwner.Execute(
publishQuery,
map[string]any{
"input": map[string]any{
"organizationId": thirdOwner.GetOrganizationID(),
},
},
&publishResult,
)
require.NoError(t, err)
docID := publishResult.PublishDataList.DocumentEdge.Node.ID
const orgQuery = `
query($id: ID!) {
node(id: $id) {
... on Organization {
id
dataListDocument { id }
}
}
}
`
var orgResult struct {
Node struct {
ID string `json:"id"`
DataListDocument *struct {
ID string `json:"id"`
} `json:"dataListDocument"`
} `json:"node"`
}
err = thirdOwner.Execute(
orgQuery,
map[string]any{"id": thirdOwner.GetOrganizationID()},
&orgResult,
)
require.NoError(t, err)
require.NotNil(t, orgResult.Node.DataListDocument)
assert.Equal(t, docID, orgResult.Node.DataListDocument.ID)
},
)
}
func TestDatum_PublishDataList_RBAC(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
factory.NewDatum(owner, owner.GetProfileID().String()).
WithName("RBAC Test Data").
Create()
const query = `
mutation($input: PublishDataListInput!) {
publishDataList(input: $input) {
documentEdge {
node { id }
}
documentVersionEdge {
node { id }
}
}
}
`
t.Run(
"viewer cannot publish data list",
func(t *testing.T) {
t.Parallel()
err := viewer.ExecuteShouldFail(
query,
map[string]any{
"input": map[string]any{
"organizationId": owner.GetOrganizationID(),
},
},
)
testutil.RequireForbiddenError(t, err)
},
)
}

View File

@@ -140,7 +140,7 @@ func TestSnapshot_List(t *testing.T) {
owner := testutil.NewClient(t, testutil.RoleOwner)
// Create multiple snapshots
snapshotTypes := []string{"RISKS", "VENDORS", "ASSETS", "DATA"}
snapshotTypes := []string{"RISKS", "VENDORS", "ASSETS"}
for i, snapshotType := range snapshotTypes {
query := `
mutation CreateSnapshot($input: CreateSnapshotInput!) {
@@ -212,14 +212,14 @@ func TestSnapshot_List(t *testing.T) {
"id": owner.GetOrganizationID().String(),
}, &result)
require.NoError(t, err)
assert.GreaterOrEqual(t, result.Node.Snapshots.TotalCount, 4)
assert.GreaterOrEqual(t, result.Node.Snapshots.TotalCount, len(snapshotTypes))
}
func TestSnapshot_Types(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
snapshotTypes := []string{"RISKS", "VENDORS", "ASSETS", "DATA"}
snapshotTypes := []string{"RISKS", "VENDORS", "ASSETS"}
for _, snapshotType := range snapshotTypes {
t.Run(snapshotType, func(t *testing.T) {

1
package-lock.json generated
View File

@@ -18190,6 +18190,7 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}

View File

@@ -18,7 +18,6 @@ export const snapshotTypes = [
"RISKS",
"VENDORS",
"ASSETS",
"DATA",
"FINDINGS",
"OBLIGATIONS",
"PROCESSING_ACTIVITIES",
@@ -36,8 +35,6 @@ export function getSnapshotTypeLabel(__: Translator, type: string | null | undef
return __("Vendors");
case "ASSETS":
return __("Assets");
case "DATA":
return __("Data");
case "FINDINGS":
case "NONCONFORMITIES":
case "CONTINUAL_IMPROVEMENTS":
@@ -59,8 +56,6 @@ export function getSnapshotTypeUrlPath(type?: string): string {
return "/vendors";
case "ASSETS":
return "/assets";
case "DATA":
return "/data";
case "FINDINGS":
case "NONCONFORMITIES":
case "CONTINUAL_IMPROVEMENTS":

View File

@@ -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 data',
action: 'Get many data',
},
{
name: 'Publish',
value: 'publish',
description: 'Publish the data list as a document',
action: 'Publish the data 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 };

View File

@@ -0,0 +1,101 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { proboApiRequest } from '../../GenericFunctions';
export const description: INodeProperties[] = [
{
displayName: 'Organization ID',
name: 'organizationId',
type: 'string',
displayOptions: {
show: {
resource: ['datum'],
operation: ['publish'],
},
},
default: '',
description: 'The ID of the organization whose data list to publish',
required: true,
},
{
displayName: 'Approver IDs',
name: 'approverIds',
type: 'string',
displayOptions: {
show: {
resource: ['datum'],
operation: ['publish'],
},
},
default: '',
description: 'Comma-separated list of approver profile IDs',
},
];
export async function execute(
this: IExecuteFunctions,
itemIndex: number,
): Promise<INodeExecutionData> {
const organizationId = this.getNodeParameter('organizationId', itemIndex) as string;
const approverIds = this.getNodeParameter('approverIds', itemIndex, '') as string;
const query = `
mutation PublishDataList($input: PublishDataListInput!) {
publishDataList(input: $input) {
documentEdge {
node {
id
status
currentPublishedMajor
currentPublishedMinor
createdAt
updatedAt
}
}
documentVersionEdge {
node {
id
title
major
minor
status
classification
documentType
publishedAt
createdAt
updatedAt
}
}
}
}
`;
const input: Record<string, unknown> = { organizationId };
if (approverIds) {
input.approverIds = approverIds
.split(',')
.map(id => id.trim())
.filter(Boolean);
}
const responseData = await proboApiRequest.call(this, query, { input });
return {
json: responseData,
pairedItem: { item: itemIndex },
};
}

View File

@@ -1,4 +1,4 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
@@ -12,43 +12,21 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package coredata
package datum
import (
"github.com/jackc/pgx/v5"
"go.probo.inc/probo/pkg/gid"
"github.com/spf13/cobra"
"go.probo.inc/probo/pkg/cmd/cmdutil"
"go.probo.inc/probo/pkg/cmd/datum/publish"
)
type (
DatumFilter struct {
snapshotID **gid.GID
func NewCmdDatum(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "datum <command>",
Short: "Manage data",
}
)
func NewDatumFilter(snapshotID **gid.GID) *DatumFilter {
return &DatumFilter{
snapshotID: snapshotID,
}
}
func (f *DatumFilter) SQLArguments() pgx.NamedArgs {
args := pgx.NamedArgs{}
if f.snapshotID != nil && *f.snapshotID != nil {
args["filter_snapshot_id"] = **f.snapshotID
}
return args
}
func (f *DatumFilter) SQLFragment() string {
if f.snapshotID == nil {
return "TRUE"
}
if *f.snapshotID == nil {
return "snapshot_id IS NULL"
} else {
return "snapshot_id = @filter_snapshot_id"
}
cmd.AddCommand(publish.NewCmdPublish(f))
return cmd
}

View File

@@ -0,0 +1,147 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package publish
import (
"encoding/json"
"fmt"
"github.com/spf13/cobra"
"go.probo.inc/probo/pkg/cli/api"
"go.probo.inc/probo/pkg/cmd/cmdutil"
)
const publishMutation = `
mutation($input: PublishDataListInput!) {
publishDataList(input: $input) {
documentEdge {
node {
id
status
createdAt
}
}
documentVersionEdge {
node {
id
title
major
minor
status
}
}
}
}
`
type publishResponse struct {
PublishDataList 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:"publishDataList"`
}
func NewCmdPublish(f *cmdutil.Factory) *cobra.Command {
var (
flagOrg string
flagApprover []string
)
cmd := &cobra.Command{
Use: "publish",
Short: "Publish the data list as a document version",
Example: ` # Publish the data list
prb datum publish --org ORG_ID
# Publish with approvers
prb datum 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.PublishDataList.DocumentVersionEdge.Node
_, _ = fmt.Fprintf(
f.IOStreams.Out,
"Published data list %s (v%d.%d)\n",
v.Title,
v.Major,
v.Minor,
)
return nil
},
}
cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID")
cmd.Flags().StringArrayVar(&flagApprover, "approver", nil, "Approver profile ID (can be repeated)")
return cmd
}

View File

@@ -26,6 +26,7 @@ import (
cmdconfig "go.probo.inc/probo/pkg/cmd/config"
cmdcontext "go.probo.inc/probo/pkg/cmd/context"
"go.probo.inc/probo/pkg/cmd/control"
"go.probo.inc/probo/pkg/cmd/datum"
"go.probo.inc/probo/pkg/cmd/document"
"go.probo.inc/probo/pkg/cmd/evidence"
"go.probo.inc/probo/pkg/cmd/finding"
@@ -77,6 +78,7 @@ func NewCmdRoot(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(cmdconfig.NewCmdConfig(f))
cmd.AddCommand(cmdcontext.NewCmdContext(f))
cmd.AddCommand(control.NewCmdControl(f))
cmd.AddCommand(datum.NewCmdDatum(f))
cmd.AddCommand(document.NewCmdDocument(f))
cmd.AddCommand(evidence.NewCmdEvidence(f))
cmd.AddCommand(finding.NewCmdFinding(f))

View File

@@ -34,17 +34,11 @@ type (
OrganizationID gid.GID `db:"organization_id"`
OwnerID gid.GID `db:"owner_profile_id"`
DataClassification DataClassification `db:"data_classification"`
SnapshotID *gid.GID `db:"snapshot_id"`
SourceID *gid.GID `db:"source_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
Data []*Datum
DataSnapshotter interface {
InsertDataSnapshots(ctx context.Context, conn pg.Tx, scope Scoper, organizationID, snapshotID gid.GID) error
}
)
func (d *Datum) CursorKey(field DatumOrderField) page.CursorKey {
@@ -88,8 +82,6 @@ SELECT
owner_profile_id,
organization_id,
data_classification,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -97,6 +89,7 @@ FROM
WHERE
%s
AND id = @data_id
AND snapshot_id IS NULL
LIMIT 1;
`
@@ -132,8 +125,6 @@ SELECT
owner_profile_id,
organization_id,
data_classification,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -141,6 +132,7 @@ FROM
WHERE
%s
AND owner_profile_id = @owner_profile_id
AND snapshot_id IS NULL
LIMIT 1;
`
@@ -169,7 +161,6 @@ func (d *Data) CountByOrganizationID(
conn pg.Querier,
scope Scoper,
organizationID gid.GID,
filter *DatumFilter,
) (int, error) {
q := `
SELECT
@@ -179,14 +170,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)
@@ -205,7 +195,6 @@ func (d *Data) LoadByOrganizationID(
scope Scoper,
organizationID gid.GID,
cursor *page.Cursor[DatumOrderField],
filter *DatumFilter,
) error {
q := `
SELECT
@@ -214,8 +203,6 @@ SELECT
organization_id,
owner_profile_id,
data_classification,
snapshot_id,
source_id,
created_at,
updated_at
FROM
@@ -223,15 +210,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)
@@ -249,6 +235,51 @@ WHERE
return nil
}
func (d *Data) LoadAllByOrganizationID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
organizationID gid.GID,
) error {
q := `
SELECT
id,
name,
organization_id,
owner_profile_id,
data_classification,
created_at,
updated_at
FROM
data
WHERE
%s
AND organization_id = @organization_id
AND snapshot_id IS NULL
ORDER BY
name 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 data: %w", err)
}
data, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Datum])
if err != nil {
return fmt.Errorf("cannot collect data: %w", err)
}
*d = data
return nil
}
func (d *Datum) Insert(
ctx context.Context,
conn pg.Tx,
@@ -262,8 +293,6 @@ INSERT INTO data (
owner_profile_id,
organization_id,
data_classification,
snapshot_id,
source_id,
created_at,
updated_at
) VALUES (
@@ -273,8 +302,6 @@ INSERT INTO data (
@owner_profile_id,
@organization_id,
@data_classification,
@snapshot_id,
@source_id,
@created_at,
@updated_at
)
@@ -287,8 +314,6 @@ INSERT INTO data (
"owner_profile_id": d.OwnerID,
"organization_id": d.OrganizationID,
"data_classification": d.DataClassification,
"snapshot_id": d.SnapshotID,
"source_id": d.SourceID,
"created_at": d.CreatedAt,
"updated_at": d.UpdatedAt,
}
@@ -323,8 +348,6 @@ RETURNING
owner_profile_id,
organization_id,
data_classification,
snapshot_id,
source_id,
created_at,
updated_at
`
@@ -380,73 +403,3 @@ WHERE
return nil
}
func (d Data) Snapshot(ctx context.Context, conn pg.Tx, scope Scoper, organizationID, snapshotID gid.GID) error {
snapshotters := []DataSnapshotter{Data{}, Vendors{}, DatumVendors{}}
for _, snapshotter := range snapshotters {
if err := snapshotter.InsertDataSnapshots(ctx, conn, scope, organizationID, snapshotID); err != nil {
return fmt.Errorf("cannot create data snapshots: (%T) %w", snapshotter, err)
}
}
return nil
}
func (d Data) InsertDataSnapshots(
ctx context.Context,
conn pg.Tx,
scope Scoper,
organizationID gid.GID,
snapshotID gid.GID,
) error {
query := `
WITH
source_data AS (
SELECT *
FROM data
WHERE %s AND organization_id = @organization_id AND snapshot_id IS NULL
)
INSERT INTO data (
tenant_id,
id,
snapshot_id,
source_id,
name,
organization_id,
owner_profile_id,
data_classification,
created_at,
updated_at
)
SELECT
@tenant_id,
generate_gid(decode_base64_unpadded(@tenant_id), @datum_entity_type),
@snapshot_id,
d.id,
d.name,
d.organization_id,
d.owner_profile_id,
d.data_classification,
d.created_at,
d.updated_at
FROM source_data d
`
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"snapshot_id": snapshotID,
"organization_id": organizationID,
"datum_entity_type": DatumEntityType,
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, query, args)
if err != nil {
return fmt.Errorf("cannot insert data snapshots: %w", err)
}
return nil
}

View File

@@ -17,7 +17,6 @@ package coredata
import (
"context"
"fmt"
"maps"
"time"
"github.com/jackc/pgx/v5"
@@ -27,10 +26,9 @@ import (
type (
DatumVendor struct {
DatumID gid.GID `db:"datum_id"`
VendorID gid.GID `db:"vendor_id"`
SnapshotID *gid.GID `db:"snapshot_id"`
CreatedAt time.Time `db:"created_at"`
DatumID gid.GID `db:"datum_id"`
VendorID gid.GID `db:"vendor_id"`
CreatedAt time.Time `db:"created_at"`
}
DatumVendors []*DatumVendor
@@ -119,61 +117,3 @@ FROM vendor_ids
return nil
}
func (d DatumVendors) InsertDataSnapshots(
ctx context.Context,
conn pg.Tx,
scope Scoper,
organizationID gid.GID,
snapshotID gid.GID,
) error {
query := `
WITH
source_data AS (
SELECT id
FROM data
WHERE organization_id = @organization_id AND snapshot_id IS NULL
),
snapshot_data AS (
SELECT id, source_id
FROM data
WHERE organization_id = @organization_id AND snapshot_id = @snapshot_id
),
snapshot_vendors AS (
SELECT id, source_id
FROM vendors
WHERE organization_id = @organization_id AND snapshot_id = @snapshot_id
),
source_data_vendors AS (
SELECT datum_id, vendor_id, snapshot_id, created_at
FROM data_vendors
WHERE %s AND datum_id = ANY(SELECT id FROM source_data)
)
INSERT INTO data_vendors (tenant_id, datum_id, vendor_id, organization_id, snapshot_id, created_at)
SELECT
@tenant_id,
sd.id,
sv.id,
@organization_id,
@snapshot_id,
dv.created_at
FROM source_data_vendors dv
JOIN snapshot_data sd ON sd.source_id = dv.datum_id
JOIN snapshot_vendors sv ON sv.source_id = dv.vendor_id
`
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"snapshot_id": snapshotID,
"organization_id": organizationID,
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, query, args)
if err != nil {
return fmt.Errorf("cannot insert datum vendor snapshots: %w", err)
}
return nil
}

View File

@@ -0,0 +1,22 @@
-- Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
--
-- Permission to use, copy, modify, and/or distribute this software for any
-- purpose with or without fee is hereby granted, provided that the above
-- copyright notice and this permission notice appear in all copies.
--
-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
-- PERFORMANCE OF THIS SOFTWARE.
CREATE TABLE generated_documents (
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
tenant_id TEXT NOT NULL,
data_document_id TEXT REFERENCES documents(id) ON DELETE SET NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL,
PRIMARY KEY (organization_id)
);

View File

@@ -124,7 +124,7 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND type != 'STATEMENTS_OF_APPLICABILITY'
AND type NOT IN ('STATEMENTS_OF_APPLICABILITY', 'DATA')
AND %s
`
@@ -164,7 +164,7 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND type != 'STATEMENTS_OF_APPLICABILITY'
AND type NOT IN ('STATEMENTS_OF_APPLICABILITY', 'DATA')
AND %s
`

View File

@@ -39,7 +39,6 @@ func SnapshotsTypes() []SnapshotsType {
SnapshotsTypeRisks,
SnapshotsTypeVendors,
SnapshotsTypeAssets,
SnapshotsTypeData,
SnapshotsTypeFindings,
SnapshotsTypeObligations,
SnapshotsTypeProcessingActivities,

View File

@@ -32,8 +32,6 @@ func GetSnapshottable(snapshotType SnapshotsType) (Snapshottable, error) {
return Assets{}, nil
case SnapshotsTypeRisks:
return Risks{}, nil
case SnapshotsTypeData:
return Data{}, nil
case SnapshotsTypeFindings:
return Findings{}, nil
case SnapshotsTypeObligations:

View File

@@ -717,6 +717,102 @@ WHERE %s
return count, nil
}
func (vs *Vendors) LoadAllByDatumID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
datumID gid.GID,
) error {
q := `
WITH vend AS (
SELECT
v.id,
v.tenant_id,
v.organization_id,
v.name,
v.description,
v.category,
v.headquarter_address,
v.legal_name,
v.website_url,
v.privacy_policy_url,
v.service_level_agreement_url,
v.data_processing_agreement_url,
v.business_associate_agreement_url,
v.subprocessors_list_url,
v.certifications,
v.countries,
v.business_owner_profile_id,
v.security_owner_profile_id,
v.status_page_url,
v.terms_of_service_url,
v.security_page_url,
v.trust_page_url,
v.show_on_trust_center,
v.snapshot_id,
v.source_id,
v.created_at,
v.updated_at
FROM
vendors v
INNER JOIN
data_vendors dv ON v.id = dv.vendor_id
WHERE
dv.datum_id = @datum_id
)
SELECT
id,
tenant_id,
organization_id,
name,
description,
category,
headquarter_address,
legal_name,
website_url,
privacy_policy_url,
service_level_agreement_url,
data_processing_agreement_url,
business_associate_agreement_url,
subprocessors_list_url,
certifications,
countries,
business_owner_profile_id,
security_owner_profile_id,
status_page_url,
terms_of_service_url,
security_page_url,
trust_page_url,
show_on_trust_center,
snapshot_id,
source_id,
created_at,
updated_at
FROM
vend
WHERE %s
ORDER BY name ASC
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"datum_id": datumID}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query vendors: %w", err)
}
vendors, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Vendor])
if err != nil {
return fmt.Errorf("cannot collect vendors: %w", err)
}
*vs = vendors
return nil
}
func (vs *Vendors) LoadByDatumID(
ctx context.Context,
conn pg.Querier,
@@ -981,108 +1077,6 @@ ORDER BY
return vendorMap, nil
}
func (d Vendors) InsertDataSnapshots(
ctx context.Context,
conn pg.Tx,
scope Scoper,
organizationID gid.GID,
snapshotID gid.GID,
) error {
query := `
WITH
source_data AS (
SELECT id
FROM data
WHERE organization_id = @organization_id AND snapshot_id IS NULL
),
source_data_vendors AS (
SELECT datum_id, vendor_id, snapshot_id, created_at
FROM data_vendors
WHERE datum_id = ANY(SELECT id FROM source_data)
),
source_vendors AS (
SELECT *
FROM vendors
WHERE %s AND id = ANY(SELECT vendor_id FROM source_data_vendors)
)
INSERT INTO vendors (
tenant_id,
id,
snapshot_id,
source_id,
organization_id,
name,
description,
category,
headquarter_address,
legal_name,
website_url,
privacy_policy_url,
service_level_agreement_url,
data_processing_agreement_url,
business_associate_agreement_url,
subprocessors_list_url,
certifications,
countries,
business_owner_profile_id,
security_owner_profile_id,
status_page_url,
terms_of_service_url,
security_page_url,
trust_page_url,
show_on_trust_center,
created_at,
updated_at
)
SELECT
@tenant_id,
generate_gid(decode_base64_unpadded(@tenant_id), @vendor_entity_type),
@snapshot_id,
v.id,
v.organization_id,
v.name,
v.description,
v.category,
v.headquarter_address,
v.legal_name,
v.website_url,
v.privacy_policy_url,
v.service_level_agreement_url,
v.data_processing_agreement_url,
v.business_associate_agreement_url,
v.subprocessors_list_url,
v.certifications,
v.countries,
v.business_owner_profile_id,
v.security_owner_profile_id,
v.status_page_url,
v.terms_of_service_url,
v.security_page_url,
v.trust_page_url,
v.show_on_trust_center,
v.created_at,
v.updated_at
FROM source_vendors v
`
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"snapshot_id": snapshotID,
"organization_id": organizationID,
"vendor_entity_type": VendorEntityType,
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, query, args)
if err != nil {
return fmt.Errorf("cannot insert vendor snapshots: %w", err)
}
return nil
}
func (vs Vendors) InsertAssetSnapshots(
ctx context.Context,
conn pg.Tx,

View File

@@ -296,6 +296,21 @@ type (
BestPractice string
RiskAssessment string
}
DataListData struct {
Title string
OrganizationName string
CreatedAt time.Time
TotalData int
Rows []DataListRow
}
DataListRow struct {
Name string
Classification string
Owner string
Vendors string
}
)
func BoolLabel(v bool) string {

View File

@@ -234,11 +234,12 @@ const (
ActionAssetDelete = "core:asset:delete"
// Datum actions
ActionDatumGet = "core:datum:get"
ActionDatumList = "core:datum:list"
ActionDatumCreate = "core:datum:create"
ActionDatumUpdate = "core:datum:update"
ActionDatumDelete = "core:datum:delete"
ActionDatumGet = "core:datum:get"
ActionDatumList = "core:datum:list"
ActionDatumCreate = "core:datum:create"
ActionDatumUpdate = "core:datum:update"
ActionDatumDelete = "core:datum:delete"
ActionDatumPublish = "core:datum:publish"
// Audit actions
ActionAuditGet = "core:audit:get"

View File

@@ -119,7 +119,6 @@ func (s DatumService) GetByOwnerID(
func (s DatumService) CountForOrganizationID(
ctx context.Context,
organizationID gid.GID,
filter *coredata.DatumFilter,
) (int, error) {
var count int
@@ -127,7 +126,7 @@ func (s DatumService) CountForOrganizationID(
ctx,
func(ctx context.Context, conn pg.Querier) (err error) {
data := coredata.Data{}
count, err = data.CountByOrganizationID(ctx, conn, s.svc.scope, organizationID, filter)
count, err = data.CountByOrganizationID(ctx, conn, s.svc.scope, organizationID)
if err != nil {
return fmt.Errorf("cannot count data: %w", err)
}
@@ -147,7 +146,6 @@ func (s DatumService) ListForOrganizationID(
ctx context.Context,
organizationID gid.GID,
cursor *page.Cursor[coredata.DatumOrderField],
filter *coredata.DatumFilter,
) (*page.Page[*coredata.Datum, coredata.DatumOrderField], error) {
var data coredata.Data
@@ -160,7 +158,6 @@ func (s DatumService) ListForOrganizationID(
s.svc.scope,
organizationID,
cursor,
filter,
)
},
)

View File

@@ -20,9 +20,11 @@ import (
"encoding/json"
"errors"
"fmt"
"strings"
"text/template"
"time"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/docgen"
@@ -339,6 +341,322 @@ func (s *GeneratedDocumentService) buildStatementOfApplicabilityDocumentData(
}, nil
}
func (s *GeneratedDocumentService) PublishDataList(
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.buildDataListDocumentData(ctx, tx, organization)
if err != nil {
return fmt.Errorf("cannot build document data: %w", err)
}
prosemirrorJSON, err := BuildDataListDocument(documentData)
if err != nil {
return fmt.Errorf("cannot build prosemirror document: %w", err)
}
now := time.Now()
var dataDocumentID *gid.GID
err = tx.QueryRow(
ctx,
`SELECT data_document_id FROM generated_documents WHERE organization_id = @organization_id`,
pgx.NamedArgs{"organization_id": organizationID},
).Scan(&dataDocumentID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return fmt.Errorf("cannot query generated documents: %w", err)
}
var existingDoc *coredata.Document
if dataDocumentID != nil {
doc := &coredata.Document{}
err = doc.LoadByID(ctx, tx, s.svc.scope, *dataDocumentID)
if err != nil && !errors.Is(err, coredata.ErrResourceNotFound) {
return fmt.Errorf("cannot load data list document: %w", err)
}
if err == nil && doc.ArchivedAt == nil {
existingDoc = doc
} else {
_, err = tx.Exec(
ctx,
`UPDATE generated_documents SET data_document_id = NULL, updated_at = @updated_at WHERE organization_id = @organization_id`,
pgx.NamedArgs{"organization_id": organizationID, "updated_at": now},
)
if 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)
}
_, err = tx.Exec(
ctx,
`INSERT INTO generated_documents (organization_id, tenant_id, data_document_id, created_at, updated_at)
VALUES (@organization_id, @tenant_id, @data_document_id, @created_at, @updated_at)
ON CONFLICT (organization_id) DO UPDATE SET data_document_id = @data_document_id, updated_at = @updated_at`,
pgx.NamedArgs{
"organization_id": organizationID,
"tenant_id": s.svc.scope.GetTenantID(),
"data_document_id": documentID,
"created_at": now,
"updated_at": now,
},
)
if 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: "Data List",
Major: newMajor,
Minor: 0,
Content: prosemirrorJSON,
Status: versionStatus,
Classification: coredata.DocumentClassificationConfidential,
DocumentType: coredata.DocumentTypeRegister,
Orientation: coredata.DocumentVersionOrientationPortrait,
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) GetDataListDocumentID(
ctx context.Context,
organizationID gid.GID,
) (*gid.GID, error) {
var dataDocumentID *gid.GID
err := s.svc.pg.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
return conn.QueryRow(
ctx,
`SELECT data_document_id FROM generated_documents WHERE organization_id = @organization_id`,
pgx.NamedArgs{"organization_id": organizationID},
).Scan(&dataDocumentID)
})
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("cannot get data list document ID: %w", err)
}
return dataDocumentID, nil
}
func (s *GeneratedDocumentService) buildDataListDocumentData(
ctx context.Context,
conn pg.Querier,
organization *coredata.Organization,
) (docgen.DataListData, error) {
var data coredata.Data
if err := data.LoadAllByOrganizationID(ctx, conn, s.svc.scope, organization.ID); err != nil {
return docgen.DataListData{}, fmt.Errorf("cannot load data: %w", err)
}
if len(data) == 0 {
return docgen.DataListData{
Title: "Data List",
OrganizationName: organization.Name,
CreatedAt: time.Now(),
TotalData: 0,
}, nil
}
ownerIDs := make([]gid.GID, 0, len(data))
ownerIDSet := make(map[gid.GID]struct{})
for _, d := range data {
if _, ok := ownerIDSet[d.OwnerID]; !ok {
ownerIDs = append(ownerIDs, d.OwnerID)
ownerIDSet[d.OwnerID] = struct{}{}
}
}
var profiles coredata.MembershipProfiles
if err := profiles.LoadByIDs(ctx, conn, s.svc.scope, ownerIDs); err != nil {
return docgen.DataListData{}, fmt.Errorf("cannot load profiles: %w", err)
}
profileMap := make(map[gid.GID]*coredata.MembershipProfile, len(profiles))
for _, p := range profiles {
profileMap[p.ID] = p
}
rows := make([]docgen.DataListRow, 0, len(data))
for _, d := range data {
ownerName := "-"
if p, ok := profileMap[d.OwnerID]; ok {
ownerName = p.FullName
}
var vendors coredata.Vendors
if err := vendors.LoadAllByDatumID(ctx, conn, s.svc.scope, d.ID); err != nil {
return docgen.DataListData{}, fmt.Errorf("cannot load vendors for datum %s: %w", d.ID, err)
}
vendorNames := make([]string, 0, len(vendors))
for _, v := range vendors {
vendorNames = append(vendorNames, v.Name)
}
vendorStr := "-"
if len(vendorNames) > 0 {
vendorStr = strings.Join(vendorNames, ", ")
}
rows = append(rows, docgen.DataListRow{
Name: d.Name,
Classification: formatClassification(d.DataClassification),
Owner: ownerName,
Vendors: vendorStr,
})
}
return docgen.DataListData{
Title: "Data List",
OrganizationName: organization.Name,
CreatedAt: time.Now(),
TotalData: len(data),
Rows: rows,
}, nil
}
func formatClassification(c coredata.DataClassification) string {
switch c {
case coredata.DataClassificationPublic:
return "Public"
case coredata.DataClassificationInternal:
return "Internal"
case coredata.DataClassificationConfidential:
return "Confidential"
case coredata.DataClassificationSecret:
return "Secret"
default:
return string(c)
}
}
var dataListTemplate = template.Must(
template.New("data_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/data_list.json.tmpl"),
)
func BuildDataListDocument(data docgen.DataListData) (string, error) {
var buf bytes.Buffer
if err := dataListTemplate.Execute(&buf, data); err != nil {
return "", fmt.Errorf("cannot execute data list template: %w", err)
}
return buf.String(), nil
}
var soaTemplate = template.Must(
template.New("statement_of_applicability.json.tmpl").
Funcs(template.FuncMap{

View File

@@ -0,0 +1,81 @@
{
"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 inventory of data assets managed by the organization. It serves as a record of all data items, their classification levels, ownership, and associated vendors." }]
},
{ "type": "horizontalRule" },
{
"type": "heading",
"attrs": { "level": 1 },
"content": [{ "type": "text", "text": "2. Data Inventory" }]
},
{
"type": "table",
"content": [
{
"type": "tableRow",
"content": [
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [250] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Name", "marks": [{ "type": "bold" }] }] }] },
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [130] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Classification", "marks": [{ "type": "bold" }] }] }] },
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [180] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Owner", "marks": [{ "type": "bold" }] }] }] },
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [250] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Vendors", "marks": [{ "type": "bold" }] }] }] }
]
}{{range .Rows}},
{
"type": "tableRow",
"content": [
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [250] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .Name}} }] }] },
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [130] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .Classification}} }] }] },
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [180] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .Owner}} }] }] },
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [250] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .Vendors}} }] }] }
]
}{{end}}
]
},
{ "type": "horizontalRule" },
{
"type": "heading",
"attrs": { "level": 1 },
"content": [{ "type": "text", "text": "3. Definitions" }]
},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": "Classification" }]
},
{
"type": "bulletList",
"content": [
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Public: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Data intended for public disclosure with no confidentiality requirements." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Internal: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Data intended for internal use only, not meant for public disclosure." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Confidential: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Sensitive data requiring protection, accessible only to authorized personnel." }] }] },
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Secret: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Highly sensitive data requiring the strictest access controls and protection measures." }] }] }
]
},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": "Owner" }]
},
{
"type": "paragraph",
"content": [{ "type": "text", "text": "The individual responsible for the data asset, including its accuracy, security, and compliance with applicable policies." }]
},
{
"type": "heading",
"attrs": { "level": 3 },
"content": [{ "type": "text", "text": "Vendors" }]
},
{
"type": "paragraph",
"content": [{ "type": "text", "text": "Third-party vendors that process or have access to the data asset." }]
}
]
}

View File

@@ -220,12 +220,7 @@ func (r *datumConnectionResolver) TotalCount(ctx context.Context, obj *types.Dat
switch obj.Resolver.(type) {
case *organizationResolver:
datumFilter := coredata.NewDatumFilter(nil)
if obj.Filter != nil {
datumFilter = coredata.NewDatumFilter(&obj.Filter.SnapshotID)
}
count, err := prb.Data.CountForOrganizationID(ctx, obj.ParentID, datumFilter)
count, err := prb.Data.CountForOrganizationID(ctx, obj.ParentID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot count data", log.Error(err))
return 0, gqlutils.Internal(ctx)
@@ -405,6 +400,29 @@ func (r *mutationResolver) DeleteDatum(ctx context.Context, input types.DeleteDa
}, nil
}
// PublishDataList is the resolver for the publishDataList field.
func (r *mutationResolver) PublishDataList(ctx context.Context, input types.PublishDataListInput) (*types.PublishDataListPayload, error) {
if err := r.authorize(ctx, input.OrganizationID, probo.ActionDatumPublish); err != nil {
return nil, err
}
prb := r.ProboService(ctx, input.OrganizationID.TenantID())
document, documentVersion, err := prb.GeneratedDocuments.PublishDataList(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 data list", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.PublishDataListPayload{
DocumentEdge: types.NewDocumentEdge(document, coredata.DocumentOrderFieldCreatedAt),
DocumentVersionEdge: types.NewDocumentVersionEdge(documentVersion, coredata.DocumentVersionOrderFieldCreatedAt),
}, nil
}
// Asset returns schema.AssetResolver implementation.
func (r *Resolver) Asset() schema.AssetResolver { return &assetResolver{r} }

View File

@@ -66,10 +66,6 @@ input AssetFilter {
snapshotId: ID
}
input DatumFilter {
snapshotId: ID
}
type Asset implements Node {
id: ID!
snapshotId: ID
@@ -97,7 +93,6 @@ type Datum implements Node
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.Datum"
) {
id: ID!
snapshotId: ID
name: String!
dataClassification: DataClassification!
owner: Profile! @goField(forceResolver: true)
@@ -150,6 +145,9 @@ extend type Mutation {
createDatum(input: CreateDatumInput!): CreateDatumPayload!
updateDatum(input: UpdateDatumInput!): UpdateDatumPayload!
deleteDatum(input: DeleteDatumInput!): DeleteDatumPayload!
publishDataList(
input: PublishDataListInput!
): PublishDataListPayload!
}
input CreateAssetInput {
@@ -219,3 +217,13 @@ type UpdateDatumPayload {
type DeleteDatumPayload {
deletedDatumId: ID!
}
input PublishDataListInput {
organizationId: ID!
approverIds: [ID!]
}
type PublishDataListPayload {
documentEdge: DocumentEdge!
documentVersionEdge: DocumentVersionEdge!
}

View File

@@ -130,13 +130,14 @@ type Organization implements Node {
filter: AssetFilter = { snapshotId: null }
): AssetConnection! @goField(forceResolver: true)
dataListDocument: Document @goField(forceResolver: true)
data(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: DatumOrder
filter: DatumFilter = { snapshotId: null }
): DatumConnection! @goField(forceResolver: true)
audits(

View File

@@ -4,7 +4,6 @@ enum SnapshotsType
VENDORS
@goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeVendors")
ASSETS @goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeAssets")
DATA @goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeData")
FINDINGS
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeFindings"

View File

@@ -256,8 +256,32 @@ func (r *organizationResolver) Assets(ctx context.Context, obj *types.Organizati
return types.NewAssetConnection(page, r, obj.ID, filter), nil
}
// Assets is the resolver for the assets field.
func (r *organizationResolver) Data(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.DatumOrderBy, filter *types.DatumFilter) (*types.DatumConnection, error) {
// DataListDocument is the resolver for the dataListDocument field.
func (r *organizationResolver) DataListDocument(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())
dataDocumentID, err := prb.GeneratedDocuments.GetDataListDocumentID(ctx, obj.ID)
if err != nil {
return nil, fmt.Errorf("cannot get data export document ID: %w", err)
}
if dataDocumentID == nil {
return nil, nil
}
doc, err := prb.Documents.Get(ctx, *dataDocumentID)
if err != nil {
return nil, fmt.Errorf("cannot get data export document: %w", err)
}
return types.NewDocument(doc), nil
}
// Data is the resolver for the data field.
func (r *organizationResolver) Data(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.DatumOrderBy) (*types.DatumConnection, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionDatumList); err != nil {
return nil, err
}
@@ -277,18 +301,13 @@ func (r *organizationResolver) Data(ctx context.Context, obj *types.Organization
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
datumFilter := coredata.NewDatumFilter(nil)
if filter != nil {
datumFilter = coredata.NewDatumFilter(&filter.SnapshotID)
}
page, err := prb.Data.ListForOrganizationID(ctx, obj.ID, cursor, datumFilter)
page, err := prb.Data.ListForOrganizationID(ctx, obj.ID, cursor)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list organization data", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewDataConnection(page, r, obj.ID, filter), nil
return types.NewDataConnection(page, r, obj.ID), nil
}
// Audits is the resolver for the audits field.

View File

@@ -25,7 +25,6 @@ import (
type Datum struct {
ID gid.GID `json:"id"`
OrganizationID gid.GID `json:"-"`
SnapshotID *gid.GID `json:"snapshotId,omitempty"`
Name string `json:"name"`
DataClassification coredata.DataClassification `json:"dataClassification"`
Owner *Profile `json:"owner"`
@@ -48,7 +47,6 @@ type (
Resolver any
ParentID gid.GID
Filter *DatumFilter
}
)
@@ -56,7 +54,6 @@ func NewDataConnection(
p *page.Page[*coredata.Datum, coredata.DatumOrderField],
parentType any,
parentID gid.GID,
filter *DatumFilter,
) *DatumConnection {
edges := make([]*DatumEdge, len(p.Data))
for i, datum := range p.Data {
@@ -69,7 +66,6 @@ func NewDataConnection(
Resolver: parentType,
ParentID: parentID,
Filter: filter,
}
}
@@ -84,7 +80,6 @@ func NewDatum(d *coredata.Datum) *Datum {
},
OrganizationID: d.OrganizationID,
Name: d.Name,
SnapshotID: d.SnapshotID,
DataClassification: d.DataClassification,
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,

View File

@@ -658,13 +658,7 @@ func (r *Resolver) ListDataTool(ctx context.Context, req *mcp.CallToolRequest, i
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
noSnapshot := (*gid.GID)(nil)
datumFilter := coredata.NewDatumFilter(&noSnapshot)
if input.Filter != nil {
datumFilter = coredata.NewDatumFilter(&input.Filter.SnapshotID)
}
page, err := prb.Data.ListForOrganizationID(ctx, input.OrganizationID, cursor, datumFilter)
page, err := prb.Data.ListForOrganizationID(ctx, input.OrganizationID, cursor)
if err != nil {
panic(fmt.Errorf("cannot list organization data: %w", err))
}
@@ -4136,3 +4130,19 @@ func (r *Resolver) GetDocumentVersionApprovalDecisionTool(ctx context.Context, r
ApprovalDecision: types.NewDocumentVersionApprovalDecision(decision),
}, nil
}
func (r *Resolver) PublishDataListTool(ctx context.Context, req *mcp.CallToolRequest, input *types.PublishDataListInput) (*mcp.CallToolResult, types.PublishDataListOutput, error) {
r.MustAuthorize(ctx, input.OrganizationID, probo.ActionDatumPublish)
svc := r.ProboService(ctx, input.OrganizationID)
document, documentVersion, err := svc.GeneratedDocuments.PublishDataList(ctx, input.OrganizationID, input.ApproverIds)
if err != nil {
return nil, types.PublishDataListOutput{}, fmt.Errorf("cannot publish data list: %w", err)
}
return nil, types.PublishDataListOutput{
DocumentID: document.ID,
DocumentVersionID: documentVersion.ID,
}, nil
}

View File

@@ -2218,13 +2218,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
name:
type: string
description: Datum name
@@ -2260,16 +2253,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 data with no snapshot (current live data). Pass a specific snapshot ID to retrieve data as it was at that snapshot.
default: null
ListDataOutput:
type: object
required:
@@ -5017,7 +5000,6 @@ components:
- RISKS
- VENDORS
- ASSETS
- DATA
- NONCONFORMITIES
- OBLIGATIONS
- CONTINUAL_IMPROVEMENTS
@@ -6794,6 +6776,33 @@ components:
description: Deleted statement of applicability ID
PublishDataListInput:
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.
PublishDataListOutput:
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:
@@ -8860,7 +8869,7 @@ tools:
outputSchema:
$ref: "#/components/schemas/GetSnapshotOutput"
- name: takeSnapshot
description: Take a snapshot of a collection of objects (risks, vendors, assets, data, findings, obligations, or processing activities)
description: Take a snapshot of a collection of objects (risks, vendors, assets, findings, obligations, or processing activities)
hints:
readonly: false
inputSchema:
@@ -9124,6 +9133,14 @@ tools:
$ref: "#/components/schemas/DeleteStatementOfApplicabilityInput"
outputSchema:
$ref: "#/components/schemas/DeleteStatementOfApplicabilityOutput"
- name: publishDataList
description: Publish the data list for an organization as a document. If a document already exists, a new version is created.
hints:
readonly: false
inputSchema:
$ref: "#/components/schemas/PublishDataListInput"
outputSchema:
$ref: "#/components/schemas/PublishDataListOutput"
- name: publishStatementOfApplicability
description: Publish a statement of applicability as a document. If a document already exists, a new version is created.
hints:

View File

@@ -25,7 +25,6 @@ func NewDatum(d *coredata.Datum) *Datum {
Name: d.Name,
OwnerID: d.OwnerID,
OrganizationID: d.OrganizationID,
SnapshotID: d.SnapshotID,
DataClassification: d.DataClassification,
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,