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:
@@ -21,11 +21,18 @@ import { graphql } from "relay-runtime";
|
|||||||
/* eslint-disable relay/unused-fields, relay/must-colocate-fragment-spreads */
|
/* eslint-disable relay/unused-fields, relay/must-colocate-fragment-spreads */
|
||||||
|
|
||||||
export const dataQuery = graphql`
|
export const dataQuery = graphql`
|
||||||
query DatumGraphListQuery($organizationId: ID!, $snapshotId: ID = null) {
|
query DatumGraphListQuery($organizationId: ID!) {
|
||||||
node(id: $organizationId) {
|
node(id: $organizationId) {
|
||||||
... on Organization {
|
... on Organization {
|
||||||
canCreateDatum: permission(action: "core:datum:create")
|
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) {
|
node(id: $dataId) {
|
||||||
... on Datum {
|
... on Datum {
|
||||||
id
|
id
|
||||||
snapshotId
|
|
||||||
name
|
name
|
||||||
dataClassification
|
dataClassification
|
||||||
owner {
|
owner {
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
DropdownItem,
|
DropdownItem,
|
||||||
|
IconPageTextLine,
|
||||||
IconPlusLarge,
|
IconPlusLarge,
|
||||||
IconTrashCan,
|
IconTrashCan,
|
||||||
|
IconUpload,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
Tbody,
|
Tbody,
|
||||||
Td,
|
Td,
|
||||||
@@ -36,7 +38,7 @@ import {
|
|||||||
usePaginationFragment,
|
usePaginationFragment,
|
||||||
usePreloadedQuery,
|
usePreloadedQuery,
|
||||||
} from "react-relay";
|
} from "react-relay";
|
||||||
import { useParams } from "react-router";
|
import { Link, useNavigate } from "react-router";
|
||||||
|
|
||||||
import type { DataListQuery } from "#/__generated__/core/DataListQuery.graphql";
|
import type { DataListQuery } from "#/__generated__/core/DataListQuery.graphql";
|
||||||
import type {
|
import type {
|
||||||
@@ -44,7 +46,6 @@ import type {
|
|||||||
DataPageFragment$key,
|
DataPageFragment$key,
|
||||||
} from "#/__generated__/core/DataPageFragment.graphql";
|
} from "#/__generated__/core/DataPageFragment.graphql";
|
||||||
import type { DatumGraphListQuery } from "#/__generated__/core/DatumGraphListQuery.graphql";
|
import type { DatumGraphListQuery } from "#/__generated__/core/DatumGraphListQuery.graphql";
|
||||||
import { SnapshotBanner } from "#/components/SnapshotBanner";
|
|
||||||
import { SortableTable } from "#/components/SortableTable";
|
import { SortableTable } from "#/components/SortableTable";
|
||||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||||
import type { NodeOf } from "#/types";
|
import type { NodeOf } from "#/types";
|
||||||
@@ -52,6 +53,7 @@ import type { NodeOf } from "#/types";
|
|||||||
import { dataQuery, useDeleteDatum } from "../../../hooks/graph/DatumGraph";
|
import { dataQuery, useDeleteDatum } from "../../../hooks/graph/DatumGraph";
|
||||||
|
|
||||||
import { CreateDatumDialog } from "./dialogs/CreateDatumDialog";
|
import { CreateDatumDialog } from "./dialogs/CreateDatumDialog";
|
||||||
|
import { PublishDataListDialog } from "./dialogs/PublishDataListDialog";
|
||||||
|
|
||||||
const paginatedDataFragment = graphql`
|
const paginatedDataFragment = graphql`
|
||||||
fragment DataPageFragment on Organization
|
fragment DataPageFragment on Organization
|
||||||
@@ -62,7 +64,6 @@ const paginatedDataFragment = graphql`
|
|||||||
after: { type: "CursorKey", defaultValue: null }
|
after: { type: "CursorKey", defaultValue: null }
|
||||||
before: { type: "CursorKey", defaultValue: null }
|
before: { type: "CursorKey", defaultValue: null }
|
||||||
last: { type: "Int", defaultValue: null }
|
last: { type: "Int", defaultValue: null }
|
||||||
snapshotId: { type: "ID", defaultValue: null }
|
|
||||||
) {
|
) {
|
||||||
data(
|
data(
|
||||||
first: $first
|
first: $first
|
||||||
@@ -70,8 +71,7 @@ const paginatedDataFragment = graphql`
|
|||||||
last: $last
|
last: $last
|
||||||
before: $before
|
before: $before
|
||||||
orderBy: $order
|
orderBy: $order
|
||||||
filter: { snapshotId: $snapshotId }
|
) @connection(key: "DataPage_data") {
|
||||||
) @connection(key: "DataPage_data", filters: ["filter"]) {
|
|
||||||
__id
|
__id
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
@@ -107,8 +107,7 @@ type Props = {
|
|||||||
export default function DataPage(props: Props) {
|
export default function DataPage(props: Props) {
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const organizationId = useOrganizationId();
|
const organizationId = useOrganizationId();
|
||||||
const { snapshotId } = useParams<{ snapshotId?: string }>();
|
const navigate = useNavigate();
|
||||||
const isSnapshotMode = Boolean(snapshotId);
|
|
||||||
|
|
||||||
const { node: data } = usePreloadedQuery<DatumGraphListQuery>(
|
const { node: data } = usePreloadedQuery<DatumGraphListQuery>(
|
||||||
dataQuery,
|
dataQuery,
|
||||||
@@ -122,6 +121,7 @@ export default function DataPage(props: Props) {
|
|||||||
|
|
||||||
const dataEntries = pagination.data.data.edges.map(edge => edge.node);
|
const dataEntries = pagination.data.data.edges.map(edge => edge.node);
|
||||||
const connectionId = pagination.data.data.__id;
|
const connectionId = pagination.data.data.__id;
|
||||||
|
const defaultApproverIds = (data.dataListDocument?.defaultApprovers ?? []).map(a => a.id);
|
||||||
|
|
||||||
const refetch = ({
|
const refetch = ({
|
||||||
order,
|
order,
|
||||||
@@ -129,7 +129,6 @@ export default function DataPage(props: Props) {
|
|||||||
order: { direction: string; field: string };
|
order: { direction: string; field: string };
|
||||||
}) => {
|
}) => {
|
||||||
pagination.refetch({
|
pagination.refetch({
|
||||||
snapshotId,
|
|
||||||
order: {
|
order: {
|
||||||
direction: order.direction as "ASC" | "DESC",
|
direction: order.direction as "ASC" | "DESC",
|
||||||
field: order.field as "CREATED_AT" | "DATA_CLASSIFICATION" | "NAME",
|
field: order.field as "CREATED_AT" | "DATA_CLASSIFICATION" | "NAME",
|
||||||
@@ -140,29 +139,52 @@ export default function DataPage(props: Props) {
|
|||||||
usePageTitle(__("Data"));
|
usePageTitle(__("Data"));
|
||||||
|
|
||||||
const hasAnyAction
|
const hasAnyAction
|
||||||
= !isSnapshotMode
|
= dataEntries.some(({ canDelete, canUpdate }) => canUpdate || canDelete);
|
||||||
&& dataEntries.some(({ canDelete, canUpdate }) => canUpdate || canDelete);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{isSnapshotMode && snapshotId && (
|
|
||||||
<SnapshotBanner snapshotId={snapshotId} />
|
|
||||||
)}
|
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={__("Data")}
|
title={__("Data")}
|
||||||
description={__(
|
description={__(
|
||||||
"Manage your organization's data assets and their classifications.",
|
"Manage your organization's data assets and their classifications.",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!snapshotId && data.canCreateDatum && (
|
<div className="flex gap-2">
|
||||||
<CreateDatumDialog
|
{data.dataListDocument?.id && (
|
||||||
connection={connectionId}
|
<Button variant="secondary" asChild>
|
||||||
organizationId={organizationId}
|
<Link
|
||||||
onCreated={() => pagination.refetch({ snapshotId })}
|
to={`/organizations/${organizationId}/documents/${data.dataListDocument.id}`}
|
||||||
>
|
>
|
||||||
<Button icon={IconPlusLarge}>{__("Add data")}</Button>
|
<IconPageTextLine size={16} />
|
||||||
</CreateDatumDialog>
|
{__("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>
|
</PageHeader>
|
||||||
<SortableTable {...pagination} refetch={refetch} pageSize={10}>
|
<SortableTable {...pagination} refetch={refetch} pageSize={10}>
|
||||||
<Thead>
|
<Thead>
|
||||||
@@ -180,7 +202,6 @@ export default function DataPage(props: Props) {
|
|||||||
key={entry.id}
|
key={entry.id}
|
||||||
entry={entry}
|
entry={entry}
|
||||||
connectionId={connectionId}
|
connectionId={connectionId}
|
||||||
snapshotId={snapshotId}
|
|
||||||
hasAnyAction={hasAnyAction}
|
hasAnyAction={hasAnyAction}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -193,21 +214,17 @@ export default function DataPage(props: Props) {
|
|||||||
function DataRow({
|
function DataRow({
|
||||||
entry,
|
entry,
|
||||||
connectionId,
|
connectionId,
|
||||||
snapshotId,
|
|
||||||
hasAnyAction,
|
hasAnyAction,
|
||||||
}: {
|
}: {
|
||||||
entry: DataEntry;
|
entry: DataEntry;
|
||||||
connectionId: string;
|
connectionId: string;
|
||||||
snapshotId?: string;
|
|
||||||
hasAnyAction: boolean;
|
hasAnyAction: boolean;
|
||||||
}) {
|
}) {
|
||||||
const organizationId = useOrganizationId();
|
const organizationId = useOrganizationId();
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const deleteDatum = useDeleteDatum(entry, connectionId);
|
const deleteDatum = useDeleteDatum(entry, connectionId);
|
||||||
const vendors = entry.vendors?.edges.map(edge => edge.node) ?? [];
|
const vendors = entry.vendors?.edges.map(edge => edge.node) ?? [];
|
||||||
const detailUrl = snapshotId
|
const detailUrl = `/organizations/${organizationId}/data/${entry.id}`;
|
||||||
? `/organizations/${organizationId}/snapshots/${snapshotId}/data/${entry.id}`
|
|
||||||
: `/organizations/${organizationId}/data/${entry.id}`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tr to={detailUrl}>
|
<Tr to={detailUrl}>
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
import { validateSnapshotConsistency } from "@probo/helpers";
|
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import {
|
import {
|
||||||
ActionDropdown,
|
ActionDropdown,
|
||||||
@@ -29,14 +28,12 @@ import {
|
|||||||
type PreloadedQuery,
|
type PreloadedQuery,
|
||||||
usePreloadedQuery,
|
usePreloadedQuery,
|
||||||
} from "react-relay";
|
} from "react-relay";
|
||||||
import { useParams } from "react-router";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import type { DatumGraphNodeQuery } from "#/__generated__/core/DatumGraphNodeQuery.graphql";
|
import type { DatumGraphNodeQuery } from "#/__generated__/core/DatumGraphNodeQuery.graphql";
|
||||||
import { ControlledField } from "#/components/form/ControlledField";
|
import { ControlledField } from "#/components/form/ControlledField";
|
||||||
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
|
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
|
||||||
import { VendorsMultiSelectField } from "#/components/form/VendorsMultiSelectField";
|
import { VendorsMultiSelectField } from "#/components/form/VendorsMultiSelectField";
|
||||||
import { SnapshotBanner } from "#/components/SnapshotBanner";
|
|
||||||
import {
|
import {
|
||||||
datumNodeQuery,
|
datumNodeQuery,
|
||||||
useDeleteDatum,
|
useDeleteDatum,
|
||||||
@@ -57,9 +54,6 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function DatumDetailsPage(props: Props) {
|
export default function DatumDetailsPage(props: Props) {
|
||||||
const { snapshotId } = useParams<{ snapshotId?: string }>();
|
|
||||||
const isSnapshotMode = Boolean(snapshotId);
|
|
||||||
|
|
||||||
const queryData = usePreloadedQuery<DatumGraphNodeQuery>(
|
const queryData = usePreloadedQuery<DatumGraphNodeQuery>(
|
||||||
datumNodeQuery,
|
datumNodeQuery,
|
||||||
props.queryRef,
|
props.queryRef,
|
||||||
@@ -67,16 +61,12 @@ export default function DatumDetailsPage(props: Props) {
|
|||||||
|
|
||||||
const datumEntry = queryData.node;
|
const datumEntry = queryData.node;
|
||||||
|
|
||||||
validateSnapshotConsistency(datumEntry, snapshotId);
|
|
||||||
|
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const organizationId = useOrganizationId();
|
const organizationId = useOrganizationId();
|
||||||
|
|
||||||
const deleteDatum = useDeleteDatum(
|
const deleteDatum = useDeleteDatum(
|
||||||
datumEntry,
|
datumEntry,
|
||||||
ConnectionHandler.getConnectionID(organizationId, "DataPage_data", {
|
ConnectionHandler.getConnectionID(organizationId, "DataPage_data"),
|
||||||
filter: { snapshotId: snapshotId || null },
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const vendors = datumEntry?.vendors?.edges.map(edge => edge.node) ?? [];
|
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 = [
|
const breadcrumbItems = [
|
||||||
{
|
{
|
||||||
label: __("Data"),
|
label: __("Data"),
|
||||||
to: breadcrumbDataUrl,
|
to: `/organizations/${organizationId}/data`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: datumEntry?.name || "",
|
label: datumEntry?.name || "",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const disabled = !isSnapshotMode && datumEntry.canUpdate;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{isSnapshotMode && snapshotId && (
|
|
||||||
<SnapshotBanner snapshotId={snapshotId} />
|
|
||||||
)}
|
|
||||||
<Breadcrumb items={breadcrumbItems} />
|
<Breadcrumb items={breadcrumbItems} />
|
||||||
|
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
@@ -138,7 +119,7 @@ export default function DatumDetailsPage(props: Props) {
|
|||||||
<div className="text-2xl">{datumEntry?.name}</div>
|
<div className="text-2xl">{datumEntry?.name}</div>
|
||||||
<Badge variant="info">{datumEntry?.dataClassification}</Badge>
|
<Badge variant="info">{datumEntry?.dataClassification}</Badge>
|
||||||
</div>
|
</div>
|
||||||
{!isSnapshotMode && datumEntry.canDelete && (
|
{datumEntry.canDelete && (
|
||||||
<ActionDropdown variant="secondary">
|
<ActionDropdown variant="secondary">
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
variant="danger"
|
variant="danger"
|
||||||
@@ -156,7 +137,7 @@ export default function DatumDetailsPage(props: Props) {
|
|||||||
label={__("Name")}
|
label={__("Name")}
|
||||||
{...register("name")}
|
{...register("name")}
|
||||||
type="text"
|
type="text"
|
||||||
disabled={!disabled}
|
disabled={!datumEntry.canUpdate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ControlledField
|
<ControlledField
|
||||||
@@ -164,7 +145,7 @@ export default function DatumDetailsPage(props: Props) {
|
|||||||
name="dataClassification"
|
name="dataClassification"
|
||||||
type="select"
|
type="select"
|
||||||
label={__("Classification")}
|
label={__("Classification")}
|
||||||
disabled={!disabled}
|
disabled={!datumEntry.canUpdate}
|
||||||
>
|
>
|
||||||
<Option value="PUBLIC">{__("Public")}</Option>
|
<Option value="PUBLIC">{__("Public")}</Option>
|
||||||
<Option value="INTERNAL">{__("Internal")}</Option>
|
<Option value="INTERNAL">{__("Internal")}</Option>
|
||||||
@@ -177,7 +158,7 @@ export default function DatumDetailsPage(props: Props) {
|
|||||||
control={control}
|
control={control}
|
||||||
name="ownerId"
|
name="ownerId"
|
||||||
label={__("Owner")}
|
label={__("Owner")}
|
||||||
disabled={!disabled}
|
disabled={!datumEntry.canUpdate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<VendorsMultiSelectField
|
<VendorsMultiSelectField
|
||||||
@@ -185,19 +166,17 @@ export default function DatumDetailsPage(props: Props) {
|
|||||||
control={control}
|
control={control}
|
||||||
name="vendorIds"
|
name="vendorIds"
|
||||||
label={__("Vendors")}
|
label={__("Vendors")}
|
||||||
disabled={!disabled}
|
disabled={!datumEntry.canUpdate}
|
||||||
selectedVendors={vendors}
|
selectedVendors={vendors}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!isSnapshotMode && (
|
<div className="flex justify-end">
|
||||||
<div className="flex justify-end">
|
{formState.isDirty && datumEntry.canUpdate && (
|
||||||
{formState.isDirty && datumEntry.canUpdate && (
|
<Button type="submit" disabled={formState.isSubmitting}>
|
||||||
<Button type="submit" disabled={formState.isSubmitting}>
|
{formState.isSubmitting ? __("Updating...") : __("Update")}
|
||||||
{formState.isSubmitting ? __("Updating...") : __("Update")}
|
</Button>
|
||||||
</Button>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -85,7 +85,7 @@ export default function SnapshotFormDialog(props: Props) {
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "",
|
name: "",
|
||||||
description: "",
|
description: "",
|
||||||
type: "DATA",
|
type: "RISKS",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -32,22 +32,8 @@ export const dataRoutes = [
|
|||||||
path: "data",
|
path: "data",
|
||||||
Fallback: PageSkeleton,
|
Fallback: PageSkeleton,
|
||||||
loader: loaderFromQueryLoader(({ organizationId }) =>
|
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, {
|
loadQuery<DatumGraphListQuery>(coreEnvironment, dataQuery, {
|
||||||
organizationId,
|
organizationId,
|
||||||
snapshotId,
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
Component: withQueryRef(
|
Component: withQueryRef(
|
||||||
@@ -66,16 +52,4 @@ export const dataRoutes = [
|
|||||||
lazy(() => import("../pages/organizations/data/DatumDetailsPage")),
|
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[];
|
] satisfies AppRoute[];
|
||||||
|
|||||||
433
cmd/migrate-data-snapshots-to-documents/main.go
Normal file
433
cmd/migrate-data-snapshots-to-documents/main.go
Normal 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...)
|
||||||
|
}
|
||||||
370
e2e/console/datum_publish_test.go
Normal file
370
e2e/console/datum_publish_test.go
Normal 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)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -140,7 +140,7 @@ func TestSnapshot_List(t *testing.T) {
|
|||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
|
|
||||||
// Create multiple snapshots
|
// Create multiple snapshots
|
||||||
snapshotTypes := []string{"RISKS", "VENDORS", "ASSETS", "DATA"}
|
snapshotTypes := []string{"RISKS", "VENDORS", "ASSETS"}
|
||||||
for i, snapshotType := range snapshotTypes {
|
for i, snapshotType := range snapshotTypes {
|
||||||
query := `
|
query := `
|
||||||
mutation CreateSnapshot($input: CreateSnapshotInput!) {
|
mutation CreateSnapshot($input: CreateSnapshotInput!) {
|
||||||
@@ -212,14 +212,14 @@ func TestSnapshot_List(t *testing.T) {
|
|||||||
"id": owner.GetOrganizationID().String(),
|
"id": owner.GetOrganizationID().String(),
|
||||||
}, &result)
|
}, &result)
|
||||||
require.NoError(t, err)
|
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) {
|
func TestSnapshot_Types(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
|
|
||||||
snapshotTypes := []string{"RISKS", "VENDORS", "ASSETS", "DATA"}
|
snapshotTypes := []string{"RISKS", "VENDORS", "ASSETS"}
|
||||||
|
|
||||||
for _, snapshotType := range snapshotTypes {
|
for _, snapshotType := range snapshotTypes {
|
||||||
t.Run(snapshotType, func(t *testing.T) {
|
t.Run(snapshotType, func(t *testing.T) {
|
||||||
|
|||||||
1
package-lock.json
generated
1
package-lock.json
generated
@@ -18190,6 +18190,7 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export const snapshotTypes = [
|
|||||||
"RISKS",
|
"RISKS",
|
||||||
"VENDORS",
|
"VENDORS",
|
||||||
"ASSETS",
|
"ASSETS",
|
||||||
"DATA",
|
|
||||||
"FINDINGS",
|
"FINDINGS",
|
||||||
"OBLIGATIONS",
|
"OBLIGATIONS",
|
||||||
"PROCESSING_ACTIVITIES",
|
"PROCESSING_ACTIVITIES",
|
||||||
@@ -36,8 +35,6 @@ export function getSnapshotTypeLabel(__: Translator, type: string | null | undef
|
|||||||
return __("Vendors");
|
return __("Vendors");
|
||||||
case "ASSETS":
|
case "ASSETS":
|
||||||
return __("Assets");
|
return __("Assets");
|
||||||
case "DATA":
|
|
||||||
return __("Data");
|
|
||||||
case "FINDINGS":
|
case "FINDINGS":
|
||||||
case "NONCONFORMITIES":
|
case "NONCONFORMITIES":
|
||||||
case "CONTINUAL_IMPROVEMENTS":
|
case "CONTINUAL_IMPROVEMENTS":
|
||||||
@@ -59,8 +56,6 @@ export function getSnapshotTypeUrlPath(type?: string): string {
|
|||||||
return "/vendors";
|
return "/vendors";
|
||||||
case "ASSETS":
|
case "ASSETS":
|
||||||
return "/assets";
|
return "/assets";
|
||||||
case "DATA":
|
|
||||||
return "/data";
|
|
||||||
case "FINDINGS":
|
case "FINDINGS":
|
||||||
case "NONCONFORMITIES":
|
case "NONCONFORMITIES":
|
||||||
case "CONTINUAL_IMPROVEMENTS":
|
case "CONTINUAL_IMPROVEMENTS":
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import * as updateOp from './update.operation';
|
|||||||
import * as deleteOp from './delete.operation';
|
import * as deleteOp from './delete.operation';
|
||||||
import * as getOp from './get.operation';
|
import * as getOp from './get.operation';
|
||||||
import * as getAllOp from './getAll.operation';
|
import * as getAllOp from './getAll.operation';
|
||||||
|
import * as publishOp from './publish.operation';
|
||||||
|
|
||||||
export const description: INodeProperties[] = [
|
export const description: INodeProperties[] = [
|
||||||
{
|
{
|
||||||
@@ -55,6 +56,12 @@ export const description: INodeProperties[] = [
|
|||||||
description: 'Get many data',
|
description: 'Get many data',
|
||||||
action: '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',
|
name: 'Update',
|
||||||
value: 'update',
|
value: 'update',
|
||||||
@@ -69,6 +76,7 @@ export const description: INodeProperties[] = [
|
|||||||
...deleteOp.description,
|
...deleteOp.description,
|
||||||
...getOp.description,
|
...getOp.description,
|
||||||
...getAllOp.description,
|
...getAllOp.description,
|
||||||
|
...publishOp.description,
|
||||||
];
|
];
|
||||||
|
|
||||||
export { createOp as create, updateOp as update, deleteOp as delete, getOp as get, getAllOp as getAll };
|
export { createOp as create, updateOp as update, deleteOp as delete, getOp as get, getAllOp as getAll, publishOp as publish };
|
||||||
|
|||||||
101
packages/n8n-node/nodes/Probo/actions/datum/publish.operation.ts
Normal file
101
packages/n8n-node/nodes/Probo/actions/datum/publish.operation.ts
Normal 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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
// purpose with or without fee is hereby granted, provided that the above
|
// 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
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
package coredata
|
package datum
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/spf13/cobra"
|
||||||
"go.probo.inc/probo/pkg/gid"
|
"go.probo.inc/probo/pkg/cmd/cmdutil"
|
||||||
|
"go.probo.inc/probo/pkg/cmd/datum/publish"
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
func NewCmdDatum(f *cmdutil.Factory) *cobra.Command {
|
||||||
DatumFilter struct {
|
cmd := &cobra.Command{
|
||||||
snapshotID **gid.GID
|
Use: "datum <command>",
|
||||||
|
Short: "Manage data",
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
|
||||||
func NewDatumFilter(snapshotID **gid.GID) *DatumFilter {
|
cmd.AddCommand(publish.NewCmdPublish(f))
|
||||||
return &DatumFilter{
|
|
||||||
snapshotID: snapshotID,
|
return cmd
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
147
pkg/cmd/datum/publish/publish.go
Normal file
147
pkg/cmd/datum/publish/publish.go
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
package publish
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"go.probo.inc/probo/pkg/cli/api"
|
||||||
|
"go.probo.inc/probo/pkg/cmd/cmdutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
const publishMutation = `
|
||||||
|
mutation($input: 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
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ import (
|
|||||||
cmdconfig "go.probo.inc/probo/pkg/cmd/config"
|
cmdconfig "go.probo.inc/probo/pkg/cmd/config"
|
||||||
cmdcontext "go.probo.inc/probo/pkg/cmd/context"
|
cmdcontext "go.probo.inc/probo/pkg/cmd/context"
|
||||||
"go.probo.inc/probo/pkg/cmd/control"
|
"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/document"
|
||||||
"go.probo.inc/probo/pkg/cmd/evidence"
|
"go.probo.inc/probo/pkg/cmd/evidence"
|
||||||
"go.probo.inc/probo/pkg/cmd/finding"
|
"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(cmdconfig.NewCmdConfig(f))
|
||||||
cmd.AddCommand(cmdcontext.NewCmdContext(f))
|
cmd.AddCommand(cmdcontext.NewCmdContext(f))
|
||||||
cmd.AddCommand(control.NewCmdControl(f))
|
cmd.AddCommand(control.NewCmdControl(f))
|
||||||
|
cmd.AddCommand(datum.NewCmdDatum(f))
|
||||||
cmd.AddCommand(document.NewCmdDocument(f))
|
cmd.AddCommand(document.NewCmdDocument(f))
|
||||||
cmd.AddCommand(evidence.NewCmdEvidence(f))
|
cmd.AddCommand(evidence.NewCmdEvidence(f))
|
||||||
cmd.AddCommand(finding.NewCmdFinding(f))
|
cmd.AddCommand(finding.NewCmdFinding(f))
|
||||||
|
|||||||
@@ -34,17 +34,11 @@ type (
|
|||||||
OrganizationID gid.GID `db:"organization_id"`
|
OrganizationID gid.GID `db:"organization_id"`
|
||||||
OwnerID gid.GID `db:"owner_profile_id"`
|
OwnerID gid.GID `db:"owner_profile_id"`
|
||||||
DataClassification DataClassification `db:"data_classification"`
|
DataClassification DataClassification `db:"data_classification"`
|
||||||
SnapshotID *gid.GID `db:"snapshot_id"`
|
|
||||||
SourceID *gid.GID `db:"source_id"`
|
|
||||||
CreatedAt time.Time `db:"created_at"`
|
CreatedAt time.Time `db:"created_at"`
|
||||||
UpdatedAt time.Time `db:"updated_at"`
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
Data []*Datum
|
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 {
|
func (d *Datum) CursorKey(field DatumOrderField) page.CursorKey {
|
||||||
@@ -88,8 +82,6 @@ SELECT
|
|||||||
owner_profile_id,
|
owner_profile_id,
|
||||||
organization_id,
|
organization_id,
|
||||||
data_classification,
|
data_classification,
|
||||||
snapshot_id,
|
|
||||||
source_id,
|
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
FROM
|
FROM
|
||||||
@@ -97,6 +89,7 @@ FROM
|
|||||||
WHERE
|
WHERE
|
||||||
%s
|
%s
|
||||||
AND id = @data_id
|
AND id = @data_id
|
||||||
|
AND snapshot_id IS NULL
|
||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -132,8 +125,6 @@ SELECT
|
|||||||
owner_profile_id,
|
owner_profile_id,
|
||||||
organization_id,
|
organization_id,
|
||||||
data_classification,
|
data_classification,
|
||||||
snapshot_id,
|
|
||||||
source_id,
|
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
FROM
|
FROM
|
||||||
@@ -141,6 +132,7 @@ FROM
|
|||||||
WHERE
|
WHERE
|
||||||
%s
|
%s
|
||||||
AND owner_profile_id = @owner_profile_id
|
AND owner_profile_id = @owner_profile_id
|
||||||
|
AND snapshot_id IS NULL
|
||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -169,7 +161,6 @@ func (d *Data) CountByOrganizationID(
|
|||||||
conn pg.Querier,
|
conn pg.Querier,
|
||||||
scope Scoper,
|
scope Scoper,
|
||||||
organizationID gid.GID,
|
organizationID gid.GID,
|
||||||
filter *DatumFilter,
|
|
||||||
) (int, error) {
|
) (int, error) {
|
||||||
q := `
|
q := `
|
||||||
SELECT
|
SELECT
|
||||||
@@ -179,14 +170,13 @@ FROM
|
|||||||
WHERE
|
WHERE
|
||||||
%s
|
%s
|
||||||
AND organization_id = @organization_id
|
AND organization_id = @organization_id
|
||||||
AND %s
|
AND snapshot_id IS NULL
|
||||||
`
|
`
|
||||||
|
|
||||||
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||||
|
|
||||||
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
||||||
maps.Copy(args, scope.SQLArguments())
|
maps.Copy(args, scope.SQLArguments())
|
||||||
maps.Copy(args, filter.SQLArguments())
|
|
||||||
|
|
||||||
row := conn.QueryRow(ctx, q, args)
|
row := conn.QueryRow(ctx, q, args)
|
||||||
|
|
||||||
@@ -205,7 +195,6 @@ func (d *Data) LoadByOrganizationID(
|
|||||||
scope Scoper,
|
scope Scoper,
|
||||||
organizationID gid.GID,
|
organizationID gid.GID,
|
||||||
cursor *page.Cursor[DatumOrderField],
|
cursor *page.Cursor[DatumOrderField],
|
||||||
filter *DatumFilter,
|
|
||||||
) error {
|
) error {
|
||||||
q := `
|
q := `
|
||||||
SELECT
|
SELECT
|
||||||
@@ -214,8 +203,6 @@ SELECT
|
|||||||
organization_id,
|
organization_id,
|
||||||
owner_profile_id,
|
owner_profile_id,
|
||||||
data_classification,
|
data_classification,
|
||||||
snapshot_id,
|
|
||||||
source_id,
|
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
FROM
|
FROM
|
||||||
@@ -223,15 +210,14 @@ FROM
|
|||||||
WHERE
|
WHERE
|
||||||
%s
|
%s
|
||||||
AND organization_id = @organization_id
|
AND organization_id = @organization_id
|
||||||
AND %s
|
AND snapshot_id IS NULL
|
||||||
AND %s
|
AND %s
|
||||||
`
|
`
|
||||||
|
|
||||||
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment())
|
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
|
||||||
|
|
||||||
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
||||||
maps.Copy(args, scope.SQLArguments())
|
maps.Copy(args, scope.SQLArguments())
|
||||||
maps.Copy(args, filter.SQLArguments())
|
|
||||||
maps.Copy(args, cursor.SQLArguments())
|
maps.Copy(args, cursor.SQLArguments())
|
||||||
|
|
||||||
rows, err := conn.Query(ctx, q, args)
|
rows, err := conn.Query(ctx, q, args)
|
||||||
@@ -249,6 +235,51 @@ WHERE
|
|||||||
return nil
|
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(
|
func (d *Datum) Insert(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
conn pg.Tx,
|
conn pg.Tx,
|
||||||
@@ -262,8 +293,6 @@ INSERT INTO data (
|
|||||||
owner_profile_id,
|
owner_profile_id,
|
||||||
organization_id,
|
organization_id,
|
||||||
data_classification,
|
data_classification,
|
||||||
snapshot_id,
|
|
||||||
source_id,
|
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
@@ -273,8 +302,6 @@ INSERT INTO data (
|
|||||||
@owner_profile_id,
|
@owner_profile_id,
|
||||||
@organization_id,
|
@organization_id,
|
||||||
@data_classification,
|
@data_classification,
|
||||||
@snapshot_id,
|
|
||||||
@source_id,
|
|
||||||
@created_at,
|
@created_at,
|
||||||
@updated_at
|
@updated_at
|
||||||
)
|
)
|
||||||
@@ -287,8 +314,6 @@ INSERT INTO data (
|
|||||||
"owner_profile_id": d.OwnerID,
|
"owner_profile_id": d.OwnerID,
|
||||||
"organization_id": d.OrganizationID,
|
"organization_id": d.OrganizationID,
|
||||||
"data_classification": d.DataClassification,
|
"data_classification": d.DataClassification,
|
||||||
"snapshot_id": d.SnapshotID,
|
|
||||||
"source_id": d.SourceID,
|
|
||||||
"created_at": d.CreatedAt,
|
"created_at": d.CreatedAt,
|
||||||
"updated_at": d.UpdatedAt,
|
"updated_at": d.UpdatedAt,
|
||||||
}
|
}
|
||||||
@@ -323,8 +348,6 @@ RETURNING
|
|||||||
owner_profile_id,
|
owner_profile_id,
|
||||||
organization_id,
|
organization_id,
|
||||||
data_classification,
|
data_classification,
|
||||||
snapshot_id,
|
|
||||||
source_id,
|
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
`
|
`
|
||||||
@@ -380,73 +403,3 @@ WHERE
|
|||||||
|
|
||||||
return nil
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ package coredata
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
@@ -27,10 +26,9 @@ import (
|
|||||||
|
|
||||||
type (
|
type (
|
||||||
DatumVendor struct {
|
DatumVendor struct {
|
||||||
DatumID gid.GID `db:"datum_id"`
|
DatumID gid.GID `db:"datum_id"`
|
||||||
VendorID gid.GID `db:"vendor_id"`
|
VendorID gid.GID `db:"vendor_id"`
|
||||||
SnapshotID *gid.GID `db:"snapshot_id"`
|
CreatedAt time.Time `db:"created_at"`
|
||||||
CreatedAt time.Time `db:"created_at"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
DatumVendors []*DatumVendor
|
DatumVendors []*DatumVendor
|
||||||
@@ -119,61 +117,3 @@ FROM vendor_ids
|
|||||||
|
|
||||||
return nil
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
22
pkg/coredata/migrations/20260420T120000Z.sql
Normal file
22
pkg/coredata/migrations/20260420T120000Z.sql
Normal 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)
|
||||||
|
);
|
||||||
@@ -124,7 +124,7 @@ FROM
|
|||||||
WHERE
|
WHERE
|
||||||
%s
|
%s
|
||||||
AND organization_id = @organization_id
|
AND organization_id = @organization_id
|
||||||
AND type != 'STATEMENTS_OF_APPLICABILITY'
|
AND type NOT IN ('STATEMENTS_OF_APPLICABILITY', 'DATA')
|
||||||
AND %s
|
AND %s
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -164,7 +164,7 @@ FROM
|
|||||||
WHERE
|
WHERE
|
||||||
%s
|
%s
|
||||||
AND organization_id = @organization_id
|
AND organization_id = @organization_id
|
||||||
AND type != 'STATEMENTS_OF_APPLICABILITY'
|
AND type NOT IN ('STATEMENTS_OF_APPLICABILITY', 'DATA')
|
||||||
AND %s
|
AND %s
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ func SnapshotsTypes() []SnapshotsType {
|
|||||||
SnapshotsTypeRisks,
|
SnapshotsTypeRisks,
|
||||||
SnapshotsTypeVendors,
|
SnapshotsTypeVendors,
|
||||||
SnapshotsTypeAssets,
|
SnapshotsTypeAssets,
|
||||||
SnapshotsTypeData,
|
|
||||||
SnapshotsTypeFindings,
|
SnapshotsTypeFindings,
|
||||||
SnapshotsTypeObligations,
|
SnapshotsTypeObligations,
|
||||||
SnapshotsTypeProcessingActivities,
|
SnapshotsTypeProcessingActivities,
|
||||||
|
|||||||
@@ -32,8 +32,6 @@ func GetSnapshottable(snapshotType SnapshotsType) (Snapshottable, error) {
|
|||||||
return Assets{}, nil
|
return Assets{}, nil
|
||||||
case SnapshotsTypeRisks:
|
case SnapshotsTypeRisks:
|
||||||
return Risks{}, nil
|
return Risks{}, nil
|
||||||
case SnapshotsTypeData:
|
|
||||||
return Data{}, nil
|
|
||||||
case SnapshotsTypeFindings:
|
case SnapshotsTypeFindings:
|
||||||
return Findings{}, nil
|
return Findings{}, nil
|
||||||
case SnapshotsTypeObligations:
|
case SnapshotsTypeObligations:
|
||||||
|
|||||||
@@ -717,6 +717,102 @@ WHERE %s
|
|||||||
return count, nil
|
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(
|
func (vs *Vendors) LoadByDatumID(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
conn pg.Querier,
|
conn pg.Querier,
|
||||||
@@ -981,108 +1077,6 @@ ORDER BY
|
|||||||
return vendorMap, nil
|
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(
|
func (vs Vendors) InsertAssetSnapshots(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
conn pg.Tx,
|
conn pg.Tx,
|
||||||
|
|||||||
@@ -296,6 +296,21 @@ type (
|
|||||||
BestPractice string
|
BestPractice string
|
||||||
RiskAssessment 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 {
|
func BoolLabel(v bool) string {
|
||||||
|
|||||||
@@ -234,11 +234,12 @@ const (
|
|||||||
ActionAssetDelete = "core:asset:delete"
|
ActionAssetDelete = "core:asset:delete"
|
||||||
|
|
||||||
// Datum actions
|
// Datum actions
|
||||||
ActionDatumGet = "core:datum:get"
|
ActionDatumGet = "core:datum:get"
|
||||||
ActionDatumList = "core:datum:list"
|
ActionDatumList = "core:datum:list"
|
||||||
ActionDatumCreate = "core:datum:create"
|
ActionDatumCreate = "core:datum:create"
|
||||||
ActionDatumUpdate = "core:datum:update"
|
ActionDatumUpdate = "core:datum:update"
|
||||||
ActionDatumDelete = "core:datum:delete"
|
ActionDatumDelete = "core:datum:delete"
|
||||||
|
ActionDatumPublish = "core:datum:publish"
|
||||||
|
|
||||||
// Audit actions
|
// Audit actions
|
||||||
ActionAuditGet = "core:audit:get"
|
ActionAuditGet = "core:audit:get"
|
||||||
|
|||||||
@@ -119,7 +119,6 @@ func (s DatumService) GetByOwnerID(
|
|||||||
func (s DatumService) CountForOrganizationID(
|
func (s DatumService) CountForOrganizationID(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
organizationID gid.GID,
|
organizationID gid.GID,
|
||||||
filter *coredata.DatumFilter,
|
|
||||||
) (int, error) {
|
) (int, error) {
|
||||||
var count int
|
var count int
|
||||||
|
|
||||||
@@ -127,7 +126,7 @@ func (s DatumService) CountForOrganizationID(
|
|||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Querier) (err error) {
|
func(ctx context.Context, conn pg.Querier) (err error) {
|
||||||
data := coredata.Data{}
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot count data: %w", err)
|
return fmt.Errorf("cannot count data: %w", err)
|
||||||
}
|
}
|
||||||
@@ -147,7 +146,6 @@ func (s DatumService) ListForOrganizationID(
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
organizationID gid.GID,
|
organizationID gid.GID,
|
||||||
cursor *page.Cursor[coredata.DatumOrderField],
|
cursor *page.Cursor[coredata.DatumOrderField],
|
||||||
filter *coredata.DatumFilter,
|
|
||||||
) (*page.Page[*coredata.Datum, coredata.DatumOrderField], error) {
|
) (*page.Page[*coredata.Datum, coredata.DatumOrderField], error) {
|
||||||
var data coredata.Data
|
var data coredata.Data
|
||||||
|
|
||||||
@@ -160,7 +158,6 @@ func (s DatumService) ListForOrganizationID(
|
|||||||
s.svc.scope,
|
s.svc.scope,
|
||||||
organizationID,
|
organizationID,
|
||||||
cursor,
|
cursor,
|
||||||
filter,
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,9 +20,11 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"go.gearno.de/kit/pg"
|
"go.gearno.de/kit/pg"
|
||||||
"go.probo.inc/probo/pkg/coredata"
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
"go.probo.inc/probo/pkg/docgen"
|
"go.probo.inc/probo/pkg/docgen"
|
||||||
@@ -339,6 +341,322 @@ func (s *GeneratedDocumentService) buildStatementOfApplicabilityDocumentData(
|
|||||||
}, nil
|
}, 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(
|
var soaTemplate = template.Must(
|
||||||
template.New("statement_of_applicability.json.tmpl").
|
template.New("statement_of_applicability.json.tmpl").
|
||||||
Funcs(template.FuncMap{
|
Funcs(template.FuncMap{
|
||||||
|
|||||||
81
pkg/probo/templates/data_list.json.tmpl
Normal file
81
pkg/probo/templates/data_list.json.tmpl
Normal 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." }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -220,12 +220,7 @@ func (r *datumConnectionResolver) TotalCount(ctx context.Context, obj *types.Dat
|
|||||||
|
|
||||||
switch obj.Resolver.(type) {
|
switch obj.Resolver.(type) {
|
||||||
case *organizationResolver:
|
case *organizationResolver:
|
||||||
datumFilter := coredata.NewDatumFilter(nil)
|
count, err := prb.Data.CountForOrganizationID(ctx, obj.ParentID)
|
||||||
if obj.Filter != nil {
|
|
||||||
datumFilter = coredata.NewDatumFilter(&obj.Filter.SnapshotID)
|
|
||||||
}
|
|
||||||
|
|
||||||
count, err := prb.Data.CountForOrganizationID(ctx, obj.ParentID, datumFilter)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.logger.ErrorCtx(ctx, "cannot count data", log.Error(err))
|
r.logger.ErrorCtx(ctx, "cannot count data", log.Error(err))
|
||||||
return 0, gqlutils.Internal(ctx)
|
return 0, gqlutils.Internal(ctx)
|
||||||
@@ -405,6 +400,29 @@ func (r *mutationResolver) DeleteDatum(ctx context.Context, input types.DeleteDa
|
|||||||
}, nil
|
}, 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.
|
// Asset returns schema.AssetResolver implementation.
|
||||||
func (r *Resolver) Asset() schema.AssetResolver { return &assetResolver{r} }
|
func (r *Resolver) Asset() schema.AssetResolver { return &assetResolver{r} }
|
||||||
|
|
||||||
|
|||||||
@@ -66,10 +66,6 @@ input AssetFilter {
|
|||||||
snapshotId: ID
|
snapshotId: ID
|
||||||
}
|
}
|
||||||
|
|
||||||
input DatumFilter {
|
|
||||||
snapshotId: ID
|
|
||||||
}
|
|
||||||
|
|
||||||
type Asset implements Node {
|
type Asset implements Node {
|
||||||
id: ID!
|
id: ID!
|
||||||
snapshotId: ID
|
snapshotId: ID
|
||||||
@@ -97,7 +93,6 @@ type Datum implements Node
|
|||||||
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.Datum"
|
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.Datum"
|
||||||
) {
|
) {
|
||||||
id: ID!
|
id: ID!
|
||||||
snapshotId: ID
|
|
||||||
name: String!
|
name: String!
|
||||||
dataClassification: DataClassification!
|
dataClassification: DataClassification!
|
||||||
owner: Profile! @goField(forceResolver: true)
|
owner: Profile! @goField(forceResolver: true)
|
||||||
@@ -150,6 +145,9 @@ extend type Mutation {
|
|||||||
createDatum(input: CreateDatumInput!): CreateDatumPayload!
|
createDatum(input: CreateDatumInput!): CreateDatumPayload!
|
||||||
updateDatum(input: UpdateDatumInput!): UpdateDatumPayload!
|
updateDatum(input: UpdateDatumInput!): UpdateDatumPayload!
|
||||||
deleteDatum(input: DeleteDatumInput!): DeleteDatumPayload!
|
deleteDatum(input: DeleteDatumInput!): DeleteDatumPayload!
|
||||||
|
publishDataList(
|
||||||
|
input: PublishDataListInput!
|
||||||
|
): PublishDataListPayload!
|
||||||
}
|
}
|
||||||
|
|
||||||
input CreateAssetInput {
|
input CreateAssetInput {
|
||||||
@@ -219,3 +217,13 @@ type UpdateDatumPayload {
|
|||||||
type DeleteDatumPayload {
|
type DeleteDatumPayload {
|
||||||
deletedDatumId: ID!
|
deletedDatumId: ID!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input PublishDataListInput {
|
||||||
|
organizationId: ID!
|
||||||
|
approverIds: [ID!]
|
||||||
|
}
|
||||||
|
|
||||||
|
type PublishDataListPayload {
|
||||||
|
documentEdge: DocumentEdge!
|
||||||
|
documentVersionEdge: DocumentVersionEdge!
|
||||||
|
}
|
||||||
|
|||||||
@@ -130,13 +130,14 @@ type Organization implements Node {
|
|||||||
filter: AssetFilter = { snapshotId: null }
|
filter: AssetFilter = { snapshotId: null }
|
||||||
): AssetConnection! @goField(forceResolver: true)
|
): AssetConnection! @goField(forceResolver: true)
|
||||||
|
|
||||||
|
dataListDocument: Document @goField(forceResolver: true)
|
||||||
|
|
||||||
data(
|
data(
|
||||||
first: Int
|
first: Int
|
||||||
after: CursorKey
|
after: CursorKey
|
||||||
last: Int
|
last: Int
|
||||||
before: CursorKey
|
before: CursorKey
|
||||||
orderBy: DatumOrder
|
orderBy: DatumOrder
|
||||||
filter: DatumFilter = { snapshotId: null }
|
|
||||||
): DatumConnection! @goField(forceResolver: true)
|
): DatumConnection! @goField(forceResolver: true)
|
||||||
|
|
||||||
audits(
|
audits(
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ enum SnapshotsType
|
|||||||
VENDORS
|
VENDORS
|
||||||
@goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeVendors")
|
@goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeVendors")
|
||||||
ASSETS @goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeAssets")
|
ASSETS @goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeAssets")
|
||||||
DATA @goEnum(value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeData")
|
|
||||||
FINDINGS
|
FINDINGS
|
||||||
@goEnum(
|
@goEnum(
|
||||||
value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeFindings"
|
value: "go.probo.inc/probo/pkg/coredata.SnapshotsTypeFindings"
|
||||||
|
|||||||
@@ -256,8 +256,32 @@ func (r *organizationResolver) Assets(ctx context.Context, obj *types.Organizati
|
|||||||
return types.NewAssetConnection(page, r, obj.ID, filter), nil
|
return types.NewAssetConnection(page, r, obj.ID, filter), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assets is the resolver for the assets field.
|
// DataListDocument is the resolver for the dataListDocument 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) {
|
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 {
|
if err := r.authorize(ctx, obj.ID, probo.ActionDatumList); err != nil {
|
||||||
return nil, err
|
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)
|
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
||||||
|
|
||||||
datumFilter := coredata.NewDatumFilter(nil)
|
page, err := prb.Data.ListForOrganizationID(ctx, obj.ID, cursor)
|
||||||
if filter != nil {
|
|
||||||
datumFilter = coredata.NewDatumFilter(&filter.SnapshotID)
|
|
||||||
}
|
|
||||||
|
|
||||||
page, err := prb.Data.ListForOrganizationID(ctx, obj.ID, cursor, datumFilter)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.logger.ErrorCtx(ctx, "cannot list organization data", log.Error(err))
|
r.logger.ErrorCtx(ctx, "cannot list organization data", log.Error(err))
|
||||||
return nil, gqlutils.Internal(ctx)
|
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.
|
// Audits is the resolver for the audits field.
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import (
|
|||||||
type Datum struct {
|
type Datum struct {
|
||||||
ID gid.GID `json:"id"`
|
ID gid.GID `json:"id"`
|
||||||
OrganizationID gid.GID `json:"-"`
|
OrganizationID gid.GID `json:"-"`
|
||||||
SnapshotID *gid.GID `json:"snapshotId,omitempty"`
|
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DataClassification coredata.DataClassification `json:"dataClassification"`
|
DataClassification coredata.DataClassification `json:"dataClassification"`
|
||||||
Owner *Profile `json:"owner"`
|
Owner *Profile `json:"owner"`
|
||||||
@@ -48,7 +47,6 @@ type (
|
|||||||
|
|
||||||
Resolver any
|
Resolver any
|
||||||
ParentID gid.GID
|
ParentID gid.GID
|
||||||
Filter *DatumFilter
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,7 +54,6 @@ func NewDataConnection(
|
|||||||
p *page.Page[*coredata.Datum, coredata.DatumOrderField],
|
p *page.Page[*coredata.Datum, coredata.DatumOrderField],
|
||||||
parentType any,
|
parentType any,
|
||||||
parentID gid.GID,
|
parentID gid.GID,
|
||||||
filter *DatumFilter,
|
|
||||||
) *DatumConnection {
|
) *DatumConnection {
|
||||||
edges := make([]*DatumEdge, len(p.Data))
|
edges := make([]*DatumEdge, len(p.Data))
|
||||||
for i, datum := range p.Data {
|
for i, datum := range p.Data {
|
||||||
@@ -69,7 +66,6 @@ func NewDataConnection(
|
|||||||
|
|
||||||
Resolver: parentType,
|
Resolver: parentType,
|
||||||
ParentID: parentID,
|
ParentID: parentID,
|
||||||
Filter: filter,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +80,6 @@ func NewDatum(d *coredata.Datum) *Datum {
|
|||||||
},
|
},
|
||||||
OrganizationID: d.OrganizationID,
|
OrganizationID: d.OrganizationID,
|
||||||
Name: d.Name,
|
Name: d.Name,
|
||||||
SnapshotID: d.SnapshotID,
|
|
||||||
DataClassification: d.DataClassification,
|
DataClassification: d.DataClassification,
|
||||||
CreatedAt: d.CreatedAt,
|
CreatedAt: d.CreatedAt,
|
||||||
UpdatedAt: d.UpdatedAt,
|
UpdatedAt: d.UpdatedAt,
|
||||||
|
|||||||
@@ -658,13 +658,7 @@ func (r *Resolver) ListDataTool(ctx context.Context, req *mcp.CallToolRequest, i
|
|||||||
|
|
||||||
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
|
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
|
||||||
|
|
||||||
noSnapshot := (*gid.GID)(nil)
|
page, err := prb.Data.ListForOrganizationID(ctx, input.OrganizationID, cursor)
|
||||||
datumFilter := coredata.NewDatumFilter(&noSnapshot)
|
|
||||||
if input.Filter != nil {
|
|
||||||
datumFilter = coredata.NewDatumFilter(&input.Filter.SnapshotID)
|
|
||||||
}
|
|
||||||
|
|
||||||
page, err := prb.Data.ListForOrganizationID(ctx, input.OrganizationID, cursor, datumFilter)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(fmt.Errorf("cannot list organization data: %w", err))
|
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),
|
ApprovalDecision: types.NewDocumentVersionApprovalDecision(decision),
|
||||||
}, nil
|
}, 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -2218,13 +2218,6 @@ components:
|
|||||||
organization_id:
|
organization_id:
|
||||||
$ref: "#/components/schemas/GID"
|
$ref: "#/components/schemas/GID"
|
||||||
description: Organization ID
|
description: Organization ID
|
||||||
snapshot_id:
|
|
||||||
anyOf:
|
|
||||||
- $ref: "#/components/schemas/GID"
|
|
||||||
description: Snapshot ID
|
|
||||||
- type: "null"
|
|
||||||
description: No snapshot
|
|
||||||
description: Snapshot ID
|
|
||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
description: Datum name
|
description: Datum name
|
||||||
@@ -2260,16 +2253,6 @@ components:
|
|||||||
cursor:
|
cursor:
|
||||||
$ref: "#/components/schemas/CursorKey"
|
$ref: "#/components/schemas/CursorKey"
|
||||||
description: Page cursor
|
description: Page cursor
|
||||||
filter:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
snapshot_id:
|
|
||||||
anyOf:
|
|
||||||
- $ref: "#/components/schemas/GID"
|
|
||||||
- type: "null"
|
|
||||||
description: Filter by snapshot ID. Defaults to null, which returns only data with no snapshot (current live data). Pass a specific snapshot ID to retrieve data as it was at that snapshot.
|
|
||||||
default: null
|
|
||||||
|
|
||||||
ListDataOutput:
|
ListDataOutput:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
@@ -5017,7 +5000,6 @@ components:
|
|||||||
- RISKS
|
- RISKS
|
||||||
- VENDORS
|
- VENDORS
|
||||||
- ASSETS
|
- ASSETS
|
||||||
- DATA
|
|
||||||
- NONCONFORMITIES
|
- NONCONFORMITIES
|
||||||
- OBLIGATIONS
|
- OBLIGATIONS
|
||||||
- CONTINUAL_IMPROVEMENTS
|
- CONTINUAL_IMPROVEMENTS
|
||||||
@@ -6794,6 +6776,33 @@ components:
|
|||||||
description: Deleted statement of applicability ID
|
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:
|
PublishStatementOfApplicabilityInput:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
@@ -8860,7 +8869,7 @@ tools:
|
|||||||
outputSchema:
|
outputSchema:
|
||||||
$ref: "#/components/schemas/GetSnapshotOutput"
|
$ref: "#/components/schemas/GetSnapshotOutput"
|
||||||
- name: takeSnapshot
|
- 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:
|
hints:
|
||||||
readonly: false
|
readonly: false
|
||||||
inputSchema:
|
inputSchema:
|
||||||
@@ -9124,6 +9133,14 @@ tools:
|
|||||||
$ref: "#/components/schemas/DeleteStatementOfApplicabilityInput"
|
$ref: "#/components/schemas/DeleteStatementOfApplicabilityInput"
|
||||||
outputSchema:
|
outputSchema:
|
||||||
$ref: "#/components/schemas/DeleteStatementOfApplicabilityOutput"
|
$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
|
- name: publishStatementOfApplicability
|
||||||
description: Publish a statement of applicability as a document. If a document already exists, a new version is created.
|
description: Publish a statement of applicability as a document. If a document already exists, a new version is created.
|
||||||
hints:
|
hints:
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ func NewDatum(d *coredata.Datum) *Datum {
|
|||||||
Name: d.Name,
|
Name: d.Name,
|
||||||
OwnerID: d.OwnerID,
|
OwnerID: d.OwnerID,
|
||||||
OrganizationID: d.OrganizationID,
|
OrganizationID: d.OrganizationID,
|
||||||
SnapshotID: d.SnapshotID,
|
|
||||||
DataClassification: d.DataClassification,
|
DataClassification: d.DataClassification,
|
||||||
CreatedAt: d.CreatedAt,
|
CreatedAt: d.CreatedAt,
|
||||||
UpdatedAt: d.UpdatedAt,
|
UpdatedAt: d.UpdatedAt,
|
||||||
|
|||||||
Reference in New Issue
Block a user