diff --git a/apps/console/src/hooks/graph/DatumGraph.ts b/apps/console/src/hooks/graph/DatumGraph.ts index d3cb3ddf3..00bc8e8f6 100644 --- a/apps/console/src/hooks/graph/DatumGraph.ts +++ b/apps/console/src/hooks/graph/DatumGraph.ts @@ -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 { diff --git a/apps/console/src/pages/organizations/data/DataPage.tsx b/apps/console/src/pages/organizations/data/DataPage.tsx index 5ad677e3d..cf41ee3c3 100644 --- a/apps/console/src/pages/organizations/data/DataPage.tsx +++ b/apps/console/src/pages/organizations/data/DataPage.tsx @@ -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( 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 (
- {isSnapshotMode && snapshotId && ( - - )} - {!snapshotId && data.canCreateDatum && ( - pagination.refetch({ snapshotId })} - > - - - )} +
+ {data.dataListDocument?.id && ( + + )} + {data.canPublishData && ( + { + void navigate( + `/organizations/${organizationId}/documents/${documentId}`, + ); + }} + > + + + )} + {data.canCreateDatum && ( + pagination.refetch({})} + > + + + )} +
@@ -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 ( diff --git a/apps/console/src/pages/organizations/data/DatumDetailsPage.tsx b/apps/console/src/pages/organizations/data/DatumDetailsPage.tsx index 3d37e1695..b0c3f1d85 100644 --- a/apps/console/src/pages/organizations/data/DatumDetailsPage.tsx +++ b/apps/console/src/pages/organizations/data/DatumDetailsPage.tsx @@ -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( 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 (
- {isSnapshotMode && snapshotId && ( - - )}
@@ -138,7 +119,7 @@ export default function DatumDetailsPage(props: Props) {
{datumEntry?.name}
{datumEntry?.dataClassification}
- {!isSnapshotMode && datumEntry.canDelete && ( + {datumEntry.canDelete && ( @@ -177,7 +158,7 @@ export default function DatumDetailsPage(props: Props) { control={control} name="ownerId" label={__("Owner")} - disabled={!disabled} + disabled={!datumEntry.canUpdate} /> - {!isSnapshotMode && ( -
- {formState.isDirty && datumEntry.canUpdate && ( - - )} -
- )} +
+ {formState.isDirty && datumEntry.canUpdate && ( + + )} +
); diff --git a/apps/console/src/pages/organizations/data/dialogs/PublishDataListDialog.tsx b/apps/console/src/pages/organizations/data/dialogs/PublishDataListDialog.tsx new file mode 100644 index 000000000..8737de370 --- /dev/null +++ b/apps/console/src/pages/organizations/data/dialogs/PublishDataListDialog.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 { 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(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.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 ( + +
void handleSubmit(onSubmit)(e)}> + +
+

+ {__("Select approvers to request approval before publishing, or publish directly without approvers.")} +

+ +
+
+ + + +
+
+ ); +} diff --git a/apps/console/src/pages/organizations/snapshots/dialog/SnapshotFormDialog.tsx b/apps/console/src/pages/organizations/snapshots/dialog/SnapshotFormDialog.tsx index 05f78c2a6..b8a064fa1 100644 --- a/apps/console/src/pages/organizations/snapshots/dialog/SnapshotFormDialog.tsx +++ b/apps/console/src/pages/organizations/snapshots/dialog/SnapshotFormDialog.tsx @@ -85,7 +85,7 @@ export default function SnapshotFormDialog(props: Props) { defaultValues: { name: "", description: "", - type: "DATA", + type: "RISKS", }, }); diff --git a/apps/console/src/routes/dataRoutes.ts b/apps/console/src/routes/dataRoutes.ts index bcbe6bb66..03e9a556c 100644 --- a/apps/console/src/routes/dataRoutes.ts +++ b/apps/console/src/routes/dataRoutes.ts @@ -32,22 +32,8 @@ export const dataRoutes = [ path: "data", Fallback: PageSkeleton, loader: loaderFromQueryLoader(({ organizationId }) => - loadQuery(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(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(coreEnvironment, datumNodeQuery, { - dataId, - }), - ), - Component: withQueryRef( - lazy(() => import("../pages/organizations/data/DatumDetailsPage")), - ), - }, ] satisfies AppRoute[]; diff --git a/cmd/migrate-data-snapshots-to-documents/main.go b/cmd/migrate-data-snapshots-to-documents/main.go new file mode 100644 index 000000000..2bd380136 --- /dev/null +++ b/cmd/migrate-data-snapshots-to-documents/main.go @@ -0,0 +1,433 @@ +// 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-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...) +} diff --git a/e2e/console/datum_publish_test.go b/e2e/console/datum_publish_test.go new file mode 100644 index 000000000..96d6875da --- /dev/null +++ b/e2e/console/datum_publish_test.go @@ -0,0 +1,370 @@ +// 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/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) + }, + ) +} diff --git a/e2e/console/snapshot_test.go b/e2e/console/snapshot_test.go index 8195b1172..4b3525eb2 100644 --- a/e2e/console/snapshot_test.go +++ b/e2e/console/snapshot_test.go @@ -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) { diff --git a/package-lock.json b/package-lock.json index 1e5502601..10d93ce9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18190,6 +18190,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } diff --git a/packages/helpers/src/snapshots.ts b/packages/helpers/src/snapshots.ts index 4ed06ac80..de686269c 100644 --- a/packages/helpers/src/snapshots.ts +++ b/packages/helpers/src/snapshots.ts @@ -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": diff --git a/packages/n8n-node/nodes/Probo/actions/datum/index.ts b/packages/n8n-node/nodes/Probo/actions/datum/index.ts index da3a7831e..4edfa6255 100644 --- a/packages/n8n-node/nodes/Probo/actions/datum/index.ts +++ b/packages/n8n-node/nodes/Probo/actions/datum/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 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 }; diff --git a/packages/n8n-node/nodes/Probo/actions/datum/publish.operation.ts b/packages/n8n-node/nodes/Probo/actions/datum/publish.operation.ts new file mode 100644 index 000000000..89eda5f7d --- /dev/null +++ b/packages/n8n-node/nodes/Probo/actions/datum/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: ['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 { + 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 = { 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/coredata/datum_filter.go b/pkg/cmd/datum/datum.go similarity index 51% rename from pkg/coredata/datum_filter.go rename to pkg/cmd/datum/datum.go index d45646471..9b6ec8f33 100644 --- a/pkg/coredata/datum_filter.go +++ b/pkg/cmd/datum/datum.go @@ -1,4 +1,4 @@ -// Copyright (c) 2025-2026 Probo Inc . +// 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 @@ -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 ", + 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 } diff --git a/pkg/cmd/datum/publish/publish.go b/pkg/cmd/datum/publish/publish.go new file mode 100644 index 000000000..9c1a9affa --- /dev/null +++ b/pkg/cmd/datum/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: 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 +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 7ed8a7dcd..3e53fd81d 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -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)) diff --git a/pkg/coredata/datum.go b/pkg/coredata/datum.go index f2364c00c..78a78b480 100644 --- a/pkg/coredata/datum.go +++ b/pkg/coredata/datum.go @@ -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 -} diff --git a/pkg/coredata/datum_vendor.go b/pkg/coredata/datum_vendor.go index 809733472..747cf4ed1 100644 --- a/pkg/coredata/datum_vendor.go +++ b/pkg/coredata/datum_vendor.go @@ -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 -} diff --git a/pkg/coredata/migrations/20260420T120000Z.sql b/pkg/coredata/migrations/20260420T120000Z.sql new file mode 100644 index 000000000..d53d40e1a --- /dev/null +++ b/pkg/coredata/migrations/20260420T120000Z.sql @@ -0,0 +1,22 @@ +-- 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. + +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) +); diff --git a/pkg/coredata/snapshot.go b/pkg/coredata/snapshot.go index c61d09f7a..0289be9d8 100644 --- a/pkg/coredata/snapshot.go +++ b/pkg/coredata/snapshot.go @@ -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 ` diff --git a/pkg/coredata/snapshots_type.go b/pkg/coredata/snapshots_type.go index e15ed3e53..e46deb7b1 100644 --- a/pkg/coredata/snapshots_type.go +++ b/pkg/coredata/snapshots_type.go @@ -39,7 +39,6 @@ func SnapshotsTypes() []SnapshotsType { SnapshotsTypeRisks, SnapshotsTypeVendors, SnapshotsTypeAssets, - SnapshotsTypeData, SnapshotsTypeFindings, SnapshotsTypeObligations, SnapshotsTypeProcessingActivities, diff --git a/pkg/coredata/snapshottable.go b/pkg/coredata/snapshottable.go index 50a7c12c8..073b931a5 100644 --- a/pkg/coredata/snapshottable.go +++ b/pkg/coredata/snapshottable.go @@ -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: diff --git a/pkg/coredata/vendor.go b/pkg/coredata/vendor.go index 21c302f92..a5b2b0cfd 100644 --- a/pkg/coredata/vendor.go +++ b/pkg/coredata/vendor.go @@ -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, diff --git a/pkg/docgen/generator.go b/pkg/docgen/generator.go index 4b82669a0..bf6236a15 100644 --- a/pkg/docgen/generator.go +++ b/pkg/docgen/generator.go @@ -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 { diff --git a/pkg/probo/actions.go b/pkg/probo/actions.go index b9176f449..8c2edd60c 100644 --- a/pkg/probo/actions.go +++ b/pkg/probo/actions.go @@ -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" diff --git a/pkg/probo/datum_service.go b/pkg/probo/datum_service.go index 2d4e2606a..cb1bd2f6d 100644 --- a/pkg/probo/datum_service.go +++ b/pkg/probo/datum_service.go @@ -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, ) }, ) diff --git a/pkg/probo/generated_document_service.go b/pkg/probo/generated_document_service.go index 4584d9e71..61146b23b 100644 --- a/pkg/probo/generated_document_service.go +++ b/pkg/probo/generated_document_service.go @@ -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{ diff --git a/pkg/probo/templates/data_list.json.tmpl b/pkg/probo/templates/data_list.json.tmpl new file mode 100644 index 000000000..a6840f8a4 --- /dev/null +++ b/pkg/probo/templates/data_list.json.tmpl @@ -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." }] + } + ] +} diff --git a/pkg/server/api/console/v1/asset_resolvers.go b/pkg/server/api/console/v1/asset_resolvers.go index 61c516bb3..ff11d067b 100644 --- a/pkg/server/api/console/v1/asset_resolvers.go +++ b/pkg/server/api/console/v1/asset_resolvers.go @@ -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} } diff --git a/pkg/server/api/console/v1/graphql/asset.graphql b/pkg/server/api/console/v1/graphql/asset.graphql index 961030854..9876e5db6 100644 --- a/pkg/server/api/console/v1/graphql/asset.graphql +++ b/pkg/server/api/console/v1/graphql/asset.graphql @@ -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! +} diff --git a/pkg/server/api/console/v1/graphql/organization.graphql b/pkg/server/api/console/v1/graphql/organization.graphql index 528cc4d1d..1e112f20e 100644 --- a/pkg/server/api/console/v1/graphql/organization.graphql +++ b/pkg/server/api/console/v1/graphql/organization.graphql @@ -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( diff --git a/pkg/server/api/console/v1/graphql/snapshot.graphql b/pkg/server/api/console/v1/graphql/snapshot.graphql index 50bb8ef31..5d7c06c6a 100644 --- a/pkg/server/api/console/v1/graphql/snapshot.graphql +++ b/pkg/server/api/console/v1/graphql/snapshot.graphql @@ -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" diff --git a/pkg/server/api/console/v1/organization_resolvers.go b/pkg/server/api/console/v1/organization_resolvers.go index aecd44019..3eec8969c 100644 --- a/pkg/server/api/console/v1/organization_resolvers.go +++ b/pkg/server/api/console/v1/organization_resolvers.go @@ -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. diff --git a/pkg/server/api/console/v1/types/datum.go b/pkg/server/api/console/v1/types/datum.go index ece490144..de1f318ad 100644 --- a/pkg/server/api/console/v1/types/datum.go +++ b/pkg/server/api/console/v1/types/datum.go @@ -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, diff --git a/pkg/server/api/mcp/v1/schema.resolvers.go b/pkg/server/api/mcp/v1/schema.resolvers.go index 700576caf..93dd16f02 100644 --- a/pkg/server/api/mcp/v1/schema.resolvers.go +++ b/pkg/server/api/mcp/v1/schema.resolvers.go @@ -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 +} diff --git a/pkg/server/api/mcp/v1/specification.yaml b/pkg/server/api/mcp/v1/specification.yaml index 060829242..f947e9e81 100644 --- a/pkg/server/api/mcp/v1/specification.yaml +++ b/pkg/server/api/mcp/v1/specification.yaml @@ -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: diff --git a/pkg/server/api/mcp/v1/types/datum.go b/pkg/server/api/mcp/v1/types/datum.go index f4fdcaba6..8b2ef0129 100644 --- a/pkg/server/api/mcp/v1/types/datum.go +++ b/pkg/server/api/mcp/v1/types/datum.go @@ -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,