SOA as document: replace export with publish workflow
Statements of Applicability are no longer exported as one-off PDFs. Instead, each SOA owns a persistent document that accumulates versions over time, following the same publish/approve lifecycle as authored documents. Publishing without approvers publishes immediately; publishing with approvers creates a draft pending approval via the existing quorum system. SOAs can also store default approvers that are pre-populated in the publish dialog. The SOA is removed from the snapshot system — applicability statements are now queried directly (snapshot_id IS NULL) rather than through snapshot copies. A standalone migration script (cmd/migrate-soa-snapshots-to-documents) converts existing SOA snapshots into documents with proper ProseMirror content, preserving version history and approval decisions. Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
@@ -67,6 +67,7 @@ export const documentLayoutQuery = graphql`
|
|||||||
... on Document {
|
... on Document {
|
||||||
id
|
id
|
||||||
status
|
status
|
||||||
|
writeMode
|
||||||
canPublish: permission(action: "core:document-version:publish")
|
canPublish: permission(action: "core:document-version:publish")
|
||||||
...PublishDialog_documentFragment
|
...PublishDialog_documentFragment
|
||||||
controlInfo: controls(first: 0) {
|
controlInfo: controls(first: 0) {
|
||||||
@@ -146,6 +147,7 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
|
|||||||
const isPendingApproval = currentVersion.status === "PENDING_APPROVAL";
|
const isPendingApproval = currentVersion.status === "PENDING_APPROVAL";
|
||||||
const isDraft = currentVersion.status === "DRAFT";
|
const isDraft = currentVersion.status === "DRAFT";
|
||||||
const isPublished = currentVersion.status === "PUBLISHED";
|
const isPublished = currentVersion.status === "PUBLISHED";
|
||||||
|
const isGenerated = document.writeMode === "GENERATED";
|
||||||
const isEditable = isLatestVersion && !isPendingApproval;
|
const isEditable = isLatestVersion && !isPendingApproval;
|
||||||
const lastQuorum = currentVersion.approvalQuorums?.edges?.[0]?.node ?? null;
|
const lastQuorum = currentVersion.approvalQuorums?.edges?.[0]?.node ?? null;
|
||||||
const hasApprovals = lastQuorum != null;
|
const hasApprovals = lastQuorum != null;
|
||||||
@@ -223,11 +225,12 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
|
|||||||
fKey={currentVersion}
|
fKey={currentVersion}
|
||||||
documentId={document.id}
|
documentId={document.id}
|
||||||
documentStatus={document.status}
|
documentStatus={document.status}
|
||||||
isEditable={isEditable}
|
isEditable={isEditable && !isGenerated}
|
||||||
onDocumentUpdated={handleDocumentUpdated}
|
onDocumentUpdated={handleDocumentUpdated}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{isGenerated && <Badge variant="neutral">{__("Generated")}</Badge>}
|
||||||
<Badge
|
<Badge
|
||||||
variant={currentVersion.status === "PUBLISHED" ? "success" : currentVersion.status === "PENDING_APPROVAL" ? "warning" : "highlight"}
|
variant={currentVersion.status === "PUBLISHED" ? "success" : currentVersion.status === "PENDING_APPROVAL" ? "warning" : "highlight"}
|
||||||
>
|
>
|
||||||
@@ -239,6 +242,8 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
|
|||||||
documentFragmentRef={document}
|
documentFragmentRef={document}
|
||||||
versionFragmentRef={currentVersion}
|
versionFragmentRef={currentVersion}
|
||||||
isEditable={isEditable}
|
isEditable={isEditable}
|
||||||
|
isGenerated={isGenerated}
|
||||||
|
isLatestVersion={isLatestVersion}
|
||||||
onDocumentUpdated={handleDocumentUpdated}
|
onDocumentUpdated={handleDocumentUpdated}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -124,9 +124,18 @@ export function DocumentDetailsCard(props: {
|
|||||||
documentFragmentRef: DocumentDetailsCard_documentFragment$key;
|
documentFragmentRef: DocumentDetailsCard_documentFragment$key;
|
||||||
versionFragmentRef: DocumentDetailsCard_versionFragment$key;
|
versionFragmentRef: DocumentDetailsCard_versionFragment$key;
|
||||||
isEditable: boolean;
|
isEditable: boolean;
|
||||||
|
isGenerated?: boolean;
|
||||||
|
isLatestVersion?: boolean;
|
||||||
onDocumentUpdated: () => void;
|
onDocumentUpdated: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { documentFragmentRef, versionFragmentRef, isEditable, onDocumentUpdated } = props;
|
const {
|
||||||
|
documentFragmentRef,
|
||||||
|
versionFragmentRef,
|
||||||
|
isEditable,
|
||||||
|
isGenerated = false,
|
||||||
|
isLatestVersion = true,
|
||||||
|
onDocumentUpdated,
|
||||||
|
} = props;
|
||||||
|
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const organizationId = useOrganizationId();
|
const organizationId = useOrganizationId();
|
||||||
@@ -140,6 +149,8 @@ export function DocumentDetailsCard(props: {
|
|||||||
const version = useFragment<DocumentDetailsCard_versionFragment$key>(versionFragment, versionFragmentRef);
|
const version = useFragment<DocumentDetailsCard_versionFragment$key>(versionFragment, versionFragmentRef);
|
||||||
|
|
||||||
const canEdit = document.canUpdate && isEditable;
|
const canEdit = document.canUpdate && isEditable;
|
||||||
|
const canEditVersionFields = canEdit && !isGenerated;
|
||||||
|
const canEditApprovers = document.canUpdate && isLatestVersion;
|
||||||
|
|
||||||
const { control, handleSubmit, reset } = useFormWithSchema(
|
const { control, handleSubmit, reset } = useFormWithSchema(
|
||||||
schema,
|
schema,
|
||||||
@@ -272,59 +283,6 @@ export function DocumentDetailsCard(props: {
|
|||||||
return (
|
return (
|
||||||
<Card className="space-y-4" padded>
|
<Card className="space-y-4" padded>
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<div>
|
|
||||||
<div className="text-xs text-txt-tertiary font-semibold mb-1">
|
|
||||||
{__("Approvers")}
|
|
||||||
</div>
|
|
||||||
{isEditingApprovers
|
|
||||||
? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex-1">
|
|
||||||
<PeopleMultiSelectField
|
|
||||||
name="approverIds"
|
|
||||||
control={approversControl}
|
|
||||||
organizationId={organizationId}
|
|
||||||
selectedPeople={document.defaultApprovers.map(a => ({
|
|
||||||
id: a.id,
|
|
||||||
fullName: a.fullName,
|
|
||||||
emailAddress: a.emailAddress,
|
|
||||||
}))}
|
|
||||||
placeholder={__("Add approvers...")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="quaternary"
|
|
||||||
icon={IconCheckmark1}
|
|
||||||
onClick={() => void handleApproversSubmit(handleUpdateApprovers)()}
|
|
||||||
disabled={isUpdatingApprovers}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="quaternary"
|
|
||||||
icon={IconCrossLargeX}
|
|
||||||
onClick={() => {
|
|
||||||
setIsEditingApprovers(false);
|
|
||||||
resetApprovers({ approverIds: document.defaultApprovers.map(a => a.id) });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="text-sm text-txt-primary">
|
|
||||||
{document.defaultApprovers.length > 0
|
|
||||||
? document.defaultApprovers.map(a => a.fullName).join(", ")
|
|
||||||
: __("None")}
|
|
||||||
</div>
|
|
||||||
{canEdit && (
|
|
||||||
<Button
|
|
||||||
variant="quaternary"
|
|
||||||
icon={IconPencil}
|
|
||||||
onClick={() => setIsEditingApprovers(true)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-txt-tertiary font-semibold mb-1">
|
<div className="text-xs text-txt-tertiary font-semibold mb-1">
|
||||||
{__("Type")}
|
{__("Type")}
|
||||||
@@ -362,7 +320,7 @@ export function DocumentDetailsCard(props: {
|
|||||||
<div className="text-sm text-txt-primary">
|
<div className="text-sm text-txt-primary">
|
||||||
{getDocumentTypeLabel(__, version.documentType)}
|
{getDocumentTypeLabel(__, version.documentType)}
|
||||||
</div>
|
</div>
|
||||||
{canEdit && (
|
{canEditVersionFields && (
|
||||||
<Button
|
<Button
|
||||||
variant="quaternary"
|
variant="quaternary"
|
||||||
icon={IconPencil}
|
icon={IconPencil}
|
||||||
@@ -409,7 +367,7 @@ export function DocumentDetailsCard(props: {
|
|||||||
<div className="text-sm text-txt-primary">
|
<div className="text-sm text-txt-primary">
|
||||||
{getDocumentClassificationLabel(__, version.classification)}
|
{getDocumentClassificationLabel(__, version.classification)}
|
||||||
</div>
|
</div>
|
||||||
{canEdit && (
|
{canEditVersionFields && (
|
||||||
<Button
|
<Button
|
||||||
variant="quaternary"
|
variant="quaternary"
|
||||||
icon={IconPencil}
|
icon={IconPencil}
|
||||||
@@ -419,6 +377,61 @@ export function DocumentDetailsCard(props: {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{isLatestVersion && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-txt-tertiary font-semibold mb-1">
|
||||||
|
{__("Approvers")}
|
||||||
|
</div>
|
||||||
|
{isEditingApprovers
|
||||||
|
? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<PeopleMultiSelectField
|
||||||
|
name="approverIds"
|
||||||
|
control={approversControl}
|
||||||
|
organizationId={organizationId}
|
||||||
|
selectedPeople={document.defaultApprovers.map(a => ({
|
||||||
|
id: a.id,
|
||||||
|
fullName: a.fullName,
|
||||||
|
emailAddress: a.emailAddress,
|
||||||
|
}))}
|
||||||
|
placeholder={__("Add approvers...")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="quaternary"
|
||||||
|
icon={IconCheckmark1}
|
||||||
|
onClick={() => void handleApproversSubmit(handleUpdateApprovers)()}
|
||||||
|
disabled={isUpdatingApprovers}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="quaternary"
|
||||||
|
icon={IconCrossLargeX}
|
||||||
|
onClick={() => {
|
||||||
|
setIsEditingApprovers(false);
|
||||||
|
resetApprovers({ approverIds: document.defaultApprovers.map(a => a.id) });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="text-sm text-txt-primary">
|
||||||
|
{document.defaultApprovers.length > 0
|
||||||
|
? document.defaultApprovers.map(a => a.fullName).join(", ")
|
||||||
|
: __("None")}
|
||||||
|
</div>
|
||||||
|
{canEditApprovers && (
|
||||||
|
<Button
|
||||||
|
variant="quaternary"
|
||||||
|
icon={IconPencil}
|
||||||
|
onClick={() => setIsEditingApprovers(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
// 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 { documentClassifications, documentTypes, getDocumentClassificationLabel, getDocumentTypeLabel, sprintf } from "@probo/helpers";
|
import { documentClassifications, documentTypes, documentWriteModes, getDocumentClassificationLabel, getDocumentTypeLabel, getDocumentWriteModeLabel, sprintf } from "@probo/helpers";
|
||||||
import { useList } from "@probo/hooks";
|
import { useList } from "@probo/hooks";
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import { Button, Card, Checkbox, IconArchive, IconArrowDown, IconCrossLargeX, IconSignature, IconTrashCan, IconUpload, Option, Select, Tbody, Th, Thead, Tr, useConfirm } from "@probo/ui";
|
import { Button, Card, Checkbox, IconArchive, IconArrowDown, IconCrossLargeX, IconSignature, IconTrashCan, IconUpload, Option, Select, Tbody, Th, Thead, Tr, useConfirm } from "@probo/ui";
|
||||||
@@ -23,7 +23,7 @@ import { ConnectionHandler, graphql } from "relay-runtime";
|
|||||||
import type { DocumentListBulkArchiveMutation } from "#/__generated__/core/DocumentListBulkArchiveMutation.graphql";
|
import type { DocumentListBulkArchiveMutation } from "#/__generated__/core/DocumentListBulkArchiveMutation.graphql";
|
||||||
import type { DocumentListBulkUnarchiveMutation } from "#/__generated__/core/DocumentListBulkUnarchiveMutation.graphql";
|
import type { DocumentListBulkUnarchiveMutation } from "#/__generated__/core/DocumentListBulkUnarchiveMutation.graphql";
|
||||||
import type { DocumentListFragment$key } from "#/__generated__/core/DocumentListFragment.graphql";
|
import type { DocumentListFragment$key } from "#/__generated__/core/DocumentListFragment.graphql";
|
||||||
import type { DocumentClassification, DocumentOrderField, DocumentsListQuery, DocumentType } from "#/__generated__/core/DocumentsListQuery.graphql";
|
import type { DocumentClassification, DocumentOrderField, DocumentsListQuery, DocumentType, DocumentWriteMode } from "#/__generated__/core/DocumentsListQuery.graphql";
|
||||||
import { BulkExportDialog, type BulkExportDialogRef } from "#/components/documents/BulkExportDialog";
|
import { BulkExportDialog, type BulkExportDialogRef } from "#/components/documents/BulkExportDialog";
|
||||||
import { type Order, SortableTable, SortableTh } from "#/components/SortableTable";
|
import { type Order, SortableTable, SortableTh } from "#/components/SortableTable";
|
||||||
import { useBulkDeleteDocumentsMutation, useBulkExportDocumentsMutation } from "#/hooks/graph/DocumentGraph";
|
import { useBulkDeleteDocumentsMutation, useBulkExportDocumentsMutation } from "#/hooks/graph/DocumentGraph";
|
||||||
@@ -50,6 +50,7 @@ const fragment = graphql`
|
|||||||
status: { type: "[DocumentStatus!]", defaultValue: [ACTIVE] }
|
status: { type: "[DocumentStatus!]", defaultValue: [ACTIVE] }
|
||||||
documentTypes: { type: "[DocumentType!]", defaultValue: null }
|
documentTypes: { type: "[DocumentType!]", defaultValue: null }
|
||||||
classifications: { type: "[DocumentClassification!]", defaultValue: null }
|
classifications: { type: "[DocumentClassification!]", defaultValue: null }
|
||||||
|
writeModes: { type: "[DocumentWriteMode!]", defaultValue: null }
|
||||||
) {
|
) {
|
||||||
documents(
|
documents(
|
||||||
first: $first
|
first: $first
|
||||||
@@ -57,7 +58,7 @@ const fragment = graphql`
|
|||||||
last: $last
|
last: $last
|
||||||
before: $before
|
before: $before
|
||||||
orderBy: $order
|
orderBy: $order
|
||||||
filter: { status: $status documentTypes: $documentTypes classifications: $classifications }
|
filter: { status: $status documentTypes: $documentTypes classifications: $classifications writeModes: $writeModes }
|
||||||
) @connection(key: "DocumentsListQuery_documents" filters: ["orderBy", "filter"]) {
|
) @connection(key: "DocumentsListQuery_documents" filters: ["orderBy", "filter"]) {
|
||||||
__id
|
__id
|
||||||
edges {
|
edges {
|
||||||
@@ -130,6 +131,7 @@ export function DocumentList(props: {
|
|||||||
|
|
||||||
const [documentTypeFilter, setDocumentTypeFilter] = useState<DocumentType | null>(null);
|
const [documentTypeFilter, setDocumentTypeFilter] = useState<DocumentType | null>(null);
|
||||||
const [classificationFilter, setClassificationFilter] = useState<DocumentClassification | null>(null);
|
const [classificationFilter, setClassificationFilter] = useState<DocumentClassification | null>(null);
|
||||||
|
const [writeModeFilter, setWriteModeFilter] = useState<DocumentWriteMode | null>(null);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const refetch = pagination.refetch;
|
const refetch = pagination.refetch;
|
||||||
@@ -140,11 +142,12 @@ export function DocumentList(props: {
|
|||||||
status: [tab],
|
status: [tab],
|
||||||
documentTypes: documentTypeFilter ? [documentTypeFilter] : null,
|
documentTypes: documentTypeFilter ? [documentTypeFilter] : null,
|
||||||
classifications: classificationFilter ? [classificationFilter] : null,
|
classifications: classificationFilter ? [classificationFilter] : null,
|
||||||
|
writeModes: writeModeFilter ? [writeModeFilter] : null,
|
||||||
},
|
},
|
||||||
{ fetchPolicy: "store-and-network" },
|
{ fetchPolicy: "store-and-network" },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}, [tab, refetch, documentTypeFilter, classificationFilter]);
|
}, [tab, refetch, documentTypeFilter, classificationFilter, writeModeFilter]);
|
||||||
|
|
||||||
const documents = pagination.data.documents.edges.map(({ node }) => node);
|
const documents = pagination.data.documents.edges.map(({ node }) => node);
|
||||||
const connectionId = pagination.data.documents.__id;
|
const connectionId = pagination.data.documents.__id;
|
||||||
@@ -194,6 +197,7 @@ export function DocumentList(props: {
|
|||||||
status: [tab],
|
status: [tab],
|
||||||
documentTypes: newType ? [newType] : null,
|
documentTypes: newType ? [newType] : null,
|
||||||
classifications: classificationFilter ? [classificationFilter] : null,
|
classifications: classificationFilter ? [classificationFilter] : null,
|
||||||
|
writeModes: writeModeFilter ? [writeModeFilter] : null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -214,6 +218,28 @@ export function DocumentList(props: {
|
|||||||
status: [tab],
|
status: [tab],
|
||||||
documentTypes: documentTypeFilter ? [documentTypeFilter] : null,
|
documentTypes: documentTypeFilter ? [documentTypeFilter] : null,
|
||||||
classifications: newClassification ? [newClassification] : null,
|
classifications: newClassification ? [newClassification] : null,
|
||||||
|
writeModes: writeModeFilter ? [writeModeFilter] : null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWriteModeFilterChange = (value: string) => {
|
||||||
|
const newWriteMode = value === "ALL" ? null : (value as DocumentWriteMode);
|
||||||
|
clear();
|
||||||
|
setWriteModeFilter(newWriteMode);
|
||||||
|
onConnectionIdChange(
|
||||||
|
ConnectionHandler.getConnectionID(
|
||||||
|
organizationId,
|
||||||
|
"DocumentsListQuery_documents",
|
||||||
|
{
|
||||||
|
orderBy: { direction: "ASC", field: "TITLE" },
|
||||||
|
filter: {
|
||||||
|
status: [tab],
|
||||||
|
documentTypes: documentTypeFilter ? [documentTypeFilter] : null,
|
||||||
|
classifications: classificationFilter ? [classificationFilter] : null,
|
||||||
|
writeModes: newWriteMode ? [newWriteMode] : null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -297,6 +323,7 @@ export function DocumentList(props: {
|
|||||||
status: [tab],
|
status: [tab],
|
||||||
documentTypes: documentTypeFilter ? [documentTypeFilter] : null,
|
documentTypes: documentTypeFilter ? [documentTypeFilter] : null,
|
||||||
classifications: classificationFilter ? [classificationFilter] : null,
|
classifications: classificationFilter ? [classificationFilter] : null,
|
||||||
|
writeModes: writeModeFilter ? [writeModeFilter] : null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -309,12 +336,24 @@ export function DocumentList(props: {
|
|||||||
status: [tab],
|
status: [tab],
|
||||||
documentTypes: documentTypeFilter ? [documentTypeFilter] : null,
|
documentTypes: documentTypeFilter ? [documentTypeFilter] : null,
|
||||||
classifications: classificationFilter ? [classificationFilter] : null,
|
classifications: classificationFilter ? [classificationFilter] : null,
|
||||||
|
writeModes: writeModeFilter ? [writeModeFilter] : null,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
<Select
|
||||||
|
value={writeModeFilter ?? "ALL"}
|
||||||
|
onValueChange={handleWriteModeFilterChange}
|
||||||
|
>
|
||||||
|
<Option value="ALL">{__("All sources")}</Option>
|
||||||
|
{documentWriteModes.map(source => (
|
||||||
|
<Option key={source} value={source}>
|
||||||
|
{getDocumentWriteModeLabel(__, source) ?? source}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
<Select
|
<Select
|
||||||
value={documentTypeFilter ?? "ALL"}
|
value={documentTypeFilter ?? "ALL"}
|
||||||
onValueChange={handleDocumentTypeFilterChange}
|
onValueChange={handleDocumentTypeFilterChange}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export const documentDescriptionPageQuery = graphql`
|
|||||||
... on Document {
|
... on Document {
|
||||||
id
|
id
|
||||||
status
|
status
|
||||||
|
writeMode
|
||||||
canUpdate: permission(action: "core:document:update")
|
canUpdate: permission(action: "core:document:update")
|
||||||
# We use this on /documents/:documentId/description
|
# We use this on /documents/:documentId/description
|
||||||
lastVersion: versions(first: 1 orderBy: { field: CREATED_AT, direction: DESC }) @skip(if: $versionSpecified) {
|
lastVersion: versions(first: 1 orderBy: { field: CREATED_AT, direction: DESC }) @skip(if: $versionSpecified) {
|
||||||
@@ -146,7 +147,8 @@ export function DocumentDescriptionPage(props: {
|
|||||||
|
|
||||||
const canEdit = isEditable
|
const canEdit = isEditable
|
||||||
&& document.canUpdate
|
&& document.canUpdate
|
||||||
&& document.status !== "ARCHIVED";
|
&& document.status !== "ARCHIVED"
|
||||||
|
&& document.writeMode !== "GENERATED";
|
||||||
|
|
||||||
// The editor key must change on explicit actions (delete draft, edit
|
// The editor key must change on explicit actions (delete draft, edit
|
||||||
// title/type) but NOT on auto-save side effects (cursor preservation).
|
// title/type) but NOT on auto-save side effects (cursor preservation).
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
// 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 { formatDate, formatError, type GraphQLError, promisifyMutation, sprintf, validateSnapshotConsistency } from "@probo/helpers";
|
import { formatDate, formatError, type GraphQLError, promisifyMutation, sprintf } from "@probo/helpers";
|
||||||
import { usePageTitle } from "@probo/hooks";
|
import { usePageTitle } from "@probo/hooks";
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import {
|
import {
|
||||||
@@ -21,17 +21,18 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
DropdownItem,
|
DropdownItem,
|
||||||
IconArrowDown,
|
|
||||||
IconCheckmark1,
|
IconCheckmark1,
|
||||||
IconCrossLargeX,
|
IconCrossLargeX,
|
||||||
|
IconPageTextLine,
|
||||||
IconPencil,
|
IconPencil,
|
||||||
IconTrashCan,
|
IconTrashCan,
|
||||||
|
IconUpload,
|
||||||
Input,
|
Input,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
useConfirm,
|
useConfirm,
|
||||||
useToast,
|
useToast,
|
||||||
} from "@probo/ui";
|
} from "@probo/ui";
|
||||||
import { Suspense, useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
ConnectionHandler,
|
ConnectionHandler,
|
||||||
graphql,
|
graphql,
|
||||||
@@ -39,19 +40,16 @@ import {
|
|||||||
useMutation,
|
useMutation,
|
||||||
usePreloadedQuery,
|
usePreloadedQuery,
|
||||||
} from "react-relay";
|
} from "react-relay";
|
||||||
import { useNavigate, useParams } from "react-router";
|
import { Link, useNavigate, useParams } from "react-router";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import type { StatementOfApplicabilityDetailPageDeleteMutation } from "#/__generated__/core/StatementOfApplicabilityDetailPageDeleteMutation.graphql";
|
import type { StatementOfApplicabilityDetailPageDeleteMutation } from "#/__generated__/core/StatementOfApplicabilityDetailPageDeleteMutation.graphql";
|
||||||
import type { StatementOfApplicabilityDetailPageExportMutation } from "#/__generated__/core/StatementOfApplicabilityDetailPageExportMutation.graphql";
|
|
||||||
import type { StatementOfApplicabilityDetailPageQuery } from "#/__generated__/core/StatementOfApplicabilityDetailPageQuery.graphql";
|
import type { StatementOfApplicabilityDetailPageQuery } from "#/__generated__/core/StatementOfApplicabilityDetailPageQuery.graphql";
|
||||||
import type { StatementOfApplicabilityDetailPageUpdateMutation } from "#/__generated__/core/StatementOfApplicabilityDetailPageUpdateMutation.graphql";
|
import type { StatementOfApplicabilityDetailPageUpdateMutation } from "#/__generated__/core/StatementOfApplicabilityDetailPageUpdateMutation.graphql";
|
||||||
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
|
|
||||||
import { SnapshotBanner } from "#/components/SnapshotBanner";
|
|
||||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
|
||||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||||
|
|
||||||
|
import { PublishStatementOfApplicabilityDialog } from "./dialogs/PublishStatementOfApplicabilityDialog";
|
||||||
import StatementOfApplicabilityControlsTab from "./tabs/StatementOfApplicabilityControlsTab";
|
import StatementOfApplicabilityControlsTab from "./tabs/StatementOfApplicabilityControlsTab";
|
||||||
|
|
||||||
export const statementOfApplicabilityDetailPageQuery = graphql`
|
export const statementOfApplicabilityDetailPageQuery = graphql`
|
||||||
@@ -60,32 +58,23 @@ export const statementOfApplicabilityDetailPageQuery = graphql`
|
|||||||
... on StatementOfApplicability {
|
... on StatementOfApplicability {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
snapshotId
|
|
||||||
createdAt
|
createdAt
|
||||||
updatedAt
|
updatedAt
|
||||||
|
document {
|
||||||
|
id
|
||||||
|
defaultApprovers {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
canUpdate: permission(action: "core:statement-of-applicability:update")
|
canUpdate: permission(action: "core:statement-of-applicability:update")
|
||||||
canDelete: permission(action: "core:statement-of-applicability:delete")
|
canDelete: permission(action: "core:statement-of-applicability:delete")
|
||||||
canExport: permission(action: "core:statement-of-applicability:export")
|
canPublish: permission(action: "core:statement-of-applicability:publish")
|
||||||
owner {
|
|
||||||
id
|
|
||||||
fullName
|
|
||||||
}
|
|
||||||
...StatementOfApplicabilityControlsTabFragment
|
...StatementOfApplicabilityControlsTabFragment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const exportMutation = graphql`
|
|
||||||
mutation StatementOfApplicabilityDetailPageExportMutation(
|
|
||||||
$input: ExportStatementOfApplicabilityPDFInput!
|
|
||||||
) {
|
|
||||||
exportStatementOfApplicabilityPDF(input: $input) {
|
|
||||||
data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const updateMutation = graphql`
|
const updateMutation = graphql`
|
||||||
mutation StatementOfApplicabilityDetailPageUpdateMutation(
|
mutation StatementOfApplicabilityDetailPageUpdateMutation(
|
||||||
$input: UpdateStatementOfApplicabilityInput!
|
$input: UpdateStatementOfApplicabilityInput!
|
||||||
@@ -94,14 +83,8 @@ const updateMutation = graphql`
|
|||||||
statementOfApplicability {
|
statementOfApplicability {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
sourceId
|
|
||||||
snapshotId
|
|
||||||
createdAt
|
createdAt
|
||||||
updatedAt
|
updatedAt
|
||||||
owner {
|
|
||||||
id
|
|
||||||
fullName
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,16 +108,14 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function StatementOfApplicabilityDetailPage(props: Props) {
|
export default function StatementOfApplicabilityDetailPage(props: Props) {
|
||||||
const { statementOfApplicabilityId, snapshotId } = useParams<{
|
const { statementOfApplicabilityId } = useParams<{
|
||||||
statementOfApplicabilityId: string;
|
statementOfApplicabilityId: string;
|
||||||
snapshotId?: string;
|
|
||||||
}>();
|
}>();
|
||||||
const organizationId = useOrganizationId();
|
const organizationId = useOrganizationId();
|
||||||
const data = usePreloadedQuery(statementOfApplicabilityDetailPageQuery, props.queryRef);
|
const data = usePreloadedQuery(statementOfApplicabilityDetailPageQuery, props.queryRef);
|
||||||
const statementOfApplicability = data.node;
|
const statementOfApplicability = data.node;
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isSnapshotMode = Boolean(snapshotId);
|
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@@ -144,12 +125,9 @@ export default function StatementOfApplicabilityDetailPage(props: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
validateSnapshotConsistency(statementOfApplicability, snapshotId);
|
|
||||||
|
|
||||||
const connectionId = ConnectionHandler.getConnectionID(
|
const connectionId = ConnectionHandler.getConnectionID(
|
||||||
organizationId,
|
organizationId,
|
||||||
StatementOfApplicabilityConnectionKey,
|
StatementOfApplicabilityConnectionKey,
|
||||||
{ filter: { snapshotId: snapshotId ?? null } },
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const [deleteStatementOfApplicability]
|
const [deleteStatementOfApplicability]
|
||||||
@@ -196,60 +174,16 @@ export default function StatementOfApplicabilityDetailPage(props: Props) {
|
|||||||
usePageTitle(statementOfApplicability.name || __("Statement of Applicability"));
|
usePageTitle(statementOfApplicability.name || __("Statement of Applicability"));
|
||||||
|
|
||||||
const [isEditingName, setIsEditingName] = useState(false);
|
const [isEditingName, setIsEditingName] = useState(false);
|
||||||
const [isEditingOwner, setIsEditingOwner] = useState(false);
|
|
||||||
const [updateStatementOfApplicability, isUpdating]
|
const [updateStatementOfApplicability, isUpdating]
|
||||||
= useMutationWithToasts<StatementOfApplicabilityDetailPageUpdateMutation>(
|
= useMutation<StatementOfApplicabilityDetailPageUpdateMutation>(updateMutation);
|
||||||
updateMutation,
|
|
||||||
{
|
|
||||||
successMessage: __("Statement of Applicability updated successfully."),
|
|
||||||
errorMessage: __("Failed to update Statement of Applicability"),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const canUpdate = !isSnapshotMode && statementOfApplicability.canUpdate;
|
const canUpdate = statementOfApplicability.canUpdate;
|
||||||
const canDelete = !isSnapshotMode && statementOfApplicability.canDelete;
|
const canDelete = statementOfApplicability.canDelete;
|
||||||
|
|
||||||
const [exportStatementOfApplicabilityPDF, isExporting]
|
|
||||||
= useMutationWithToasts<StatementOfApplicabilityDetailPageExportMutation>(
|
|
||||||
exportMutation,
|
|
||||||
{
|
|
||||||
successMessage: __(
|
|
||||||
"Statement of Applicability exported successfully.",
|
|
||||||
),
|
|
||||||
errorMessage: __("Failed to export Statement of Applicability"),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleExport = async () => {
|
|
||||||
if (!statementOfApplicability.id) return;
|
|
||||||
|
|
||||||
await exportStatementOfApplicabilityPDF({
|
|
||||||
variables: {
|
|
||||||
input: {
|
|
||||||
statementOfApplicabilityId: statementOfApplicability.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onCompleted: (data) => {
|
|
||||||
if (data.exportStatementOfApplicabilityPDF?.data) {
|
|
||||||
const link = window.document.createElement("a");
|
|
||||||
link.href = data.exportStatementOfApplicabilityPDF.data;
|
|
||||||
link.download = `${statementOfApplicability.name || "statement-of-applicability"}.pdf`;
|
|
||||||
window.document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
window.document.body.removeChild(link);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const nameSchema = z.object({
|
const nameSchema = z.object({
|
||||||
name: z.string().min(1, __("Name is required")),
|
name: z.string().min(1, __("Name is required")),
|
||||||
});
|
});
|
||||||
|
|
||||||
const ownerSchema = z.object({
|
|
||||||
ownerId: z.string().min(1, __("Owner is required")),
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register: registerName,
|
register: registerName,
|
||||||
handleSubmit: handleSubmitName,
|
handleSubmit: handleSubmitName,
|
||||||
@@ -260,46 +194,34 @@ export default function StatementOfApplicabilityDetailPage(props: Props) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const handleUpdateName = handleSubmitName((data) => {
|
||||||
control: controlOwner,
|
|
||||||
handleSubmit: handleSubmitOwner,
|
|
||||||
reset: resetOwner,
|
|
||||||
} = useFormWithSchema(ownerSchema, {
|
|
||||||
defaultValues: {
|
|
||||||
ownerId: statementOfApplicability.owner?.id || "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleUpdateName = handleSubmitName(async (data) => {
|
|
||||||
if (!statementOfApplicability.id) return;
|
if (!statementOfApplicability.id) return;
|
||||||
|
|
||||||
await updateStatementOfApplicability({
|
updateStatementOfApplicability({
|
||||||
variables: {
|
variables: {
|
||||||
input: {
|
input: {
|
||||||
id: statementOfApplicability.id,
|
id: statementOfApplicability.id,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onCompleted() {
|
||||||
|
toast({
|
||||||
|
title: __("Success"),
|
||||||
|
description: __("Statement of Applicability updated successfully."),
|
||||||
|
variant: "success",
|
||||||
|
});
|
||||||
setIsEditingName(false);
|
setIsEditingName(false);
|
||||||
resetName({ name: data.name });
|
resetName({ name: data.name });
|
||||||
},
|
},
|
||||||
});
|
onError(error) {
|
||||||
});
|
toast({
|
||||||
|
title: __("Error"),
|
||||||
const handleUpdateOwner = handleSubmitOwner(async (data) => {
|
description: formatError(
|
||||||
if (!statementOfApplicability.id) return;
|
__("Failed to update Statement of Applicability"),
|
||||||
|
error as GraphQLError,
|
||||||
await updateStatementOfApplicability({
|
),
|
||||||
variables: {
|
variant: "error",
|
||||||
input: {
|
});
|
||||||
id: statementOfApplicability.id,
|
|
||||||
ownerId: data.ownerId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
setIsEditingOwner(false);
|
|
||||||
resetOwner({ ownerId: data.ownerId });
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -311,20 +233,12 @@ export default function StatementOfApplicabilityDetailPage(props: Props) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancelOwnerEdit = () => {
|
const defaultApproverIds = (statementOfApplicability.document?.defaultApprovers ?? []).map(a => a.id);
|
||||||
setIsEditingOwner(false);
|
|
||||||
resetOwner({
|
|
||||||
ownerId: statementOfApplicability.owner?.id || "",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const listUrl = snapshotId
|
const listUrl = `/organizations/${organizationId}/statements-of-applicability`;
|
||||||
? `/organizations/${organizationId}/snapshots/${snapshotId}/statements-of-applicability`
|
|
||||||
: `/organizations/${organizationId}/statements-of-applicability`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{snapshotId && <SnapshotBanner snapshotId={snapshotId} />}
|
|
||||||
<Breadcrumb
|
<Breadcrumb
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
@@ -385,16 +299,33 @@ export default function StatementOfApplicabilityDetailPage(props: Props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{statementOfApplicability.canExport && (
|
{statementOfApplicability.document?.id && (
|
||||||
<Button
|
<Button variant="secondary" asChild>
|
||||||
variant="secondary"
|
<Link
|
||||||
icon={IconArrowDown}
|
to={`/organizations/${organizationId}/documents/${statementOfApplicability.document.id}`}
|
||||||
onClick={() => void handleExport()}
|
>
|
||||||
disabled={isExporting}
|
<IconPageTextLine size={16} />
|
||||||
>
|
{__("Document")}
|
||||||
{__("Export")}
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{statementOfApplicability.canPublish && statementOfApplicability.id && (
|
||||||
|
<PublishStatementOfApplicabilityDialog
|
||||||
|
statementOfApplicabilityId={statementOfApplicability.id}
|
||||||
|
defaultApproverIds={defaultApproverIds}
|
||||||
|
onPublished={(documentId) => {
|
||||||
|
void navigate(
|
||||||
|
`/organizations/${organizationId}/documents/${documentId}`,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
icon={IconUpload}
|
||||||
|
>
|
||||||
|
{__("Publish")}
|
||||||
|
</Button>
|
||||||
|
</PublishStatementOfApplicabilityDialog>
|
||||||
|
)}
|
||||||
{canDelete && (
|
{canDelete && (
|
||||||
<ActionDropdown variant="secondary">
|
<ActionDropdown variant="secondary">
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
@@ -412,56 +343,6 @@ export default function StatementOfApplicabilityDetailPage(props: Props) {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h2 className="text-base font-medium">{__("Details")}</h2>
|
<h2 className="text-base font-medium">{__("Details")}</h2>
|
||||||
<Card className="space-y-4" padded>
|
<Card className="space-y-4" padded>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-txt-tertiary font-semibold mb-1">
|
|
||||||
{__("Owner")}
|
|
||||||
</div>
|
|
||||||
{isEditingOwner && canUpdate
|
|
||||||
? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<div>{__("Loading...")}</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<PeopleSelectField
|
|
||||||
organizationId={organizationId}
|
|
||||||
control={controlOwner}
|
|
||||||
name="ownerId"
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
<Button
|
|
||||||
variant="quaternary"
|
|
||||||
icon={IconCheckmark1}
|
|
||||||
onClick={() => void handleUpdateOwner()}
|
|
||||||
disabled={isUpdating}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="quaternary"
|
|
||||||
icon={IconCrossLargeX}
|
|
||||||
onClick={handleCancelOwnerEdit}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="text-sm text-txt-primary">
|
|
||||||
{statementOfApplicability.owner
|
|
||||||
?.fullName || "-"}
|
|
||||||
</div>
|
|
||||||
{canUpdate && (
|
|
||||||
<Button
|
|
||||||
variant="quaternary"
|
|
||||||
icon={IconPencil}
|
|
||||||
onClick={() =>
|
|
||||||
setIsEditingOwner(true)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-txt-tertiary font-semibold mb-1">
|
<div className="text-xs text-txt-tertiary font-semibold mb-1">
|
||||||
@@ -494,7 +375,6 @@ export default function StatementOfApplicabilityDetailPage(props: Props) {
|
|||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
isSnapshotMode={isSnapshotMode}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -25,19 +25,16 @@ import {
|
|||||||
Thead,
|
Thead,
|
||||||
Tr,
|
Tr,
|
||||||
} from "@probo/ui";
|
} from "@probo/ui";
|
||||||
import { useEffect } from "react";
|
|
||||||
import {
|
import {
|
||||||
graphql,
|
graphql,
|
||||||
type PreloadedQuery,
|
type PreloadedQuery,
|
||||||
usePaginationFragment,
|
usePaginationFragment,
|
||||||
usePreloadedQuery,
|
usePreloadedQuery,
|
||||||
} from "react-relay";
|
} from "react-relay";
|
||||||
import { useParams } from "react-router";
|
|
||||||
|
|
||||||
import type { StatementsOfApplicabilityPageFragment$key } from "#/__generated__/core/StatementsOfApplicabilityPageFragment.graphql";
|
import type { StatementsOfApplicabilityPageFragment$key } from "#/__generated__/core/StatementsOfApplicabilityPageFragment.graphql";
|
||||||
import type { StatementsOfApplicabilityPagePaginationQuery } from "#/__generated__/core/StatementsOfApplicabilityPagePaginationQuery.graphql";
|
import type { StatementsOfApplicabilityPagePaginationQuery } from "#/__generated__/core/StatementsOfApplicabilityPagePaginationQuery.graphql";
|
||||||
import type { StatementsOfApplicabilityPageQuery } from "#/__generated__/core/StatementsOfApplicabilityPageQuery.graphql";
|
import type { StatementsOfApplicabilityPageQuery } from "#/__generated__/core/StatementsOfApplicabilityPageQuery.graphql";
|
||||||
import { SnapshotBanner } from "#/components/SnapshotBanner";
|
|
||||||
|
|
||||||
import { StatementOfApplicabilityRow } from "./_components/StatementOfApplicabilityRow";
|
import { StatementOfApplicabilityRow } from "./_components/StatementOfApplicabilityRow";
|
||||||
import { CreateStatementOfApplicabilityDialog } from "./dialogs/CreateStatementOfApplicabilityDialog";
|
import { CreateStatementOfApplicabilityDialog } from "./dialogs/CreateStatementOfApplicabilityDialog";
|
||||||
@@ -67,7 +64,6 @@ const paginatedFragment = 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 }
|
||||||
filter: { type: "StatementOfApplicabilityFilter", defaultValue: { snapshotId: null } }
|
|
||||||
) {
|
) {
|
||||||
statementsOfApplicability(
|
statementsOfApplicability(
|
||||||
first: $first
|
first: $first
|
||||||
@@ -75,8 +71,7 @@ const paginatedFragment = graphql`
|
|||||||
last: $last
|
last: $last
|
||||||
before: $before
|
before: $before
|
||||||
orderBy: $order
|
orderBy: $order
|
||||||
filter: $filter
|
) @connection(key: "StatementsOfApplicabilityPage_statementsOfApplicability") {
|
||||||
) @connection(key: "StatementsOfApplicabilityPage_statementsOfApplicability", filters: ["filter"]) {
|
|
||||||
__id
|
__id
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
@@ -94,8 +89,6 @@ export default function StatementsOfApplicabilityPage({
|
|||||||
queryRef: PreloadedQuery<StatementsOfApplicabilityPageQuery>;
|
queryRef: PreloadedQuery<StatementsOfApplicabilityPageQuery>;
|
||||||
}) {
|
}) {
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const { snapshotId } = useParams<{ snapshotId?: string }>();
|
|
||||||
const isSnapshotMode = Boolean(snapshotId);
|
|
||||||
|
|
||||||
usePageTitle(__("Statements of Applicability"));
|
usePageTitle(__("Statements of Applicability"));
|
||||||
|
|
||||||
@@ -109,33 +102,21 @@ export default function StatementsOfApplicabilityPage({
|
|||||||
data: { statementsOfApplicability },
|
data: { statementsOfApplicability },
|
||||||
loadNext,
|
loadNext,
|
||||||
hasNext,
|
hasNext,
|
||||||
refetch,
|
|
||||||
isLoadingNext,
|
isLoadingNext,
|
||||||
} = usePaginationFragment<
|
} = usePaginationFragment<
|
||||||
StatementsOfApplicabilityPagePaginationQuery,
|
StatementsOfApplicabilityPagePaginationQuery,
|
||||||
StatementsOfApplicabilityPageFragment$key
|
StatementsOfApplicabilityPageFragment$key
|
||||||
>(paginatedFragment, organization);
|
>(paginatedFragment, organization);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (snapshotId) {
|
|
||||||
refetch(
|
|
||||||
{ filter: { snapshotId } },
|
|
||||||
{ fetchPolicy: "store-or-network" },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [snapshotId, refetch]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{snapshotId && <SnapshotBanner snapshotId={snapshotId} />}
|
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={__("Statements of Applicability")}
|
title={__("Statements of Applicability")}
|
||||||
description={__(
|
description={__(
|
||||||
"Manage statements of applicability for your organization's frameworks.",
|
"Manage statements of applicability for your organization's frameworks.",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!isSnapshotMode
|
{organization.canCreateStatementOfApplicability && (
|
||||||
&& organization.canCreateStatementOfApplicability && (
|
|
||||||
<CreateStatementOfApplicabilityDialog
|
<CreateStatementOfApplicabilityDialog
|
||||||
connectionId={statementsOfApplicability.__id}
|
connectionId={statementsOfApplicability.__id}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import {
|
|||||||
useToast,
|
useToast,
|
||||||
} from "@probo/ui";
|
} from "@probo/ui";
|
||||||
import { useFragment, useMutation } from "react-relay";
|
import { useFragment, useMutation } from "react-relay";
|
||||||
import { useParams } from "react-router";
|
|
||||||
import { graphql } from "relay-runtime";
|
import { graphql } from "relay-runtime";
|
||||||
|
|
||||||
import type { StatementOfApplicabilityRowDeleteMutation } from "#/__generated__/core/StatementOfApplicabilityRowDeleteMutation.graphql";
|
import type { StatementOfApplicabilityRowDeleteMutation } from "#/__generated__/core/StatementOfApplicabilityRowDeleteMutation.graphql";
|
||||||
@@ -62,13 +61,11 @@ type Props = {
|
|||||||
export function StatementOfApplicabilityRow({ fKey, connectionId }: Props) {
|
export function StatementOfApplicabilityRow({ fKey, connectionId }: Props) {
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const organizationId = useOrganizationId();
|
const organizationId = useOrganizationId();
|
||||||
const { snapshotId } = useParams<{ snapshotId?: string }>();
|
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const isSnapshotMode = Boolean(snapshotId);
|
|
||||||
|
|
||||||
const statementOfApplicability = useFragment(fragment, fKey);
|
const statementOfApplicability = useFragment(fragment, fKey);
|
||||||
const canDelete = !isSnapshotMode && statementOfApplicability.canDelete;
|
const canDelete = statementOfApplicability.canDelete;
|
||||||
|
|
||||||
const [deleteStatementOfApplicability] = useMutation<StatementOfApplicabilityRowDeleteMutation>(deleteMutation);
|
const [deleteStatementOfApplicability] = useMutation<StatementOfApplicabilityRowDeleteMutation>(deleteMutation);
|
||||||
|
|
||||||
@@ -106,9 +103,7 @@ export function StatementOfApplicabilityRow({ fKey, connectionId }: Props) {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const detailUrl = snapshotId
|
const detailUrl = `/organizations/${organizationId}/statements-of-applicability/${statementOfApplicability.id}`;
|
||||||
? `/organizations/${organizationId}/snapshots/${snapshotId}/statements-of-applicability/${statementOfApplicability.id}`
|
|
||||||
: `/organizations/${organizationId}/statements-of-applicability/${statementOfApplicability.id}`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tr to={detailUrl}>
|
<Tr to={detailUrl}>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
// 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 { formatError, type GraphQLError } from "@probo/helpers";
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
@@ -21,17 +22,16 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
Field,
|
Field,
|
||||||
useDialogRef,
|
useDialogRef,
|
||||||
|
useToast,
|
||||||
} from "@probo/ui";
|
} from "@probo/ui";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Suspense } from "react";
|
import { useMutation } from "react-relay";
|
||||||
import { graphql } from "react-relay";
|
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
|
import { graphql } from "relay-runtime";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import type { CreateStatementOfApplicabilityDialogMutation } from "#/__generated__/core/CreateStatementOfApplicabilityDialogMutation.graphql";
|
import type { CreateStatementOfApplicabilityDialogMutation } from "#/__generated__/core/CreateStatementOfApplicabilityDialogMutation.graphql";
|
||||||
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
|
|
||||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
|
||||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||||
|
|
||||||
const createMutation = graphql`
|
const createMutation = graphql`
|
||||||
@@ -44,8 +44,6 @@ const createMutation = graphql`
|
|||||||
node {
|
node {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
sourceId
|
|
||||||
snapshotId
|
|
||||||
createdAt
|
createdAt
|
||||||
updatedAt
|
updatedAt
|
||||||
canDelete: permission(action: "core:statement-of-applicability:delete")
|
canDelete: permission(action: "core:statement-of-applicability:delete")
|
||||||
@@ -63,7 +61,6 @@ type Props = {
|
|||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
ownerId: z.string().min(1),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export function CreateStatementOfApplicabilityDialog({
|
export function CreateStatementOfApplicabilityDialog({
|
||||||
@@ -71,41 +68,37 @@ export function CreateStatementOfApplicabilityDialog({
|
|||||||
connectionId,
|
connectionId,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
|
const { toast } = useToast();
|
||||||
const organizationId = useOrganizationId();
|
const organizationId = useOrganizationId();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { control, register, handleSubmit, reset } = useFormWithSchema(
|
const { register, handleSubmit, reset } = useFormWithSchema(
|
||||||
schema,
|
schema,
|
||||||
{
|
{
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "",
|
name: "",
|
||||||
ownerId: "",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const ref = useDialogRef();
|
const ref = useDialogRef();
|
||||||
|
|
||||||
const [createStatementOfApplicability, isCreating]
|
const [createStatementOfApplicability, isCreating]
|
||||||
= useMutationWithToasts<CreateStatementOfApplicabilityDialogMutation>(
|
= useMutation<CreateStatementOfApplicabilityDialogMutation>(createMutation);
|
||||||
createMutation,
|
|
||||||
{
|
|
||||||
successMessage: __(
|
|
||||||
"Statement of applicability created successfully.",
|
|
||||||
),
|
|
||||||
errorMessage: __("Failed to create statement of applicability"),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const onSubmit = async (data: z.infer<typeof schema>) => {
|
const onSubmit = (data: z.infer<typeof schema>) => {
|
||||||
await createStatementOfApplicability({
|
createStatementOfApplicability({
|
||||||
variables: {
|
variables: {
|
||||||
input: {
|
input: {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
organizationId,
|
organizationId,
|
||||||
ownerId: data.ownerId,
|
|
||||||
},
|
},
|
||||||
connections: [connectionId],
|
connections: [connectionId],
|
||||||
},
|
},
|
||||||
onCompleted: (response) => {
|
onCompleted(response) {
|
||||||
|
toast({
|
||||||
|
title: __("Success"),
|
||||||
|
description: __("Statement of applicability created successfully."),
|
||||||
|
variant: "success",
|
||||||
|
});
|
||||||
reset();
|
reset();
|
||||||
ref.current?.close();
|
ref.current?.close();
|
||||||
const statementOfApplicabilityId
|
const statementOfApplicabilityId
|
||||||
@@ -115,6 +108,16 @@ export function CreateStatementOfApplicabilityDialog({
|
|||||||
`/organizations/${organizationId}/statements-of-applicability/${statementOfApplicabilityId}`,
|
`/organizations/${organizationId}/statements-of-applicability/${statementOfApplicabilityId}`,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
onError(error) {
|
||||||
|
toast({
|
||||||
|
title: __("Error"),
|
||||||
|
description: formatError(
|
||||||
|
__("Failed to create statement of applicability"),
|
||||||
|
error as GraphQLError,
|
||||||
|
),
|
||||||
|
variant: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -139,15 +142,6 @@ export function CreateStatementOfApplicabilityDialog({
|
|||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<Field label={__("Owner")}>
|
|
||||||
<Suspense fallback={<div>{__("Loading...")}</div>}>
|
|
||||||
<PeopleSelectField
|
|
||||||
organizationId={organizationId}
|
|
||||||
control={control}
|
|
||||||
name="ownerId"
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
</Field>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button disabled={isCreating} type="submit">
|
<Button disabled={isCreating} type="submit">
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
// 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 { PublishStatementOfApplicabilityDialogMutation } from "#/__generated__/core/PublishStatementOfApplicabilityDialogMutation.graphql";
|
||||||
|
import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField";
|
||||||
|
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||||
|
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||||
|
|
||||||
|
const publishMutation = graphql`
|
||||||
|
mutation PublishStatementOfApplicabilityDialogMutation(
|
||||||
|
$input: PublishStatementOfApplicabilityInput!
|
||||||
|
) {
|
||||||
|
publishStatementOfApplicability(input: $input) {
|
||||||
|
documentEdge {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: ReactNode;
|
||||||
|
statementOfApplicabilityId: string;
|
||||||
|
defaultApproverIds?: string[];
|
||||||
|
onPublished?: (documentId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PublishStatementOfApplicabilityDialog({
|
||||||
|
children,
|
||||||
|
statementOfApplicabilityId,
|
||||||
|
defaultApproverIds = [],
|
||||||
|
onPublished,
|
||||||
|
}: Props) {
|
||||||
|
const { __ } = useTranslate();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const organizationId = useOrganizationId();
|
||||||
|
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<PublishStatementOfApplicabilityDialogMutation>(publishMutation);
|
||||||
|
|
||||||
|
const approverIds = watch("approverIds");
|
||||||
|
const hasApprovers = approverIds.length > 0;
|
||||||
|
|
||||||
|
const onSubmit = (data: z.infer<typeof schema>) => {
|
||||||
|
publish({
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
statementOfApplicabilityId,
|
||||||
|
approverIds: data.approverIds.length > 0 ? data.approverIds : undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onCompleted(response) {
|
||||||
|
const documentId = response.publishStatementOfApplicability?.documentEdge?.node?.id;
|
||||||
|
if (documentId) {
|
||||||
|
toast({
|
||||||
|
title: __("Success"),
|
||||||
|
description: hasApprovers
|
||||||
|
? __("Approval requested successfully.")
|
||||||
|
: __("Statement of Applicability published successfully."),
|
||||||
|
variant: "success",
|
||||||
|
});
|
||||||
|
dialogRef.current?.close();
|
||||||
|
reset();
|
||||||
|
onPublished?.(documentId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
toast({
|
||||||
|
title: __("Error"),
|
||||||
|
description: formatError(
|
||||||
|
__("Failed to publish Statement of Applicability"),
|
||||||
|
error as GraphQLError,
|
||||||
|
),
|
||||||
|
variant: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
className="max-w-xl"
|
||||||
|
ref={dialogRef}
|
||||||
|
trigger={children}
|
||||||
|
title={__("Publish Statement of Applicability")}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -117,12 +117,10 @@ const deleteApplicabilityStatementMutation = graphql`
|
|||||||
|
|
||||||
export default function StatementOfApplicabilityControlsTab({
|
export default function StatementOfApplicabilityControlsTab({
|
||||||
statementOfApplicability,
|
statementOfApplicability,
|
||||||
isSnapshotMode = false,
|
|
||||||
}: {
|
}: {
|
||||||
statementOfApplicability: StatementOfApplicabilityControlsTabFragment$key & {
|
statementOfApplicability: StatementOfApplicabilityControlsTabFragment$key & {
|
||||||
id: string;
|
id: string;
|
||||||
};
|
};
|
||||||
isSnapshotMode?: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const data = useFragment(controlsFragment, statementOfApplicability);
|
const data = useFragment(controlsFragment, statementOfApplicability);
|
||||||
@@ -166,12 +164,9 @@ export default function StatementOfApplicabilityControlsTab({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const canCreate
|
const canCreate = data.canCreateApplicabilityStatement;
|
||||||
= !isSnapshotMode && data.canCreateApplicabilityStatement;
|
const canUpdate = data.canUpdateApplicabilityStatement;
|
||||||
const canUpdate
|
const canDelete = data.canDeleteApplicabilityStatement;
|
||||||
= !isSnapshotMode && data.canUpdateApplicabilityStatement;
|
|
||||||
const canDelete
|
|
||||||
= !isSnapshotMode && data.canDeleteApplicabilityStatement;
|
|
||||||
|
|
||||||
const handleOpenAddStatementDialog = () => {
|
const handleOpenAddStatementDialog = () => {
|
||||||
if (!data.organization || !connectionId) return;
|
if (!data.organization || !connectionId) return;
|
||||||
|
|||||||
@@ -25,13 +25,6 @@ export const statementsOfApplicabilityRoutes = [
|
|||||||
() => import("#/pages/organizations/statements-of-applicability/StatementsOfApplicabilityPageLoader"),
|
() => import("#/pages/organizations/statements-of-applicability/StatementsOfApplicabilityPageLoader"),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "snapshots/:snapshotId/statements-of-applicability",
|
|
||||||
Fallback: PageSkeleton,
|
|
||||||
Component: lazy(
|
|
||||||
() => import("#/pages/organizations/statements-of-applicability/StatementsOfApplicabilityPageLoader"),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "statements-of-applicability/:statementOfApplicabilityId",
|
path: "statements-of-applicability/:statementOfApplicabilityId",
|
||||||
Fallback: PageSkeleton,
|
Fallback: PageSkeleton,
|
||||||
@@ -39,11 +32,4 @@ export const statementsOfApplicabilityRoutes = [
|
|||||||
() => import("#/pages/organizations/statements-of-applicability/StatementOfApplicabilityDetailPageLoader"),
|
() => import("#/pages/organizations/statements-of-applicability/StatementOfApplicabilityDetailPageLoader"),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "snapshots/:snapshotId/statements-of-applicability/:statementOfApplicabilityId",
|
|
||||||
Fallback: PageSkeleton,
|
|
||||||
Component: lazy(
|
|
||||||
() => import("#/pages/organizations/statements-of-applicability/StatementOfApplicabilityDetailPageLoader"),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
] satisfies AppRoute[];
|
] satisfies AppRoute[];
|
||||||
|
|||||||
538
cmd/migrate-soa-snapshots-to-documents/main.go
Normal file
538
cmd/migrate-soa-snapshots-to-documents/main.go
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
// 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-soa-snapshots-to-documents creates documents, document
|
||||||
|
// versions, approval quorums, and approval decisions from existing SOA
|
||||||
|
// snapshots. For each snapshot it generates the ProseMirror content using
|
||||||
|
// the same builder as the publish flow.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"go.gearno.de/kit/pg"
|
||||||
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
|
"go.probo.inc/probo/pkg/docgen"
|
||||||
|
"go.probo.inc/probo/pkg/gid"
|
||||||
|
"go.probo.inc/probo/pkg/probo"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := run(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() error {
|
||||||
|
var (
|
||||||
|
pgDSN string
|
||||||
|
dryRun bool
|
||||||
|
)
|
||||||
|
|
||||||
|
flag.StringVar(
|
||||||
|
&pgDSN,
|
||||||
|
"pg-dsn",
|
||||||
|
os.Getenv("DATABASE_URL"),
|
||||||
|
"PostgreSQL connection URL (default: DATABASE_URL env)",
|
||||||
|
)
|
||||||
|
flag.BoolVar(&dryRun, "dry-run", false, "show what would be done without writing")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if pgDSN == "" {
|
||||||
|
return fmt.Errorf("set -pg-dsn or DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
pgClient, err := newPgClientFromDSN(pgDSN)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot create pg client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return pgClient.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
|
||||||
|
return migrate(ctx, tx, dryRun)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type originalSOA struct {
|
||||||
|
id string
|
||||||
|
tenantID gid.TenantID
|
||||||
|
organizationID gid.GID
|
||||||
|
name string
|
||||||
|
ownerProfileID *string
|
||||||
|
}
|
||||||
|
|
||||||
|
type snapshotSOA struct {
|
||||||
|
id string
|
||||||
|
snapshotID string
|
||||||
|
ownerProfileID *string
|
||||||
|
publishedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrate(ctx context.Context, tx pg.Tx, dryRun bool) error {
|
||||||
|
originals, err := loadOriginalSOAs(ctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(originals) == 0 {
|
||||||
|
fmt.Println("no SOAs with snapshots to migrate")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats struct {
|
||||||
|
documents, versions, quorums, decisions, defaultApprovers int
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, orig := range originals {
|
||||||
|
snapshots, err := loadSnapshots(ctx, tx, orig.id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if dryRun {
|
||||||
|
fmt.Printf("would migrate SOA %s (%s) — %d snapshot(s)\n", orig.id, orig.name, len(snapshots))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
documentID := gid.New(orig.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": orig.tenantID,
|
||||||
|
"organization_id": orig.organizationID,
|
||||||
|
"current_published_major": len(snapshots),
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot insert document for SOA %s: %w", orig.id, err)
|
||||||
|
}
|
||||||
|
stats.documents++
|
||||||
|
|
||||||
|
_, err = tx.Exec(
|
||||||
|
ctx,
|
||||||
|
`UPDATE statements_of_applicability SET document_id = @document_id WHERE id = @id`,
|
||||||
|
pgx.NamedArgs{
|
||||||
|
"document_id": documentID,
|
||||||
|
"id": orig.id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot link document to SOA %s: %w", orig.id, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if orig.ownerProfileID != nil {
|
||||||
|
_, err = tx.Exec(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
INSERT INTO document_default_approvers (
|
||||||
|
document_id, approver_profile_id, tenant_id, organization_id, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
@document_id, @approver_profile_id, @tenant_id, @organization_id, @created_at, @created_at
|
||||||
|
)`,
|
||||||
|
pgx.NamedArgs{
|
||||||
|
"document_id": documentID,
|
||||||
|
"approver_profile_id": *orig.ownerProfileID,
|
||||||
|
"tenant_id": orig.tenantID,
|
||||||
|
"organization_id": orig.organizationID,
|
||||||
|
"created_at": now,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot insert default approver for SOA %s: %w", orig.id, err)
|
||||||
|
}
|
||||||
|
stats.defaultApprovers++
|
||||||
|
}
|
||||||
|
|
||||||
|
for major, snap := range snapshots {
|
||||||
|
content, err := buildSnapshotContent(ctx, tx, snap.id, orig.name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot build content for snapshot %s of SOA %s: %w", snap.snapshotID, orig.id, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
versionID := gid.New(orig.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,
|
||||||
|
'STATEMENT_OF_APPLICABILITY'::document_type,
|
||||||
|
@content, '',
|
||||||
|
'PUBLISHED'::document_version_status,
|
||||||
|
'LANDSCAPE'::document_version_orientation,
|
||||||
|
@published_at, @published_at, @published_at
|
||||||
|
)`,
|
||||||
|
pgx.NamedArgs{
|
||||||
|
"id": versionID,
|
||||||
|
"tenant_id": orig.tenantID,
|
||||||
|
"organization_id": orig.organizationID,
|
||||||
|
"document_id": documentID,
|
||||||
|
"title": orig.name,
|
||||||
|
"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++
|
||||||
|
|
||||||
|
if snap.ownerProfileID != nil {
|
||||||
|
quorumID := gid.New(orig.tenantID, coredata.DocumentVersionApprovalQuorumEntityType)
|
||||||
|
|
||||||
|
_, err = tx.Exec(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
INSERT INTO document_version_approval_quorums (
|
||||||
|
id, tenant_id, organization_id, version_id, status, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
@id, @tenant_id, @organization_id, @version_id,
|
||||||
|
'APPROVED'::document_version_approval_quorum_status,
|
||||||
|
@created_at, @created_at
|
||||||
|
)`,
|
||||||
|
pgx.NamedArgs{
|
||||||
|
"id": quorumID,
|
||||||
|
"tenant_id": orig.tenantID,
|
||||||
|
"organization_id": orig.organizationID,
|
||||||
|
"version_id": versionID,
|
||||||
|
"created_at": snap.publishedAt,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot insert quorum for snapshot %s: %w", snap.snapshotID, err)
|
||||||
|
}
|
||||||
|
stats.quorums++
|
||||||
|
|
||||||
|
decisionID := gid.New(orig.tenantID, coredata.DocumentVersionApprovalDecisionEntityType)
|
||||||
|
|
||||||
|
_, err = tx.Exec(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
INSERT INTO document_version_approval_decisions (
|
||||||
|
id, tenant_id, organization_id, quorum_id,
|
||||||
|
approver_id, state, decided_at, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
@id, @tenant_id, @organization_id, @quorum_id,
|
||||||
|
@approver_id,
|
||||||
|
'APPROVED'::document_version_approval_decision_state,
|
||||||
|
@decided_at, @decided_at, @decided_at
|
||||||
|
)`,
|
||||||
|
pgx.NamedArgs{
|
||||||
|
"id": decisionID,
|
||||||
|
"tenant_id": orig.tenantID,
|
||||||
|
"organization_id": orig.organizationID,
|
||||||
|
"quorum_id": quorumID,
|
||||||
|
"approver_id": *snap.ownerProfileID,
|
||||||
|
"decided_at": snap.publishedAt,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot insert decision for snapshot %s: %w", snap.snapshotID, err)
|
||||||
|
}
|
||||||
|
stats.decisions++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("migrated SOA %s (%s) — %d version(s)\n", orig.id, orig.name, len(snapshots))
|
||||||
|
}
|
||||||
|
|
||||||
|
if dryRun {
|
||||||
|
fmt.Printf("\n%d SOA(s) would be migrated\n", len(originals))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\ncreated %d document(s), %d version(s), %d quorum(s), %d decision(s), %d default approver(s)\n",
|
||||||
|
stats.documents, stats.versions, stats.quorums, stats.decisions, stats.defaultApprovers)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadOriginalSOAs(ctx context.Context, tx pg.Tx) ([]originalSOA, error) {
|
||||||
|
rows, err := tx.Query(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
soa.id,
|
||||||
|
soa.tenant_id,
|
||||||
|
soa.organization_id,
|
||||||
|
soa.name,
|
||||||
|
soa.owner_profile_id
|
||||||
|
FROM statements_of_applicability soa
|
||||||
|
WHERE soa.snapshot_id IS NULL
|
||||||
|
AND soa.document_id IS NULL
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM statements_of_applicability snap
|
||||||
|
WHERE snap.source_id = soa.id AND snap.snapshot_id IS NOT NULL
|
||||||
|
)
|
||||||
|
ORDER BY soa.created_at;
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot query original SOAs: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var result []originalSOA
|
||||||
|
for rows.Next() {
|
||||||
|
var o originalSOA
|
||||||
|
if err := rows.Scan(&o.id, &o.tenantID, &o.organizationID, &o.name, &o.ownerProfileID); err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot scan original SOA: %w", err)
|
||||||
|
}
|
||||||
|
result = append(result, o)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadSnapshots(ctx context.Context, tx pg.Tx, originalSOAID string) ([]snapshotSOA, error) {
|
||||||
|
rows, err := tx.Query(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
snap.id,
|
||||||
|
snap.snapshot_id,
|
||||||
|
snap.owner_profile_id,
|
||||||
|
snap_record.created_at
|
||||||
|
FROM statements_of_applicability snap
|
||||||
|
JOIN snapshots snap_record ON snap_record.id = snap.snapshot_id
|
||||||
|
WHERE snap.source_id = @source_id
|
||||||
|
AND snap.snapshot_id IS NOT NULL
|
||||||
|
ORDER BY snap_record.created_at ASC;
|
||||||
|
`,
|
||||||
|
pgx.NamedArgs{"source_id": originalSOAID},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot query snapshots for SOA %s: %w", originalSOAID, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var result []snapshotSOA
|
||||||
|
for rows.Next() {
|
||||||
|
var s snapshotSOA
|
||||||
|
if err := rows.Scan(&s.id, &s.snapshotID, &s.ownerProfileID, &s.publishedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot scan snapshot: %w", err)
|
||||||
|
}
|
||||||
|
result = append(result, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
type snapshotControl struct {
|
||||||
|
frameworkName string
|
||||||
|
sectionTitle string
|
||||||
|
controlName string
|
||||||
|
applicability bool
|
||||||
|
justification *string
|
||||||
|
bestPractice bool
|
||||||
|
implemented string
|
||||||
|
notImplementedJustification *string
|
||||||
|
hasLegal bool
|
||||||
|
hasContractual bool
|
||||||
|
hasRisk bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSnapshotContent(ctx context.Context, tx pg.Tx, snapshotSOAID string, soaName string) (string, error) {
|
||||||
|
rows, err := tx.Query(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
WITH control_risks_via_measures AS (
|
||||||
|
SELECT DISTINCT cm.control_id
|
||||||
|
FROM controls_measures cm
|
||||||
|
INNER JOIN risks_measures rm ON cm.measure_id = rm.measure_id
|
||||||
|
),
|
||||||
|
control_risks_via_documents AS (
|
||||||
|
SELECT DISTINCT cd.control_id
|
||||||
|
FROM controls_documents cd
|
||||||
|
INNER JOIN risks_documents rd ON cd.document_id = rd.document_id
|
||||||
|
),
|
||||||
|
control_risks AS (
|
||||||
|
SELECT control_id FROM control_risks_via_measures
|
||||||
|
UNION
|
||||||
|
SELECT control_id FROM control_risks_via_documents
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
f.name AS framework_name,
|
||||||
|
c.section_title,
|
||||||
|
c.name AS control_name,
|
||||||
|
stmt.applicability,
|
||||||
|
stmt.justification,
|
||||||
|
c.best_practice,
|
||||||
|
c.implemented,
|
||||||
|
c.not_implemented_justification,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM controls_obligations co
|
||||||
|
JOIN obligations o ON o.id = co.obligation_id
|
||||||
|
WHERE co.control_id = c.id AND o.type = 'LEGAL'
|
||||||
|
) AS has_legal,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM controls_obligations co
|
||||||
|
JOIN obligations o ON o.id = co.obligation_id
|
||||||
|
WHERE co.control_id = c.id AND o.type = 'CONTRACTUAL'
|
||||||
|
) AS has_contractual,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM control_risks cr WHERE cr.control_id = c.id
|
||||||
|
) AS has_risk
|
||||||
|
FROM applicability_statements stmt
|
||||||
|
JOIN controls c ON c.id = stmt.control_id
|
||||||
|
JOIN frameworks f ON f.id = c.framework_id
|
||||||
|
WHERE stmt.statement_of_applicability_id = @soa_id
|
||||||
|
ORDER BY f.name, c.section_title;
|
||||||
|
`,
|
||||||
|
pgx.NamedArgs{"soa_id": snapshotSOAID},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("cannot load snapshot controls: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var soaRows []docgen.SOARow
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var sc snapshotControl
|
||||||
|
if err := rows.Scan(
|
||||||
|
&sc.frameworkName,
|
||||||
|
&sc.sectionTitle,
|
||||||
|
&sc.controlName,
|
||||||
|
&sc.applicability,
|
||||||
|
&sc.justification,
|
||||||
|
&sc.bestPractice,
|
||||||
|
&sc.implemented,
|
||||||
|
&sc.notImplementedJustification,
|
||||||
|
&sc.hasLegal,
|
||||||
|
&sc.hasContractual,
|
||||||
|
&sc.hasRisk,
|
||||||
|
); err != nil {
|
||||||
|
return "", fmt.Errorf("cannot scan control: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
applicable := sc.applicability
|
||||||
|
|
||||||
|
justification := "-"
|
||||||
|
if !applicable && sc.justification != nil {
|
||||||
|
justification = *sc.justification
|
||||||
|
}
|
||||||
|
|
||||||
|
implemented := "-"
|
||||||
|
if applicable {
|
||||||
|
if sc.implemented == "IMPLEMENTED" {
|
||||||
|
implemented = "Yes"
|
||||||
|
} else {
|
||||||
|
implemented = "No"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notImplJustification := "-"
|
||||||
|
if applicable && sc.implemented != "IMPLEMENTED" && sc.notImplementedJustification != nil {
|
||||||
|
notImplJustification = *sc.notImplementedJustification
|
||||||
|
}
|
||||||
|
|
||||||
|
regulatory := "-"
|
||||||
|
contractual := "-"
|
||||||
|
bestPractice := "-"
|
||||||
|
riskAssessment := "-"
|
||||||
|
if applicable {
|
||||||
|
regulatory = docgen.BoolLabel(sc.hasLegal)
|
||||||
|
contractual = docgen.BoolLabel(sc.hasContractual)
|
||||||
|
bestPractice = docgen.BoolLabel(sc.bestPractice)
|
||||||
|
riskAssessment = docgen.BoolLabel(sc.hasRisk)
|
||||||
|
}
|
||||||
|
|
||||||
|
soaRows = append(soaRows, docgen.SOARow{
|
||||||
|
FrameworkName: sc.frameworkName,
|
||||||
|
ControlSection: sc.sectionTitle,
|
||||||
|
ControlName: sc.controlName,
|
||||||
|
Applicability: docgen.BoolLabel(applicable),
|
||||||
|
Justification: justification,
|
||||||
|
Implemented: implemented,
|
||||||
|
NotImplJustification: notImplJustification,
|
||||||
|
Regulatory: regulatory,
|
||||||
|
Contractual: contractual,
|
||||||
|
BestPractice: bestPractice,
|
||||||
|
RiskAssessment: riskAssessment,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
data := docgen.StatementOfApplicabilityData{
|
||||||
|
Title: soaName,
|
||||||
|
TotalControls: len(soaRows),
|
||||||
|
Rows: soaRows,
|
||||||
|
}
|
||||||
|
|
||||||
|
return probo.BuildStatementOfApplicabilityDocument(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
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...)
|
||||||
|
}
|
||||||
475
e2e/console/statement_of_applicability_test.go
Normal file
475
e2e/console/statement_of_applicability_test.go
Normal file
@@ -0,0 +1,475 @@
|
|||||||
|
// 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 TestStatementOfApplicability_Create(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
|
|
||||||
|
t.Run(
|
||||||
|
"create a statement of applicability",
|
||||||
|
func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
mutation($input: CreateStatementOfApplicabilityInput!) {
|
||||||
|
createStatementOfApplicability(input: $input) {
|
||||||
|
statementOfApplicabilityEdge {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
name := factory.SafeName("SOA")
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
CreateStatementOfApplicability struct {
|
||||||
|
StatementOfApplicabilityEdge struct {
|
||||||
|
Node struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
UpdatedAt string `json:"updatedAt"`
|
||||||
|
} `json:"node"`
|
||||||
|
} `json:"statementOfApplicabilityEdge"`
|
||||||
|
} `json:"createStatementOfApplicability"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := owner.Execute(
|
||||||
|
query,
|
||||||
|
map[string]any{
|
||||||
|
"input": map[string]any{
|
||||||
|
"organizationId": owner.GetOrganizationID().String(),
|
||||||
|
"name": name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
&result,
|
||||||
|
)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
node := result.CreateStatementOfApplicability.StatementOfApplicabilityEdge.Node
|
||||||
|
assert.NotEmpty(t, node.ID)
|
||||||
|
assert.Equal(t, name, node.Name)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatementOfApplicability_CreateDocument(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
|
|
||||||
|
t.Run(
|
||||||
|
"create document without approvers publishes immediately",
|
||||||
|
func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
frameworkID := factory.NewFramework(owner).Create()
|
||||||
|
controlID := factory.NewControl(owner, frameworkID).Create()
|
||||||
|
|
||||||
|
soaID := factory.NewStatementOfApplicability(owner).Create()
|
||||||
|
factory.CreateApplicabilityStatement(owner, soaID, controlID, true, nil)
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
mutation($input: PublishStatementOfApplicabilityInput!) {
|
||||||
|
publishStatementOfApplicability(input: $input) {
|
||||||
|
documentEdge {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
writeMode
|
||||||
|
status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
documentVersionEdge {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
documentType
|
||||||
|
orientation
|
||||||
|
status
|
||||||
|
major
|
||||||
|
minor
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
PublishStatementOfApplicability 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"`
|
||||||
|
Orientation string `json:"orientation"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Major int `json:"major"`
|
||||||
|
Minor int `json:"minor"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"node"`
|
||||||
|
} `json:"documentVersionEdge"`
|
||||||
|
} `json:"publishStatementOfApplicability"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := owner.Execute(
|
||||||
|
query,
|
||||||
|
map[string]any{
|
||||||
|
"input": map[string]any{
|
||||||
|
"statementOfApplicabilityId": soaID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
&result,
|
||||||
|
)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
doc := result.PublishStatementOfApplicability.DocumentEdge.Node
|
||||||
|
assert.NotEmpty(t, doc.ID)
|
||||||
|
assert.Equal(t, "GENERATED", doc.WriteMode)
|
||||||
|
assert.Equal(t, "ACTIVE", doc.Status)
|
||||||
|
|
||||||
|
ver := result.PublishStatementOfApplicability.DocumentVersionEdge.Node
|
||||||
|
assert.NotEmpty(t, ver.ID)
|
||||||
|
assert.Equal(t, "STATEMENT_OF_APPLICABILITY", ver.DocumentType)
|
||||||
|
assert.Equal(t, "LANDSCAPE", ver.Orientation)
|
||||||
|
assert.Equal(t, "PUBLISHED", ver.Status)
|
||||||
|
assert.Equal(t, 1, ver.Major)
|
||||||
|
assert.Equal(t, 0, ver.Minor)
|
||||||
|
assert.Contains(t, ver.Content, "Purpose")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
t.Run(
|
||||||
|
"create document with approvers creates draft with quorum",
|
||||||
|
func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
frameworkID := factory.NewFramework(owner).Create()
|
||||||
|
controlID := factory.NewControl(owner, frameworkID).Create()
|
||||||
|
|
||||||
|
soaID := factory.NewStatementOfApplicability(owner).Create()
|
||||||
|
factory.CreateApplicabilityStatement(owner, soaID, controlID, true, nil)
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
mutation($input: PublishStatementOfApplicabilityInput!) {
|
||||||
|
publishStatementOfApplicability(input: $input) {
|
||||||
|
documentEdge {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
writeMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
documentVersionEdge {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
status
|
||||||
|
major
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
PublishStatementOfApplicability 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:"publishStatementOfApplicability"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := owner.Execute(
|
||||||
|
query,
|
||||||
|
map[string]any{
|
||||||
|
"input": map[string]any{
|
||||||
|
"statementOfApplicabilityId": soaID,
|
||||||
|
"approverIds": []string{owner.GetProfileID().String()},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
&result,
|
||||||
|
)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
doc := result.PublishStatementOfApplicability.DocumentEdge.Node
|
||||||
|
assert.NotEmpty(t, doc.ID)
|
||||||
|
assert.Equal(t, "GENERATED", doc.WriteMode)
|
||||||
|
|
||||||
|
ver := result.PublishStatementOfApplicability.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()
|
||||||
|
|
||||||
|
frameworkID := factory.NewFramework(owner).Create()
|
||||||
|
controlID := factory.NewControl(owner, frameworkID).Create()
|
||||||
|
|
||||||
|
soaID := factory.NewStatementOfApplicability(owner).Create()
|
||||||
|
factory.CreateApplicabilityStatement(owner, soaID, controlID, true, nil)
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
mutation($input: PublishStatementOfApplicabilityInput!) {
|
||||||
|
publishStatementOfApplicability(input: $input) {
|
||||||
|
documentEdge {
|
||||||
|
node { id }
|
||||||
|
}
|
||||||
|
documentVersionEdge {
|
||||||
|
node { id major }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
var result1, result2 struct {
|
||||||
|
PublishStatementOfApplicability 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:"publishStatementOfApplicability"`
|
||||||
|
}
|
||||||
|
|
||||||
|
input := map[string]any{
|
||||||
|
"input": map[string]any{
|
||||||
|
"statementOfApplicabilityId": soaID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := owner.Execute(query, input, &result1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = owner.Execute(query, input, &result2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
doc1 := result1.PublishStatementOfApplicability.DocumentEdge.Node.ID
|
||||||
|
doc2 := result2.PublishStatementOfApplicability.DocumentEdge.Node.ID
|
||||||
|
assert.Equal(t, doc1, doc2, "should reuse same document")
|
||||||
|
|
||||||
|
ver1Major := result1.PublishStatementOfApplicability.DocumentVersionEdge.Node.Major
|
||||||
|
ver2Major := result2.PublishStatementOfApplicability.DocumentVersionEdge.Node.Major
|
||||||
|
assert.Equal(t, 1, ver1Major)
|
||||||
|
assert.Equal(t, 2, ver2Major)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
t.Run(
|
||||||
|
"document linked back to SOA",
|
||||||
|
func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
frameworkID := factory.NewFramework(owner).Create()
|
||||||
|
controlID := factory.NewControl(owner, frameworkID).Create()
|
||||||
|
|
||||||
|
soaID := factory.NewStatementOfApplicability(owner).Create()
|
||||||
|
factory.CreateApplicabilityStatement(owner, soaID, controlID, true, nil)
|
||||||
|
|
||||||
|
const createQuery = `
|
||||||
|
mutation($input: PublishStatementOfApplicabilityInput!) {
|
||||||
|
publishStatementOfApplicability(input: $input) {
|
||||||
|
documentEdge {
|
||||||
|
node { id }
|
||||||
|
}
|
||||||
|
documentVersionEdge {
|
||||||
|
node { id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
var createResult struct {
|
||||||
|
PublishStatementOfApplicability 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:"publishStatementOfApplicability"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := owner.Execute(
|
||||||
|
createQuery,
|
||||||
|
map[string]any{
|
||||||
|
"input": map[string]any{
|
||||||
|
"statementOfApplicabilityId": soaID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
&createResult,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
docID := createResult.PublishStatementOfApplicability.DocumentEdge.Node.ID
|
||||||
|
|
||||||
|
const soaQuery = `
|
||||||
|
query($id: ID!) {
|
||||||
|
node(id: $id) {
|
||||||
|
... on StatementOfApplicability {
|
||||||
|
id
|
||||||
|
document { id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
var soaResult struct {
|
||||||
|
Node struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Document *struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"document"`
|
||||||
|
} `json:"node"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err = owner.Execute(soaQuery, map[string]any{"id": soaID}, &soaResult)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, soaResult.Node.Document)
|
||||||
|
assert.Equal(t, docID, soaResult.Node.Document.ID)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatementOfApplicability_CreateDocument_RBAC(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
|
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
|
||||||
|
|
||||||
|
frameworkID := factory.NewFramework(owner).Create()
|
||||||
|
controlID := factory.NewControl(owner, frameworkID).Create()
|
||||||
|
|
||||||
|
soaID := factory.NewStatementOfApplicability(owner).Create()
|
||||||
|
factory.CreateApplicabilityStatement(owner, soaID, controlID, true, nil)
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
mutation($input: PublishStatementOfApplicabilityInput!) {
|
||||||
|
publishStatementOfApplicability(input: $input) {
|
||||||
|
documentEdge {
|
||||||
|
node { id }
|
||||||
|
}
|
||||||
|
documentVersionEdge {
|
||||||
|
node { id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
t.Run(
|
||||||
|
"viewer cannot create document",
|
||||||
|
func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := viewer.ExecuteShouldFail(
|
||||||
|
query,
|
||||||
|
map[string]any{
|
||||||
|
"input": map[string]any{
|
||||||
|
"statementOfApplicabilityId": soaID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
testutil.RequireForbiddenError(t, err)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatementOfApplicability_TenantIsolation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
org1Owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
|
org2Owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
|
|
||||||
|
soaID := factory.NewStatementOfApplicability(org1Owner).Create()
|
||||||
|
|
||||||
|
t.Run(
|
||||||
|
"cannot create document for another org SOA",
|
||||||
|
func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
mutation($input: PublishStatementOfApplicabilityInput!) {
|
||||||
|
publishStatementOfApplicability(input: $input) {
|
||||||
|
documentEdge {
|
||||||
|
node { id }
|
||||||
|
}
|
||||||
|
documentVersionEdge {
|
||||||
|
node { id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
err := org2Owner.ExecuteShouldFail(
|
||||||
|
query,
|
||||||
|
map[string]any{
|
||||||
|
"input": map[string]any{
|
||||||
|
"statementOfApplicabilityId": soaID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
require.Error(t, err)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1128,3 +1128,92 @@ func (b *AccessReviewCampaignBuilder) WithAccessSourceIDs(ids []string) *AccessR
|
|||||||
func (b *AccessReviewCampaignBuilder) Create() string {
|
func (b *AccessReviewCampaignBuilder) Create() string {
|
||||||
return CreateAccessReviewCampaign(b.client, b.organizationID, b.attrs)
|
return CreateAccessReviewCampaign(b.client, b.organizationID, b.attrs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StatementOfApplicabilityBuilder struct {
|
||||||
|
client *testutil.Client
|
||||||
|
attrs Attrs
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStatementOfApplicability(c *testutil.Client) *StatementOfApplicabilityBuilder {
|
||||||
|
return &StatementOfApplicabilityBuilder{client: c, attrs: Attrs{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *StatementOfApplicabilityBuilder) WithName(name string) *StatementOfApplicabilityBuilder {
|
||||||
|
b.attrs["name"] = name
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *StatementOfApplicabilityBuilder) Create() string {
|
||||||
|
b.client.T.Helper()
|
||||||
|
|
||||||
|
a := b.attrs
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
mutation($input: CreateStatementOfApplicabilityInput!) {
|
||||||
|
createStatementOfApplicability(input: $input) {
|
||||||
|
statementOfApplicabilityEdge {
|
||||||
|
node { id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
input := map[string]any{
|
||||||
|
"organizationId": b.client.GetOrganizationID().String(),
|
||||||
|
"name": a.getString("name", SafeName("SOA")),
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
CreateStatementOfApplicability struct {
|
||||||
|
StatementOfApplicabilityEdge struct {
|
||||||
|
Node struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"node"`
|
||||||
|
} `json:"statementOfApplicabilityEdge"`
|
||||||
|
} `json:"createStatementOfApplicability"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := b.client.Execute(query, map[string]any{"input": input}, &result)
|
||||||
|
require.NoError(b.client.T, err, "createStatementOfApplicability mutation failed")
|
||||||
|
|
||||||
|
return result.CreateStatementOfApplicability.StatementOfApplicabilityEdge.Node.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateApplicabilityStatement(c *testutil.Client, soaID, controlID string, applicability bool, justification *string) string {
|
||||||
|
c.T.Helper()
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
mutation($input: CreateApplicabilityStatementInput!) {
|
||||||
|
createApplicabilityStatement(input: $input) {
|
||||||
|
applicabilityStatementEdge {
|
||||||
|
node { id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
input := map[string]any{
|
||||||
|
"statementOfApplicabilityId": soaID,
|
||||||
|
"controlId": controlID,
|
||||||
|
"applicability": applicability,
|
||||||
|
}
|
||||||
|
|
||||||
|
if justification != nil {
|
||||||
|
input["justification"] = *justification
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
CreateApplicabilityStatement struct {
|
||||||
|
ApplicabilityStatementEdge struct {
|
||||||
|
Node struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"node"`
|
||||||
|
} `json:"applicabilityStatementEdge"`
|
||||||
|
} `json:"createApplicabilityStatement"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := c.Execute(query, map[string]any{"input": input}, &result)
|
||||||
|
require.NoError(c.T, err, "createApplicabilityStatement mutation failed")
|
||||||
|
|
||||||
|
return result.CreateApplicabilityStatement.ApplicabilityStatementEdge.Node.ID
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
type Translator = (s: string) => string;
|
type Translator = (s: string) => string;
|
||||||
|
|
||||||
export const documentTypes = ["OTHER", "GOVERNANCE", "POLICY", "PROCEDURE", "PLAN", "REGISTER", "RECORD", "REPORT", "TEMPLATE"] as const;
|
export const documentTypes = ["OTHER", "GOVERNANCE", "POLICY", "PROCEDURE", "PLAN", "REGISTER", "RECORD", "REPORT", "TEMPLATE", "STATEMENT_OF_APPLICABILITY"] as const;
|
||||||
|
|
||||||
export function getDocumentTypeLabel(__: Translator, type: string) {
|
export function getDocumentTypeLabel(__: Translator, type: string) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@@ -36,6 +36,19 @@ export function getDocumentTypeLabel(__: Translator, type: string) {
|
|||||||
return __("Report");
|
return __("Report");
|
||||||
case "TEMPLATE":
|
case "TEMPLATE":
|
||||||
return __("Template");
|
return __("Template");
|
||||||
|
case "STATEMENT_OF_APPLICABILITY":
|
||||||
|
return __("Statement of Applicability");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const documentWriteModes = ["AUTHORED", "GENERATED"] as const;
|
||||||
|
|
||||||
|
export function getDocumentWriteModeLabel(__: Translator, writeMode: string) {
|
||||||
|
switch (writeMode) {
|
||||||
|
case "AUTHORED":
|
||||||
|
return __("Authored");
|
||||||
|
case "GENERATED":
|
||||||
|
return __("Generated");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ export {
|
|||||||
documentTypes,
|
documentTypes,
|
||||||
getDocumentClassificationLabel,
|
getDocumentClassificationLabel,
|
||||||
documentClassifications,
|
documentClassifications,
|
||||||
|
documentWriteModes,
|
||||||
|
getDocumentWriteModeLabel,
|
||||||
} from "./documents";
|
} from "./documents";
|
||||||
export { getAssetTypeVariant } from "./assets";
|
export { getAssetTypeVariant } from "./assets";
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ export const snapshotTypes = [
|
|||||||
"FINDINGS",
|
"FINDINGS",
|
||||||
"OBLIGATIONS",
|
"OBLIGATIONS",
|
||||||
"PROCESSING_ACTIVITIES",
|
"PROCESSING_ACTIVITIES",
|
||||||
"STATEMENTS_OF_APPLICABILITY",
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function getSnapshotTypeLabel(__: Translator, type: string | null | undefined) {
|
export function getSnapshotTypeLabel(__: Translator, type: string | null | undefined) {
|
||||||
@@ -47,8 +46,6 @@ export function getSnapshotTypeLabel(__: Translator, type: string | null | undef
|
|||||||
return __("Obligations");
|
return __("Obligations");
|
||||||
case "PROCESSING_ACTIVITIES":
|
case "PROCESSING_ACTIVITIES":
|
||||||
return __("Processing Activities");
|
return __("Processing Activities");
|
||||||
case "STATEMENTS_OF_APPLICABILITY":
|
|
||||||
return __("Statements of Applicability");
|
|
||||||
default:
|
default:
|
||||||
return __("Unknown");
|
return __("Unknown");
|
||||||
}
|
}
|
||||||
@@ -72,8 +69,6 @@ export function getSnapshotTypeUrlPath(type?: string): string {
|
|||||||
return "/obligations";
|
return "/obligations";
|
||||||
case "PROCESSING_ACTIVITIES":
|
case "PROCESSING_ACTIVITIES":
|
||||||
return "/processing-activities";
|
return "/processing-activities";
|
||||||
case "STATEMENTS_OF_APPLICABILITY":
|
|
||||||
return "/statements-of-applicability";
|
|
||||||
default:
|
default:
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,6 +131,11 @@ export class Probo implements INodeType {
|
|||||||
value: 'risk',
|
value: 'risk',
|
||||||
description: 'Manage risks',
|
description: 'Manage risks',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Statement of Applicability',
|
||||||
|
value: 'statementOfApplicability',
|
||||||
|
description: 'Manage statements of applicability',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'User',
|
name: 'User',
|
||||||
value: 'user',
|
value: 'user',
|
||||||
|
|||||||
@@ -74,11 +74,17 @@ export const description: INodeProperties[] = [
|
|||||||
},
|
},
|
||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
displayName: 'Query',
|
displayName: 'Classifications',
|
||||||
name: 'query',
|
name: 'classifications',
|
||||||
type: 'string',
|
type: 'multiOptions',
|
||||||
default: '',
|
default: [],
|
||||||
description: 'Search query to filter documents',
|
description: 'Filter by document classification',
|
||||||
|
options: [
|
||||||
|
{ name: 'Confidential', value: 'CONFIDENTIAL' },
|
||||||
|
{ name: 'Internal', value: 'INTERNAL' },
|
||||||
|
{ name: 'Public', value: 'PUBLIC' },
|
||||||
|
{ name: 'Secret', value: 'SECRET' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
displayName: 'Document Types',
|
displayName: 'Document Types',
|
||||||
@@ -95,21 +101,16 @@ export const description: INodeProperties[] = [
|
|||||||
{ name: 'Record', value: 'RECORD' },
|
{ name: 'Record', value: 'RECORD' },
|
||||||
{ name: 'Register', value: 'REGISTER' },
|
{ name: 'Register', value: 'REGISTER' },
|
||||||
{ name: 'Report', value: 'REPORT' },
|
{ name: 'Report', value: 'REPORT' },
|
||||||
|
{ name: 'Statement of Applicability', value: 'STATEMENT_OF_APPLICABILITY' },
|
||||||
{ name: 'Template', value: 'TEMPLATE' },
|
{ name: 'Template', value: 'TEMPLATE' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
displayName: 'Classifications',
|
displayName: 'Query',
|
||||||
name: 'classifications',
|
name: 'query',
|
||||||
type: 'multiOptions',
|
type: 'string',
|
||||||
default: [],
|
default: '',
|
||||||
description: 'Filter by document classification',
|
description: 'Search query to filter documents',
|
||||||
options: [
|
|
||||||
{ name: 'Confidential', value: 'CONFIDENTIAL' },
|
|
||||||
{ name: 'Internal', value: 'INTERNAL' },
|
|
||||||
{ name: 'Public', value: 'PUBLIC' },
|
|
||||||
{ name: 'Secret', value: 'SECRET' },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
displayName: 'Status',
|
displayName: 'Status',
|
||||||
@@ -122,6 +123,17 @@ export const description: INodeProperties[] = [
|
|||||||
{ name: 'Archived', value: 'ARCHIVED' },
|
{ name: 'Archived', value: 'ARCHIVED' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Write Modes',
|
||||||
|
name: 'writeModes',
|
||||||
|
type: 'multiOptions',
|
||||||
|
default: [],
|
||||||
|
description: 'Filter by write mode',
|
||||||
|
options: [
|
||||||
|
{ name: 'Authored', value: 'AUTHORED' },
|
||||||
|
{ name: 'Generated', value: 'GENERATED' },
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -137,6 +149,7 @@ export async function execute(
|
|||||||
|
|
||||||
const filter: IDataObject = {};
|
const filter: IDataObject = {};
|
||||||
if (filters.query) filter.query = filters.query;
|
if (filters.query) filter.query = filters.query;
|
||||||
|
if ((filters.writeModes as string[])?.length) filter.writeModes = filters.writeModes;
|
||||||
if ((filters.documentTypes as string[])?.length) filter.documentTypes = filters.documentTypes;
|
if ((filters.documentTypes as string[])?.length) filter.documentTypes = filters.documentTypes;
|
||||||
if ((filters.classifications as string[])?.length) filter.classifications = filters.classifications;
|
if ((filters.classifications as string[])?.length) filter.classifications = filters.classifications;
|
||||||
filter.status = (filters.status as string[])?.length ? filters.status : ['ACTIVE'];
|
filter.status = (filters.status as string[])?.length ? filters.status : ['ACTIVE'];
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import * as meeting from './meeting';
|
|||||||
import * as organization from './organization';
|
import * as organization from './organization';
|
||||||
import * as user from './user';
|
import * as user from './user';
|
||||||
import * as risk from './risk';
|
import * as risk from './risk';
|
||||||
|
import * as statementOfApplicability from './statementOfApplicability';
|
||||||
import * as vendor from './vendor';
|
import * as vendor from './vendor';
|
||||||
|
|
||||||
export interface ResourceModule {
|
export interface ResourceModule {
|
||||||
@@ -50,6 +51,7 @@ export const resources: Record<string, ResourceModule> = {
|
|||||||
organization: organization as ResourceModule,
|
organization: organization as ResourceModule,
|
||||||
user: user as ResourceModule,
|
user: user as ResourceModule,
|
||||||
risk: risk as ResourceModule,
|
risk: risk as ResourceModule,
|
||||||
|
statementOfApplicability: statementOfApplicability as ResourceModule,
|
||||||
vendor: vendor as ResourceModule,
|
vendor: vendor as ResourceModule,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// 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: ['statementOfApplicability'],
|
||||||
|
operation: ['create'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
default: '',
|
||||||
|
description: 'The ID of the organization',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Name',
|
||||||
|
name: 'name',
|
||||||
|
type: 'string',
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
resource: ['statementOfApplicability'],
|
||||||
|
operation: ['create'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
default: '',
|
||||||
|
description: 'The name of the statement of applicability',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Additional Fields',
|
||||||
|
name: 'additionalFields',
|
||||||
|
type: 'collection',
|
||||||
|
placeholder: 'Add Field',
|
||||||
|
default: {},
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
resource: ['statementOfApplicability'],
|
||||||
|
operation: ['create'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
displayName: 'Default Approver IDs',
|
||||||
|
name: 'defaultApproverIds',
|
||||||
|
type: 'string',
|
||||||
|
default: '',
|
||||||
|
description: 'Comma-separated list of default approver profile IDs',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function execute(
|
||||||
|
this: IExecuteFunctions,
|
||||||
|
itemIndex: number,
|
||||||
|
): Promise<INodeExecutionData> {
|
||||||
|
const organizationId = this.getNodeParameter('organizationId', itemIndex) as string;
|
||||||
|
const name = this.getNodeParameter('name', itemIndex) as string;
|
||||||
|
const additionalFields = this.getNodeParameter('additionalFields', itemIndex, {}) as {
|
||||||
|
defaultApproverIds?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
mutation CreateStatementOfApplicability($input: CreateStatementOfApplicabilityInput!) {
|
||||||
|
createStatementOfApplicability(input: $input) {
|
||||||
|
statementOfApplicabilityEdge {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const input: Record<string, unknown> = {
|
||||||
|
organizationId,
|
||||||
|
name,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (additionalFields.defaultApproverIds) {
|
||||||
|
input.defaultApproverIds = additionalFields.defaultApproverIds
|
||||||
|
.split(',')
|
||||||
|
.map(id => id.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseData = await proboApiRequest.call(this, query, { input });
|
||||||
|
|
||||||
|
return {
|
||||||
|
json: responseData,
|
||||||
|
pairedItem: { item: itemIndex },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// 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: 'Statement of Applicability ID',
|
||||||
|
name: 'statementOfApplicabilityId',
|
||||||
|
type: 'string',
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
resource: ['statementOfApplicability'],
|
||||||
|
operation: ['delete'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
default: '',
|
||||||
|
description: 'The ID of the statement of applicability to delete',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function execute(
|
||||||
|
this: IExecuteFunctions,
|
||||||
|
itemIndex: number,
|
||||||
|
): Promise<INodeExecutionData> {
|
||||||
|
const statementOfApplicabilityId = this.getNodeParameter('statementOfApplicabilityId', itemIndex) as string;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
mutation DeleteStatementOfApplicability($input: DeleteStatementOfApplicabilityInput!) {
|
||||||
|
deleteStatementOfApplicability(input: $input) {
|
||||||
|
deletedStatementOfApplicabilityId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const responseData = await proboApiRequest.call(this, query, {
|
||||||
|
input: { statementOfApplicabilityId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
json: responseData,
|
||||||
|
pairedItem: { item: itemIndex },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// 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: 'Statement of Applicability ID',
|
||||||
|
name: 'statementOfApplicabilityId',
|
||||||
|
type: 'string',
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
resource: ['statementOfApplicability'],
|
||||||
|
operation: ['get'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
default: '',
|
||||||
|
description: 'The ID of the statement of applicability',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function execute(
|
||||||
|
this: IExecuteFunctions,
|
||||||
|
itemIndex: number,
|
||||||
|
): Promise<INodeExecutionData> {
|
||||||
|
const statementOfApplicabilityId = this.getNodeParameter('statementOfApplicabilityId', itemIndex) as string;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
query GetStatementOfApplicability($id: ID!) {
|
||||||
|
node(id: $id) {
|
||||||
|
... on StatementOfApplicability {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const responseData = await proboApiRequest.call(this, query, {
|
||||||
|
id: statementOfApplicabilityId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
json: responseData,
|
||||||
|
pairedItem: { item: itemIndex },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// 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, IDataObject } from 'n8n-workflow';
|
||||||
|
import { proboApiRequestAllItems } from '../../GenericFunctions';
|
||||||
|
|
||||||
|
export const description: INodeProperties[] = [
|
||||||
|
{
|
||||||
|
displayName: 'Organization ID',
|
||||||
|
name: 'organizationId',
|
||||||
|
type: 'string',
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
resource: ['statementOfApplicability'],
|
||||||
|
operation: ['getAll'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
default: '',
|
||||||
|
description: 'The ID of the organization',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Return All',
|
||||||
|
name: 'returnAll',
|
||||||
|
type: 'boolean',
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
resource: ['statementOfApplicability'],
|
||||||
|
operation: ['getAll'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
default: false,
|
||||||
|
description: 'Whether to return all results or only up to a given limit',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Limit',
|
||||||
|
name: 'limit',
|
||||||
|
type: 'number',
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
resource: ['statementOfApplicability'],
|
||||||
|
operation: ['getAll'],
|
||||||
|
returnAll: [false],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
typeOptions: {
|
||||||
|
minValue: 1,
|
||||||
|
},
|
||||||
|
default: 50,
|
||||||
|
description: 'Max number of results to return',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function execute(
|
||||||
|
this: IExecuteFunctions,
|
||||||
|
itemIndex: number,
|
||||||
|
): Promise<INodeExecutionData> {
|
||||||
|
const organizationId = this.getNodeParameter('organizationId', itemIndex) as string;
|
||||||
|
const returnAll = this.getNodeParameter('returnAll', itemIndex) as boolean;
|
||||||
|
const limit = this.getNodeParameter('limit', itemIndex, 50) as number;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
query GetStatementsOfApplicability($organizationId: ID!, $first: Int, $after: CursorKey) {
|
||||||
|
node(id: $organizationId) {
|
||||||
|
... on Organization {
|
||||||
|
statementsOfApplicability(first: $first, after: $after) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pageInfo {
|
||||||
|
hasNextPage
|
||||||
|
endCursor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const statementsOfApplicability = await proboApiRequestAllItems.call(
|
||||||
|
this,
|
||||||
|
query,
|
||||||
|
{ organizationId },
|
||||||
|
(response) => {
|
||||||
|
const data = response?.data as IDataObject | undefined;
|
||||||
|
const node = data?.node as IDataObject | undefined;
|
||||||
|
return node?.statementsOfApplicability as IDataObject | undefined;
|
||||||
|
},
|
||||||
|
returnAll,
|
||||||
|
limit,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
json: { statementsOfApplicability },
|
||||||
|
pairedItem: { item: itemIndex },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// 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 } from 'n8n-workflow';
|
||||||
|
import * as createOp from './create.operation';
|
||||||
|
import * as getOp from './get.operation';
|
||||||
|
import * as getAllOp from './getAll.operation';
|
||||||
|
import * as updateOp from './update.operation';
|
||||||
|
import * as deleteOp from './delete.operation';
|
||||||
|
import * as publishOp from './publish.operation';
|
||||||
|
|
||||||
|
export const description: INodeProperties[] = [
|
||||||
|
{
|
||||||
|
displayName: 'Operation',
|
||||||
|
name: 'operation',
|
||||||
|
type: 'options',
|
||||||
|
noDataExpression: true,
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
resource: ['statementOfApplicability'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: 'Create',
|
||||||
|
value: 'create',
|
||||||
|
description: 'Create a new statement of applicability',
|
||||||
|
action: 'Create a statement of applicability',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Delete',
|
||||||
|
value: 'delete',
|
||||||
|
description: 'Delete a statement of applicability',
|
||||||
|
action: 'Delete a statement of applicability',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Get',
|
||||||
|
value: 'get',
|
||||||
|
description: 'Get a statement of applicability',
|
||||||
|
action: 'Get a statement of applicability',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Get Many',
|
||||||
|
value: 'getAll',
|
||||||
|
description: 'Get many statements of applicability',
|
||||||
|
action: 'Get many statements of applicability',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Publish',
|
||||||
|
value: 'publish',
|
||||||
|
description: 'Publish a statement of applicability as a document version',
|
||||||
|
action: 'Publish a statement of applicability',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Update',
|
||||||
|
value: 'update',
|
||||||
|
description: 'Update an existing statement of applicability',
|
||||||
|
action: 'Update a statement of applicability',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
default: 'create',
|
||||||
|
},
|
||||||
|
...createOp.description,
|
||||||
|
...getOp.description,
|
||||||
|
...getAllOp.description,
|
||||||
|
...updateOp.description,
|
||||||
|
...deleteOp.description,
|
||||||
|
...publishOp.description,
|
||||||
|
];
|
||||||
|
|
||||||
|
export {
|
||||||
|
createOp as create,
|
||||||
|
getOp as get,
|
||||||
|
getAllOp as getAll,
|
||||||
|
updateOp as update,
|
||||||
|
deleteOp as delete,
|
||||||
|
publishOp as publish,
|
||||||
|
};
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||||
|
import { proboApiRequest } from '../../GenericFunctions';
|
||||||
|
|
||||||
|
export const description: INodeProperties[] = [
|
||||||
|
{
|
||||||
|
displayName: 'Statement of Applicability ID',
|
||||||
|
name: 'statementOfApplicabilityId',
|
||||||
|
type: 'string',
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
resource: ['statementOfApplicability'],
|
||||||
|
operation: ['publish'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
default: '',
|
||||||
|
description: 'The ID of the statement of applicability to publish',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Approver IDs',
|
||||||
|
name: 'approverIds',
|
||||||
|
type: 'string',
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
resource: ['statementOfApplicability'],
|
||||||
|
operation: ['publish'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
default: '',
|
||||||
|
description: 'Comma-separated list of approver profile IDs',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function execute(
|
||||||
|
this: IExecuteFunctions,
|
||||||
|
itemIndex: number,
|
||||||
|
): Promise<INodeExecutionData> {
|
||||||
|
const statementOfApplicabilityId = this.getNodeParameter('statementOfApplicabilityId', itemIndex) as string;
|
||||||
|
const approverIds = this.getNodeParameter('approverIds', itemIndex, '') as string;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
mutation PublishStatementOfApplicability($input: PublishStatementOfApplicabilityInput!) {
|
||||||
|
publishStatementOfApplicability(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> = { statementOfApplicabilityId };
|
||||||
|
|
||||||
|
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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// 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: 'Statement of Applicability ID',
|
||||||
|
name: 'id',
|
||||||
|
type: 'string',
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
resource: ['statementOfApplicability'],
|
||||||
|
operation: ['update'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
default: '',
|
||||||
|
description: 'The ID of the statement of applicability to update',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Update Fields',
|
||||||
|
name: 'updateFields',
|
||||||
|
type: 'collection',
|
||||||
|
placeholder: 'Add Field',
|
||||||
|
default: {},
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
resource: ['statementOfApplicability'],
|
||||||
|
operation: ['update'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
displayName: 'Name',
|
||||||
|
name: 'name',
|
||||||
|
type: 'string',
|
||||||
|
default: '',
|
||||||
|
description: 'The name of the statement of applicability',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Default Approver IDs',
|
||||||
|
name: 'defaultApproverIds',
|
||||||
|
type: 'string',
|
||||||
|
default: '',
|
||||||
|
description: 'Comma-separated list of default approver profile IDs',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function execute(
|
||||||
|
this: IExecuteFunctions,
|
||||||
|
itemIndex: number,
|
||||||
|
): Promise<INodeExecutionData> {
|
||||||
|
const id = this.getNodeParameter('id', itemIndex) as string;
|
||||||
|
const updateFields = this.getNodeParameter('updateFields', itemIndex, {}) as {
|
||||||
|
name?: string;
|
||||||
|
defaultApproverIds?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
mutation UpdateStatementOfApplicability($input: UpdateStatementOfApplicabilityInput!) {
|
||||||
|
updateStatementOfApplicability(input: $input) {
|
||||||
|
statementOfApplicability {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const input: Record<string, unknown> = { id };
|
||||||
|
if (updateFields.name) input.name = updateFields.name;
|
||||||
|
if (updateFields.defaultApproverIds) {
|
||||||
|
input.defaultApproverIds = updateFields.defaultApproverIds
|
||||||
|
.split(',')
|
||||||
|
.map(id => id.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseData = await proboApiRequest.call(this, query, { input });
|
||||||
|
|
||||||
|
return {
|
||||||
|
json: responseData,
|
||||||
|
pairedItem: { item: itemIndex },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -67,7 +67,7 @@ const extensions = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const richEditorVariants = tv({
|
const richEditorVariants = tv({
|
||||||
base: ["relative flex-1 py-14 pr-8 bg-level-1 shadow-base"],
|
base: ["relative flex-1 min-w-0 overflow-auto py-14 pr-8 bg-level-1 shadow-base"],
|
||||||
variants: {
|
variants: {
|
||||||
disabled: {
|
disabled: {
|
||||||
true: "pl-8",
|
true: "pl-8",
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
@apply border border-border-mid border-collapse;
|
@apply border border-border-mid border-collapse w-max;
|
||||||
tbody {
|
tbody {
|
||||||
@apply border border-border-mid border-collapse;
|
@apply border border-border-mid border-collapse;
|
||||||
}
|
}
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tableWrapper {
|
.tableWrapper {
|
||||||
@apply relative overflow-x-auto my-4;
|
@apply relative overflow-auto my-4 max-h-[70vh];
|
||||||
}
|
}
|
||||||
|
|
||||||
.mermaid-block {
|
.mermaid-block {
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
|||||||
flagOrderBy string
|
flagOrderBy string
|
||||||
flagOrderDir string
|
flagOrderDir string
|
||||||
flagQuery string
|
flagQuery string
|
||||||
|
flagWriteMode string
|
||||||
flagDocumentType string
|
flagDocumentType string
|
||||||
flagClassification string
|
flagClassification string
|
||||||
flagStatus string
|
flagStatus string
|
||||||
@@ -148,11 +149,21 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
|||||||
if flagQuery != "" {
|
if flagQuery != "" {
|
||||||
filter["query"] = flagQuery
|
filter["query"] = flagQuery
|
||||||
}
|
}
|
||||||
|
if flagWriteMode != "" {
|
||||||
|
if err := cmdutil.ValidateEnum(
|
||||||
|
"write-mode",
|
||||||
|
flagWriteMode,
|
||||||
|
[]string{"AUTHORED", "GENERATED"},
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
filter["writeModes"] = []string{flagWriteMode}
|
||||||
|
}
|
||||||
if flagDocumentType != "" {
|
if flagDocumentType != "" {
|
||||||
if err := cmdutil.ValidateEnum(
|
if err := cmdutil.ValidateEnum(
|
||||||
"document-type",
|
"document-type",
|
||||||
flagDocumentType,
|
flagDocumentType,
|
||||||
[]string{"OTHER", "GOVERNANCE", "POLICY", "PROCEDURE", "PLAN", "REGISTER", "RECORD", "REPORT", "TEMPLATE"},
|
[]string{"OTHER", "GOVERNANCE", "POLICY", "PROCEDURE", "PLAN", "REGISTER", "RECORD", "REPORT", "TEMPLATE", "STATEMENT_OF_APPLICABILITY"},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -261,7 +272,8 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
|||||||
cmd.Flags().StringVar(&flagOrderBy, "order-by", "", "Order by field (TITLE, CREATED_AT, UPDATED_AT, DOCUMENT_TYPE)")
|
cmd.Flags().StringVar(&flagOrderBy, "order-by", "", "Order by field (TITLE, CREATED_AT, UPDATED_AT, DOCUMENT_TYPE)")
|
||||||
cmd.Flags().StringVar(&flagOrderDir, "order-direction", "DESC", "Sort direction (ASC, DESC)")
|
cmd.Flags().StringVar(&flagOrderDir, "order-direction", "DESC", "Sort direction (ASC, DESC)")
|
||||||
cmd.Flags().StringVarP(&flagQuery, "query", "q", "", "Search query")
|
cmd.Flags().StringVarP(&flagQuery, "query", "q", "", "Search query")
|
||||||
cmd.Flags().StringVar(&flagDocumentType, "document-type", "", "Filter by document type (OTHER, GOVERNANCE, POLICY, PROCEDURE, PLAN, REGISTER, RECORD, REPORT, TEMPLATE)")
|
cmd.Flags().StringVar(&flagWriteMode, "write-mode", "", "Filter by write mode (AUTHORED, GENERATED)")
|
||||||
|
cmd.Flags().StringVar(&flagDocumentType, "document-type", "", "Filter by document type (OTHER, GOVERNANCE, POLICY, PROCEDURE, PLAN, REGISTER, RECORD, REPORT, TEMPLATE, STATEMENT_OF_APPLICABILITY)")
|
||||||
cmd.Flags().StringVar(&flagClassification, "classification", "", "Filter by classification (PUBLIC, INTERNAL, CONFIDENTIAL, SECRET)")
|
cmd.Flags().StringVar(&flagClassification, "classification", "", "Filter by classification (PUBLIC, INTERNAL, CONFIDENTIAL, SECRET)")
|
||||||
cmd.Flags().StringVar(&flagStatus, "status", "", "Filter by status (ACTIVE, ARCHIVED)")
|
cmd.Flags().StringVar(&flagStatus, "status", "", "Filter by status (ACTIVE, ARCHIVED)")
|
||||||
flagOutput = cmdutil.AddOutputFlag(cmd)
|
flagOutput = cmdutil.AddOutputFlag(cmd)
|
||||||
|
|||||||
137
pkg/cmd/soa/publish/publish.go
Normal file
137
pkg/cmd/soa/publish/publish.go
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
// 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: PublishStatementOfApplicabilityInput!) {
|
||||||
|
publishStatementOfApplicability(input: $input) {
|
||||||
|
documentEdge {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
status
|
||||||
|
createdAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
documentVersionEdge {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
major
|
||||||
|
minor
|
||||||
|
status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
type publishResponse struct {
|
||||||
|
PublishStatementOfApplicability 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:"publishStatementOfApplicability"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCmdPublish(f *cmdutil.Factory) *cobra.Command {
|
||||||
|
var flagApprover []string
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "publish <soa-id>",
|
||||||
|
Short: "Publish a statement of applicability as a document version",
|
||||||
|
Example: ` # Publish an SOA
|
||||||
|
prb soa publish SOA_ID
|
||||||
|
|
||||||
|
# Publish with approvers
|
||||||
|
prb soa publish SOA_ID --approver PROFILE_ID1 --approver PROFILE_ID2`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
client := api.NewClient(
|
||||||
|
host,
|
||||||
|
hc.Token,
|
||||||
|
"/api/console/v1/graphql",
|
||||||
|
cfg.HTTPTimeoutDuration(),
|
||||||
|
)
|
||||||
|
|
||||||
|
input := map[string]any{
|
||||||
|
"statementOfApplicabilityId": args[0],
|
||||||
|
}
|
||||||
|
|
||||||
|
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.PublishStatementOfApplicability.DocumentVersionEdge.Node
|
||||||
|
_, _ = fmt.Fprintf(
|
||||||
|
f.IOStreams.Out,
|
||||||
|
"Published statement of applicability %s (v%d.%d)\n",
|
||||||
|
v.Title,
|
||||||
|
v.Major,
|
||||||
|
v.Minor,
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringArrayVar(&flagApprover, "approver", nil, "Approver profile ID (can be repeated)")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/cmd/soa/create"
|
"go.probo.inc/probo/pkg/cmd/soa/create"
|
||||||
"go.probo.inc/probo/pkg/cmd/soa/delete"
|
"go.probo.inc/probo/pkg/cmd/soa/delete"
|
||||||
"go.probo.inc/probo/pkg/cmd/soa/list"
|
"go.probo.inc/probo/pkg/cmd/soa/list"
|
||||||
|
"go.probo.inc/probo/pkg/cmd/soa/publish"
|
||||||
"go.probo.inc/probo/pkg/cmd/soa/statement"
|
"go.probo.inc/probo/pkg/cmd/soa/statement"
|
||||||
"go.probo.inc/probo/pkg/cmd/soa/update"
|
"go.probo.inc/probo/pkg/cmd/soa/update"
|
||||||
"go.probo.inc/probo/pkg/cmd/soa/view"
|
"go.probo.inc/probo/pkg/cmd/soa/view"
|
||||||
@@ -36,6 +37,7 @@ func NewCmdSoa(f *cmdutil.Factory) *cobra.Command {
|
|||||||
cmd.AddCommand(view.NewCmdView(f))
|
cmd.AddCommand(view.NewCmdView(f))
|
||||||
cmd.AddCommand(update.NewCmdUpdate(f))
|
cmd.AddCommand(update.NewCmdUpdate(f))
|
||||||
cmd.AddCommand(delete.NewCmdDelete(f))
|
cmd.AddCommand(delete.NewCmdDelete(f))
|
||||||
|
cmd.AddCommand(publish.NewCmdPublish(f))
|
||||||
cmd.AddCommand(statement.NewCmdStatement(f))
|
cmd.AddCommand(statement.NewCmdStatement(f))
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
|
|||||||
@@ -156,7 +156,6 @@ WITH current_soa AS (
|
|||||||
WHERE
|
WHERE
|
||||||
%s
|
%s
|
||||||
AND id = @statement_of_applicability_id
|
AND id = @statement_of_applicability_id
|
||||||
AND snapshot_id IS NULL
|
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
soac.id,
|
soac.id,
|
||||||
@@ -351,7 +350,6 @@ WITH current_soa AS (
|
|||||||
WHERE
|
WHERE
|
||||||
%s
|
%s
|
||||||
AND id = @statement_of_applicability_id
|
AND id = @statement_of_applicability_id
|
||||||
AND snapshot_id IS NULL
|
|
||||||
)
|
)
|
||||||
DELETE FROM applicability_statements
|
DELETE FROM applicability_statements
|
||||||
WHERE statement_of_applicability_id IN (SELECT id FROM current_soa)
|
WHERE statement_of_applicability_id IN (SELECT id FROM current_soa)
|
||||||
@@ -467,6 +465,57 @@ WHERE
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (sacs *ApplicabilityStatements) LoadAllByStatementOfApplicabilityID(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Querier,
|
||||||
|
scope Scoper,
|
||||||
|
statementOfApplicabilityID gid.GID,
|
||||||
|
) error {
|
||||||
|
q := `
|
||||||
|
SELECT
|
||||||
|
a.id,
|
||||||
|
a.statement_of_applicability_id,
|
||||||
|
a.control_id,
|
||||||
|
a.organization_id,
|
||||||
|
a.snapshot_id,
|
||||||
|
a.applicability,
|
||||||
|
a.justification,
|
||||||
|
a.created_at,
|
||||||
|
a.updated_at,
|
||||||
|
f.name || ' - ' || c.section_title AS section_title
|
||||||
|
FROM
|
||||||
|
applicability_statements a
|
||||||
|
INNER JOIN
|
||||||
|
controls c ON c.id = a.control_id
|
||||||
|
INNER JOIN
|
||||||
|
frameworks f ON f.id = c.framework_id
|
||||||
|
WHERE
|
||||||
|
a.%s
|
||||||
|
AND a.statement_of_applicability_id = @statement_of_applicability_id
|
||||||
|
ORDER BY
|
||||||
|
section_title ASC;
|
||||||
|
`
|
||||||
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||||
|
|
||||||
|
args := pgx.StrictNamedArgs{
|
||||||
|
"statement_of_applicability_id": statementOfApplicabilityID,
|
||||||
|
}
|
||||||
|
maps.Copy(args, scope.SQLArguments())
|
||||||
|
|
||||||
|
rows, err := conn.Query(ctx, q, args)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot query applicability_statements: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
controls, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[ApplicabilityStatement])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot collect applicability_statements: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
*sacs = controls
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (sacs *ApplicabilityStatements) CountByStatementOfApplicabilityID(
|
func (sacs *ApplicabilityStatements) CountByStatementOfApplicabilityID(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
conn pg.Querier,
|
conn pg.Querier,
|
||||||
@@ -484,7 +533,7 @@ WHERE
|
|||||||
`
|
`
|
||||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||||
|
|
||||||
args := pgx.NamedArgs{"statement_of_applicability_id": statementOfApplicabilityID}
|
args := pgx.StrictNamedArgs{"statement_of_applicability_id": statementOfApplicabilityID}
|
||||||
maps.Copy(args, scope.SQLArguments())
|
maps.Copy(args, scope.SQLArguments())
|
||||||
|
|
||||||
var count int
|
var count int
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ type (
|
|||||||
}
|
}
|
||||||
|
|
||||||
ControlObligations []*ControlObligation
|
ControlObligations []*ControlObligation
|
||||||
|
|
||||||
|
ControlObligationType struct {
|
||||||
|
ControlID gid.GID `db:"control_id"`
|
||||||
|
ObligationType ObligationType `db:"obligation_type"`
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func (co ControlObligation) Upsert(
|
func (co ControlObligation) Upsert(
|
||||||
@@ -137,3 +142,48 @@ WHERE %s
|
|||||||
|
|
||||||
return count, nil
|
return count, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func LoadObligationTypesByControlIDs(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Querier,
|
||||||
|
scope Scoper,
|
||||||
|
controlIDs []gid.GID,
|
||||||
|
) ([]ControlObligationType, error) {
|
||||||
|
q := `
|
||||||
|
WITH control_obls AS (
|
||||||
|
SELECT DISTINCT
|
||||||
|
co.control_id,
|
||||||
|
o.type AS obligation_type,
|
||||||
|
o.tenant_id
|
||||||
|
FROM
|
||||||
|
controls_obligations co
|
||||||
|
INNER JOIN
|
||||||
|
obligations o ON co.obligation_id = o.id
|
||||||
|
WHERE
|
||||||
|
co.control_id = ANY(@control_ids)
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
control_id,
|
||||||
|
obligation_type
|
||||||
|
FROM
|
||||||
|
control_obls
|
||||||
|
WHERE
|
||||||
|
%s;
|
||||||
|
`
|
||||||
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||||
|
|
||||||
|
args := pgx.StrictNamedArgs{"control_ids": controlIDs}
|
||||||
|
maps.Copy(args, scope.SQLArguments())
|
||||||
|
|
||||||
|
rows, err := conn.Query(ctx, q, args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot load obligation types by control IDs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := pgx.CollectRows(rows, pgx.RowToStructByName[ControlObligationType])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot collect control obligation types: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type (
|
|||||||
CurrentPublishedMajor *int `db:"current_published_major"`
|
CurrentPublishedMajor *int `db:"current_published_major"`
|
||||||
CurrentPublishedMinor *int `db:"current_published_minor"`
|
CurrentPublishedMinor *int `db:"current_published_minor"`
|
||||||
TrustCenterVisibility TrustCenterVisibility `db:"trust_center_visibility"`
|
TrustCenterVisibility TrustCenterVisibility `db:"trust_center_visibility"`
|
||||||
|
WriteMode DocumentWriteMode `db:"write_mode"`
|
||||||
Status DocumentStatus `db:"status"`
|
Status DocumentStatus `db:"status"`
|
||||||
ArchivedAt *time.Time `db:"archived_at"`
|
ArchivedAt *time.Time `db:"archived_at"`
|
||||||
CreatedAt time.Time `db:"created_at"`
|
CreatedAt time.Time `db:"created_at"`
|
||||||
@@ -102,6 +103,7 @@ SELECT
|
|||||||
documents.organization_id,
|
documents.organization_id,
|
||||||
documents.current_published_major,
|
documents.current_published_major,
|
||||||
documents.current_published_minor,
|
documents.current_published_minor,
|
||||||
|
documents.write_mode,
|
||||||
documents.trust_center_visibility,
|
documents.trust_center_visibility,
|
||||||
documents.status,
|
documents.status,
|
||||||
documents.archived_at,
|
documents.archived_at,
|
||||||
@@ -161,6 +163,7 @@ SELECT
|
|||||||
documents.organization_id,
|
documents.organization_id,
|
||||||
documents.current_published_major,
|
documents.current_published_major,
|
||||||
documents.current_published_minor,
|
documents.current_published_minor,
|
||||||
|
documents.write_mode,
|
||||||
documents.trust_center_visibility,
|
documents.trust_center_visibility,
|
||||||
documents.status,
|
documents.status,
|
||||||
documents.archived_at,
|
documents.archived_at,
|
||||||
@@ -221,6 +224,7 @@ SELECT
|
|||||||
documents.organization_id,
|
documents.organization_id,
|
||||||
documents.current_published_major,
|
documents.current_published_major,
|
||||||
documents.current_published_minor,
|
documents.current_published_minor,
|
||||||
|
documents.write_mode,
|
||||||
documents.trust_center_visibility,
|
documents.trust_center_visibility,
|
||||||
documents.status,
|
documents.status,
|
||||||
documents.archived_at,
|
documents.archived_at,
|
||||||
@@ -311,6 +315,7 @@ base AS (
|
|||||||
documents.organization_id,
|
documents.organization_id,
|
||||||
documents.current_published_major,
|
documents.current_published_major,
|
||||||
documents.current_published_minor,
|
documents.current_published_minor,
|
||||||
|
documents.write_mode,
|
||||||
documents.trust_center_visibility,
|
documents.trust_center_visibility,
|
||||||
documents.status,
|
documents.status,
|
||||||
documents.archived_at,
|
documents.archived_at,
|
||||||
@@ -370,6 +375,7 @@ SELECT
|
|||||||
documents.organization_id,
|
documents.organization_id,
|
||||||
documents.current_published_major,
|
documents.current_published_major,
|
||||||
documents.current_published_minor,
|
documents.current_published_minor,
|
||||||
|
documents.write_mode,
|
||||||
documents.trust_center_visibility,
|
documents.trust_center_visibility,
|
||||||
documents.status,
|
documents.status,
|
||||||
documents.archived_at,
|
documents.archived_at,
|
||||||
@@ -443,6 +449,7 @@ base AS (
|
|||||||
documents.organization_id,
|
documents.organization_id,
|
||||||
documents.current_published_major,
|
documents.current_published_major,
|
||||||
documents.current_published_minor,
|
documents.current_published_minor,
|
||||||
|
documents.write_mode,
|
||||||
documents.trust_center_visibility,
|
documents.trust_center_visibility,
|
||||||
documents.status,
|
documents.status,
|
||||||
documents.archived_at,
|
documents.archived_at,
|
||||||
@@ -497,6 +504,7 @@ INSERT INTO
|
|||||||
organization_id,
|
organization_id,
|
||||||
current_published_major,
|
current_published_major,
|
||||||
current_published_minor,
|
current_published_minor,
|
||||||
|
write_mode,
|
||||||
trust_center_visibility,
|
trust_center_visibility,
|
||||||
status,
|
status,
|
||||||
archived_at,
|
archived_at,
|
||||||
@@ -509,6 +517,7 @@ VALUES (
|
|||||||
@organization_id,
|
@organization_id,
|
||||||
@current_published_major,
|
@current_published_major,
|
||||||
@current_published_minor,
|
@current_published_minor,
|
||||||
|
@write_mode,
|
||||||
@trust_center_visibility,
|
@trust_center_visibility,
|
||||||
@status,
|
@status,
|
||||||
@archived_at,
|
@archived_at,
|
||||||
@@ -523,6 +532,7 @@ VALUES (
|
|||||||
"organization_id": p.OrganizationID,
|
"organization_id": p.OrganizationID,
|
||||||
"current_published_major": p.CurrentPublishedMajor,
|
"current_published_major": p.CurrentPublishedMajor,
|
||||||
"current_published_minor": p.CurrentPublishedMinor,
|
"current_published_minor": p.CurrentPublishedMinor,
|
||||||
|
"write_mode": p.WriteMode,
|
||||||
"trust_center_visibility": p.TrustCenterVisibility,
|
"trust_center_visibility": p.TrustCenterVisibility,
|
||||||
"status": p.Status,
|
"status": p.Status,
|
||||||
"archived_at": p.ArchivedAt,
|
"archived_at": p.ArchivedAt,
|
||||||
@@ -675,6 +685,7 @@ base AS (
|
|||||||
sd.current_published_major,
|
sd.current_published_major,
|
||||||
sd.current_published_minor,
|
sd.current_published_minor,
|
||||||
sd.trust_center_visibility,
|
sd.trust_center_visibility,
|
||||||
|
sd.write_mode,
|
||||||
sd.status,
|
sd.status,
|
||||||
sd.archived_at,
|
sd.archived_at,
|
||||||
sd.created_at,
|
sd.created_at,
|
||||||
@@ -774,6 +785,7 @@ base AS (
|
|||||||
sd.current_published_major,
|
sd.current_published_major,
|
||||||
sd.current_published_minor,
|
sd.current_published_minor,
|
||||||
sd.trust_center_visibility,
|
sd.trust_center_visibility,
|
||||||
|
sd.write_mode,
|
||||||
sd.status,
|
sd.status,
|
||||||
sd.archived_at,
|
sd.archived_at,
|
||||||
sd.created_at,
|
sd.created_at,
|
||||||
@@ -873,6 +885,7 @@ base AS (
|
|||||||
sd.current_published_major,
|
sd.current_published_major,
|
||||||
sd.current_published_minor,
|
sd.current_published_minor,
|
||||||
sd.trust_center_visibility,
|
sd.trust_center_visibility,
|
||||||
|
sd.write_mode,
|
||||||
sd.status,
|
sd.status,
|
||||||
sd.archived_at,
|
sd.archived_at,
|
||||||
sd.created_at,
|
sd.created_at,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type (
|
|||||||
employeeFilterModes []EmployeeFilterMode
|
employeeFilterModes []EmployeeFilterMode
|
||||||
documentTypes []DocumentType
|
documentTypes []DocumentType
|
||||||
classifications []DocumentClassification
|
classifications []DocumentClassification
|
||||||
|
writeModes []DocumentWriteMode
|
||||||
status []DocumentStatus
|
status []DocumentStatus
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -71,6 +72,11 @@ func (f *DocumentFilter) WithClassifications(classifications []DocumentClassific
|
|||||||
return f
|
return f
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *DocumentFilter) WithWriteModes(writeModes []DocumentWriteMode) *DocumentFilter {
|
||||||
|
f.writeModes = writeModes
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
func (f *DocumentFilter) WithStatus(status []DocumentStatus) *DocumentFilter {
|
func (f *DocumentFilter) WithStatus(status []DocumentStatus) *DocumentFilter {
|
||||||
f.status = status
|
f.status = status
|
||||||
return f
|
return f
|
||||||
@@ -101,6 +107,14 @@ func (f *DocumentFilter) SQLArguments() pgx.NamedArgs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var writeModes []string
|
||||||
|
if f.writeModes != nil {
|
||||||
|
writeModes = make([]string, len(f.writeModes))
|
||||||
|
for i, cs := range f.writeModes {
|
||||||
|
writeModes[i] = cs.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var status []string
|
var status []string
|
||||||
if f.status != nil {
|
if f.status != nil {
|
||||||
status = make([]string, len(f.status))
|
status = make([]string, len(f.status))
|
||||||
@@ -122,6 +136,7 @@ func (f *DocumentFilter) SQLArguments() pgx.NamedArgs {
|
|||||||
"employee_filter_modes": employeeFilterModes,
|
"employee_filter_modes": employeeFilterModes,
|
||||||
"document_types": documentTypes,
|
"document_types": documentTypes,
|
||||||
"classifications": classifications,
|
"classifications": classifications,
|
||||||
|
"write_modes": writeModes,
|
||||||
"document_status": status,
|
"document_status": status,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -209,6 +224,12 @@ func (f *DocumentFilter) SQLFragment() string {
|
|||||||
ELSE TRUE
|
ELSE TRUE
|
||||||
END
|
END
|
||||||
AND
|
AND
|
||||||
|
CASE
|
||||||
|
WHEN @write_modes::text[] IS NOT NULL THEN
|
||||||
|
documents.write_mode::text = ANY(@write_modes::text[])
|
||||||
|
ELSE TRUE
|
||||||
|
END
|
||||||
|
AND
|
||||||
CASE
|
CASE
|
||||||
WHEN @document_status::text[] IS NULL THEN TRUE
|
WHEN @document_status::text[] IS NULL THEN TRUE
|
||||||
ELSE status::text = ANY(@document_status::text[])
|
ELSE status::text = ANY(@document_status::text[])
|
||||||
|
|||||||
@@ -24,15 +24,16 @@ type (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DocumentTypeOther DocumentType = "OTHER"
|
DocumentTypeOther DocumentType = "OTHER"
|
||||||
DocumentTypeGovernance DocumentType = "GOVERNANCE"
|
DocumentTypeGovernance DocumentType = "GOVERNANCE"
|
||||||
DocumentTypePolicy DocumentType = "POLICY"
|
DocumentTypePolicy DocumentType = "POLICY"
|
||||||
DocumentTypeProcedure DocumentType = "PROCEDURE"
|
DocumentTypeProcedure DocumentType = "PROCEDURE"
|
||||||
DocumentTypePlan DocumentType = "PLAN"
|
DocumentTypePlan DocumentType = "PLAN"
|
||||||
DocumentTypeRegister DocumentType = "REGISTER"
|
DocumentTypeRegister DocumentType = "REGISTER"
|
||||||
DocumentTypeRecord DocumentType = "RECORD"
|
DocumentTypeRecord DocumentType = "RECORD"
|
||||||
DocumentTypeReport DocumentType = "REPORT"
|
DocumentTypeReport DocumentType = "REPORT"
|
||||||
DocumentTypeTemplate DocumentType = "TEMPLATE"
|
DocumentTypeTemplate DocumentType = "TEMPLATE"
|
||||||
|
DocumentTypeStatementOfApplicability DocumentType = "STATEMENT_OF_APPLICABILITY"
|
||||||
)
|
)
|
||||||
|
|
||||||
func DocumentTypes() []DocumentType {
|
func DocumentTypes() []DocumentType {
|
||||||
@@ -46,6 +47,7 @@ func DocumentTypes() []DocumentType {
|
|||||||
DocumentTypeRecord,
|
DocumentTypeRecord,
|
||||||
DocumentTypeReport,
|
DocumentTypeReport,
|
||||||
DocumentTypeTemplate,
|
DocumentTypeTemplate,
|
||||||
|
DocumentTypeStatementOfApplicability,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +77,8 @@ func (dt *DocumentType) UnmarshalText(data []byte) error {
|
|||||||
*dt = DocumentTypeReport
|
*dt = DocumentTypeReport
|
||||||
case DocumentTypeTemplate.String():
|
case DocumentTypeTemplate.String():
|
||||||
*dt = DocumentTypeTemplate
|
*dt = DocumentTypeTemplate
|
||||||
|
case DocumentTypeStatementOfApplicability.String():
|
||||||
|
*dt = DocumentTypeStatementOfApplicability
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("invalid DocumentType value: %q", val)
|
return fmt.Errorf("invalid DocumentType value: %q", val)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,20 +30,21 @@ import (
|
|||||||
|
|
||||||
type (
|
type (
|
||||||
DocumentVersion struct {
|
DocumentVersion struct {
|
||||||
ID gid.GID `db:"id"`
|
ID gid.GID `db:"id"`
|
||||||
OrganizationID gid.GID `db:"organization_id"`
|
OrganizationID gid.GID `db:"organization_id"`
|
||||||
DocumentID gid.GID `db:"document_id"`
|
DocumentID gid.GID `db:"document_id"`
|
||||||
Title string `db:"title"`
|
Title string `db:"title"`
|
||||||
Major int `db:"major"`
|
Major int `db:"major"`
|
||||||
Minor int `db:"minor"`
|
Minor int `db:"minor"`
|
||||||
Classification DocumentClassification `db:"classification"`
|
Classification DocumentClassification `db:"classification"`
|
||||||
DocumentType DocumentType `db:"document_type"`
|
DocumentType DocumentType `db:"document_type"`
|
||||||
Content string `db:"content"`
|
Content string `db:"content"`
|
||||||
Changelog string `db:"changelog"`
|
Changelog string `db:"changelog"`
|
||||||
Status DocumentVersionStatus `db:"status"`
|
Status DocumentVersionStatus `db:"status"`
|
||||||
PublishedAt *time.Time `db:"published_at"`
|
Orientation DocumentVersionOrientation `db:"orientation"`
|
||||||
CreatedAt time.Time `db:"created_at"`
|
PublishedAt *time.Time `db:"published_at"`
|
||||||
UpdatedAt time.Time `db:"updated_at"`
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
DocumentVersions []*DocumentVersion
|
DocumentVersions []*DocumentVersion
|
||||||
@@ -93,6 +94,7 @@ SELECT
|
|||||||
content,
|
content,
|
||||||
changelog,
|
changelog,
|
||||||
status,
|
status,
|
||||||
|
orientation,
|
||||||
published_at,
|
published_at,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
@@ -156,6 +158,7 @@ SELECT
|
|||||||
content,
|
content,
|
||||||
changelog,
|
changelog,
|
||||||
status,
|
status,
|
||||||
|
orientation,
|
||||||
published_at,
|
published_at,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
@@ -211,6 +214,8 @@ INSERT INTO document_versions (
|
|||||||
content,
|
content,
|
||||||
changelog,
|
changelog,
|
||||||
status,
|
status,
|
||||||
|
orientation,
|
||||||
|
published_at,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
)
|
)
|
||||||
@@ -227,6 +232,8 @@ VALUES (
|
|||||||
@content,
|
@content,
|
||||||
@changelog,
|
@changelog,
|
||||||
@status,
|
@status,
|
||||||
|
@orientation,
|
||||||
|
@published_at,
|
||||||
@created_at,
|
@created_at,
|
||||||
@updated_at
|
@updated_at
|
||||||
)
|
)
|
||||||
@@ -244,6 +251,8 @@ VALUES (
|
|||||||
"content": dv.Content,
|
"content": dv.Content,
|
||||||
"changelog": dv.Changelog,
|
"changelog": dv.Changelog,
|
||||||
"status": dv.Status,
|
"status": dv.Status,
|
||||||
|
"orientation": dv.Orientation,
|
||||||
|
"published_at": dv.PublishedAt,
|
||||||
"created_at": dv.CreatedAt,
|
"created_at": dv.CreatedAt,
|
||||||
"updated_at": dv.UpdatedAt,
|
"updated_at": dv.UpdatedAt,
|
||||||
}
|
}
|
||||||
@@ -285,6 +294,7 @@ SELECT
|
|||||||
content,
|
content,
|
||||||
changelog,
|
changelog,
|
||||||
status,
|
status,
|
||||||
|
orientation,
|
||||||
published_at,
|
published_at,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
@@ -344,6 +354,7 @@ SELECT
|
|||||||
content,
|
content,
|
||||||
changelog,
|
changelog,
|
||||||
status,
|
status,
|
||||||
|
orientation,
|
||||||
published_at,
|
published_at,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
@@ -399,6 +410,7 @@ SELECT
|
|||||||
content,
|
content,
|
||||||
changelog,
|
changelog,
|
||||||
status,
|
status,
|
||||||
|
orientation,
|
||||||
published_at,
|
published_at,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
@@ -453,6 +465,7 @@ UPDATE document_versions SET
|
|||||||
published_at = @published_at,
|
published_at = @published_at,
|
||||||
classification = @classification,
|
classification = @classification,
|
||||||
document_type = @document_type,
|
document_type = @document_type,
|
||||||
|
orientation = @orientation,
|
||||||
updated_at = @updated_at
|
updated_at = @updated_at
|
||||||
WHERE %s
|
WHERE %s
|
||||||
AND id = @document_version_id
|
AND id = @document_version_id
|
||||||
@@ -471,6 +484,7 @@ WHERE %s
|
|||||||
"published_at": dv.PublishedAt,
|
"published_at": dv.PublishedAt,
|
||||||
"classification": dv.Classification,
|
"classification": dv.Classification,
|
||||||
"document_type": dv.DocumentType,
|
"document_type": dv.DocumentType,
|
||||||
|
"orientation": dv.Orientation,
|
||||||
"updated_at": dv.UpdatedAt,
|
"updated_at": dv.UpdatedAt,
|
||||||
}
|
}
|
||||||
maps.Copy(args, scope.SQLArguments())
|
maps.Copy(args, scope.SQLArguments())
|
||||||
|
|||||||
72
pkg/coredata/document_version_orientation.go
Normal file
72
pkg/coredata/document_version_orientation.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
// 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 coredata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
DocumentVersionOrientation string
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DocumentVersionOrientationPortrait DocumentVersionOrientation = "PORTRAIT"
|
||||||
|
DocumentVersionOrientationLandscape DocumentVersionOrientation = "LANDSCAPE"
|
||||||
|
)
|
||||||
|
|
||||||
|
func DocumentVersionOrientations() []DocumentVersionOrientation {
|
||||||
|
return []DocumentVersionOrientation{
|
||||||
|
DocumentVersionOrientationPortrait,
|
||||||
|
DocumentVersionOrientationLandscape,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o DocumentVersionOrientation) MarshalText() ([]byte, error) {
|
||||||
|
return []byte(o.String()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *DocumentVersionOrientation) UnmarshalText(data []byte) error {
|
||||||
|
val := string(data)
|
||||||
|
|
||||||
|
switch val {
|
||||||
|
case DocumentVersionOrientationPortrait.String():
|
||||||
|
*o = DocumentVersionOrientationPortrait
|
||||||
|
case DocumentVersionOrientationLandscape.String():
|
||||||
|
*o = DocumentVersionOrientationLandscape
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid DocumentVersionOrientation value: %q", val)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o DocumentVersionOrientation) String() string {
|
||||||
|
return string(o)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *DocumentVersionOrientation) Scan(value any) error {
|
||||||
|
val, ok := value.(string)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("invalid scan source for DocumentVersionOrientation, expected string got %T", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return o.UnmarshalText([]byte(val))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o DocumentVersionOrientation) Value() (driver.Value, error) {
|
||||||
|
return o.String(), nil
|
||||||
|
}
|
||||||
48
pkg/coredata/document_write_mode.go
Normal file
48
pkg/coredata/document_write_mode.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
// 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 coredata
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
type (
|
||||||
|
DocumentWriteMode string
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DocumentWriteModeAuthored DocumentWriteMode = "AUTHORED"
|
||||||
|
DocumentWriteModeGenerated DocumentWriteMode = "GENERATED"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (e DocumentWriteMode) IsValid() bool {
|
||||||
|
switch e {
|
||||||
|
case DocumentWriteModeAuthored, DocumentWriteModeGenerated:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e DocumentWriteMode) String() string { return string(e) }
|
||||||
|
|
||||||
|
func (e *DocumentWriteMode) UnmarshalText(text []byte) error {
|
||||||
|
*e = DocumentWriteMode(text)
|
||||||
|
if !e.IsValid() {
|
||||||
|
return fmt.Errorf("%s is not a valid DocumentWriteMode", string(text))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e DocumentWriteMode) MarshalText() ([]byte, error) {
|
||||||
|
return []byte(e.String()), nil
|
||||||
|
}
|
||||||
@@ -24,22 +24,23 @@ type (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ElectronicSignatureDocumentTypeNDA ElectronicSignatureDocumentType = "NDA"
|
ElectronicSignatureDocumentTypeNDA ElectronicSignatureDocumentType = "NDA"
|
||||||
ElectronicSignatureDocumentTypeDPA ElectronicSignatureDocumentType = "DPA"
|
ElectronicSignatureDocumentTypeDPA ElectronicSignatureDocumentType = "DPA"
|
||||||
ElectronicSignatureDocumentTypeMSA ElectronicSignatureDocumentType = "MSA"
|
ElectronicSignatureDocumentTypeMSA ElectronicSignatureDocumentType = "MSA"
|
||||||
ElectronicSignatureDocumentTypeSOW ElectronicSignatureDocumentType = "SOW"
|
ElectronicSignatureDocumentTypeSOW ElectronicSignatureDocumentType = "SOW"
|
||||||
ElectronicSignatureDocumentTypeSLA ElectronicSignatureDocumentType = "SLA"
|
ElectronicSignatureDocumentTypeSLA ElectronicSignatureDocumentType = "SLA"
|
||||||
ElectronicSignatureDocumentTypeTOS ElectronicSignatureDocumentType = "TOS"
|
ElectronicSignatureDocumentTypeTOS ElectronicSignatureDocumentType = "TOS"
|
||||||
ElectronicSignatureDocumentTypePrivacyPolicy ElectronicSignatureDocumentType = "PRIVACY_POLICY"
|
ElectronicSignatureDocumentTypePrivacyPolicy ElectronicSignatureDocumentType = "PRIVACY_POLICY"
|
||||||
ElectronicSignatureDocumentTypeGovernance ElectronicSignatureDocumentType = "GOVERNANCE"
|
ElectronicSignatureDocumentTypeGovernance ElectronicSignatureDocumentType = "GOVERNANCE"
|
||||||
ElectronicSignatureDocumentTypePolicy ElectronicSignatureDocumentType = "POLICY"
|
ElectronicSignatureDocumentTypePolicy ElectronicSignatureDocumentType = "POLICY"
|
||||||
ElectronicSignatureDocumentTypeProcedure ElectronicSignatureDocumentType = "PROCEDURE"
|
ElectronicSignatureDocumentTypeProcedure ElectronicSignatureDocumentType = "PROCEDURE"
|
||||||
ElectronicSignatureDocumentTypePlan ElectronicSignatureDocumentType = "PLAN"
|
ElectronicSignatureDocumentTypePlan ElectronicSignatureDocumentType = "PLAN"
|
||||||
ElectronicSignatureDocumentTypeRegister ElectronicSignatureDocumentType = "REGISTER"
|
ElectronicSignatureDocumentTypeRegister ElectronicSignatureDocumentType = "REGISTER"
|
||||||
ElectronicSignatureDocumentTypeRecord ElectronicSignatureDocumentType = "RECORD"
|
ElectronicSignatureDocumentTypeRecord ElectronicSignatureDocumentType = "RECORD"
|
||||||
ElectronicSignatureDocumentTypeReport ElectronicSignatureDocumentType = "REPORT"
|
ElectronicSignatureDocumentTypeReport ElectronicSignatureDocumentType = "REPORT"
|
||||||
ElectronicSignatureDocumentTypeTemplate ElectronicSignatureDocumentType = "TEMPLATE"
|
ElectronicSignatureDocumentTypeTemplate ElectronicSignatureDocumentType = "TEMPLATE"
|
||||||
ElectronicSignatureDocumentTypeOther ElectronicSignatureDocumentType = "OTHER"
|
ElectronicSignatureDocumentTypeStatementOfApplicability ElectronicSignatureDocumentType = "STATEMENT_OF_APPLICABILITY"
|
||||||
|
ElectronicSignatureDocumentTypeOther ElectronicSignatureDocumentType = "OTHER"
|
||||||
|
|
||||||
ESignProcessConsentText = "By typing my full name and clicking Accept, I consent to sign this document electronically and agree that my electronic signature has the same legal validity as a handwritten signature."
|
ESignProcessConsentText = "By typing my full name and clicking Accept, I consent to sign this document electronically and agree that my electronic signature has the same legal validity as a handwritten signature."
|
||||||
)
|
)
|
||||||
@@ -61,6 +62,7 @@ func ElectronicSignatureDocumentTypes() []ElectronicSignatureDocumentType {
|
|||||||
ElectronicSignatureDocumentTypeRecord,
|
ElectronicSignatureDocumentTypeRecord,
|
||||||
ElectronicSignatureDocumentTypeReport,
|
ElectronicSignatureDocumentTypeReport,
|
||||||
ElectronicSignatureDocumentTypeTemplate,
|
ElectronicSignatureDocumentTypeTemplate,
|
||||||
|
ElectronicSignatureDocumentTypeStatementOfApplicability,
|
||||||
ElectronicSignatureDocumentTypeOther,
|
ElectronicSignatureDocumentTypeOther,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,6 +105,8 @@ func (dt *ElectronicSignatureDocumentType) UnmarshalText(data []byte) error {
|
|||||||
*dt = ElectronicSignatureDocumentTypeReport
|
*dt = ElectronicSignatureDocumentTypeReport
|
||||||
case ElectronicSignatureDocumentTypeTemplate.String():
|
case ElectronicSignatureDocumentTypeTemplate.String():
|
||||||
*dt = ElectronicSignatureDocumentTypeTemplate
|
*dt = ElectronicSignatureDocumentTypeTemplate
|
||||||
|
case ElectronicSignatureDocumentTypeStatementOfApplicability.String():
|
||||||
|
*dt = ElectronicSignatureDocumentTypeStatementOfApplicability
|
||||||
case ElectronicSignatureDocumentTypeOther.String():
|
case ElectronicSignatureDocumentTypeOther.String():
|
||||||
*dt = ElectronicSignatureDocumentTypeOther
|
*dt = ElectronicSignatureDocumentTypeOther
|
||||||
default:
|
default:
|
||||||
@@ -161,6 +165,8 @@ func (dt ElectronicSignatureDocumentType) DisplayName() string {
|
|||||||
return "Report"
|
return "Report"
|
||||||
case ElectronicSignatureDocumentTypeTemplate:
|
case ElectronicSignatureDocumentTypeTemplate:
|
||||||
return "Template"
|
return "Template"
|
||||||
|
case ElectronicSignatureDocumentTypeStatementOfApplicability:
|
||||||
|
return "Statement of Applicability"
|
||||||
default:
|
default:
|
||||||
return string(dt)
|
return string(dt)
|
||||||
}
|
}
|
||||||
@@ -199,6 +205,8 @@ func (dt ElectronicSignatureDocumentType) ConsentText() (string, error) {
|
|||||||
docAgreement = "I acknowledge and agree to this Report."
|
docAgreement = "I acknowledge and agree to this Report."
|
||||||
case ElectronicSignatureDocumentTypeTemplate:
|
case ElectronicSignatureDocumentTypeTemplate:
|
||||||
docAgreement = "I acknowledge and agree to this Template."
|
docAgreement = "I acknowledge and agree to this Template."
|
||||||
|
case ElectronicSignatureDocumentTypeStatementOfApplicability:
|
||||||
|
docAgreement = "I acknowledge and agree to this Statement of Applicability."
|
||||||
case ElectronicSignatureDocumentTypeOther:
|
case ElectronicSignatureDocumentTypeOther:
|
||||||
return "", fmt.Errorf("cannot get consent text: document type OTHER requires explicit consent text")
|
return "", fmt.Errorf("cannot get consent text: document type OTHER requires explicit consent text")
|
||||||
default:
|
default:
|
||||||
@@ -226,6 +234,8 @@ func ElectronicSignatureDocumentTypeFromDocumentType(dt DocumentType) Electronic
|
|||||||
return ElectronicSignatureDocumentTypeReport
|
return ElectronicSignatureDocumentTypeReport
|
||||||
case DocumentTypeTemplate:
|
case DocumentTypeTemplate:
|
||||||
return ElectronicSignatureDocumentTypeTemplate
|
return ElectronicSignatureDocumentTypeTemplate
|
||||||
|
case DocumentTypeStatementOfApplicability:
|
||||||
|
return ElectronicSignatureDocumentTypeStatementOfApplicability
|
||||||
default:
|
default:
|
||||||
return ElectronicSignatureDocumentTypeOther
|
return ElectronicSignatureDocumentTypeOther
|
||||||
}
|
}
|
||||||
|
|||||||
45
pkg/coredata/migrations/20260410T120000Z.sql
Normal file
45
pkg/coredata/migrations/20260410T120000Z.sql
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
-- 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 TYPE document_version_orientation AS ENUM ('PORTRAIT', 'LANDSCAPE');
|
||||||
|
|
||||||
|
CREATE TYPE document_write_mode AS ENUM ('AUTHORED', 'GENERATED');
|
||||||
|
|
||||||
|
ALTER TABLE document_versions
|
||||||
|
ADD COLUMN orientation document_version_orientation DEFAULT 'PORTRAIT';
|
||||||
|
|
||||||
|
ALTER TABLE document_versions
|
||||||
|
ALTER COLUMN orientation DROP DEFAULT;
|
||||||
|
|
||||||
|
ALTER TABLE documents
|
||||||
|
ADD COLUMN write_mode document_write_mode NOT NULL DEFAULT 'AUTHORED';
|
||||||
|
|
||||||
|
ALTER TABLE documents
|
||||||
|
ALTER COLUMN write_mode DROP DEFAULT;
|
||||||
|
|
||||||
|
ALTER TYPE document_type ADD VALUE 'STATEMENT_OF_APPLICABILITY';
|
||||||
|
|
||||||
|
ALTER TYPE electronic_signature_document_type ADD VALUE 'STATEMENT_OF_APPLICABILITY';
|
||||||
|
|
||||||
|
ALTER TABLE statements_of_applicability
|
||||||
|
ADD COLUMN document_id TEXT UNIQUE REFERENCES documents(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- TODO: drop owner_profile_id column
|
||||||
|
ALTER TABLE statements_of_applicability
|
||||||
|
ALTER COLUMN owner_profile_id DROP NOT NULL;
|
||||||
|
|
||||||
|
-- TODO: drop statements_of_applicability.source_id column
|
||||||
|
-- TODO: drop statements_of_applicability.snapshot_id column
|
||||||
|
-- TODO: drop applicability_statements.snapshot_id column
|
||||||
|
|
||||||
@@ -124,6 +124,7 @@ FROM
|
|||||||
WHERE
|
WHERE
|
||||||
%s
|
%s
|
||||||
AND organization_id = @organization_id
|
AND organization_id = @organization_id
|
||||||
|
AND type != 'STATEMENTS_OF_APPLICABILITY'
|
||||||
AND %s
|
AND %s
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -163,6 +164,7 @@ FROM
|
|||||||
WHERE
|
WHERE
|
||||||
%s
|
%s
|
||||||
AND organization_id = @organization_id
|
AND organization_id = @organization_id
|
||||||
|
AND type != 'STATEMENTS_OF_APPLICABILITY'
|
||||||
AND %s
|
AND %s
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ func SnapshotsTypes() []SnapshotsType {
|
|||||||
SnapshotsTypeFindings,
|
SnapshotsTypeFindings,
|
||||||
SnapshotsTypeObligations,
|
SnapshotsTypeObligations,
|
||||||
SnapshotsTypeProcessingActivities,
|
SnapshotsTypeProcessingActivities,
|
||||||
SnapshotsTypeStatementsOfApplicability,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,8 +42,6 @@ func GetSnapshottable(snapshotType SnapshotsType) (Snapshottable, error) {
|
|||||||
return ProcessingActivities{}, nil
|
return ProcessingActivities{}, nil
|
||||||
case SnapshotsTypeVendors:
|
case SnapshotsTypeVendors:
|
||||||
return Vendors{}, nil
|
return Vendors{}, nil
|
||||||
case SnapshotsTypeStatementsOfApplicability:
|
|
||||||
return StatementsOfApplicability{}, nil
|
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported snapshot type: %s", snapshotType)
|
return nil, fmt.Errorf("unsupported snapshot type: %s", snapshotType)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,9 +33,7 @@ type (
|
|||||||
ID gid.GID `db:"id"`
|
ID gid.GID `db:"id"`
|
||||||
OrganizationID gid.GID `db:"organization_id"`
|
OrganizationID gid.GID `db:"organization_id"`
|
||||||
Name string `db:"name"`
|
Name string `db:"name"`
|
||||||
SourceID *gid.GID `db:"source_id"`
|
DocumentID *gid.GID `db:"document_id"`
|
||||||
SnapshotID *gid.GID `db:"snapshot_id"`
|
|
||||||
OwnerID gid.GID `db:"owner_profile_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"`
|
||||||
}
|
}
|
||||||
@@ -79,9 +77,7 @@ SELECT
|
|||||||
id,
|
id,
|
||||||
organization_id,
|
organization_id,
|
||||||
name,
|
name,
|
||||||
source_id,
|
document_id,
|
||||||
snapshot_id,
|
|
||||||
owner_profile_id,
|
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
FROM
|
FROM
|
||||||
@@ -89,6 +85,7 @@ FROM
|
|||||||
WHERE
|
WHERE
|
||||||
%s
|
%s
|
||||||
AND id = @statement_of_applicability_id
|
AND id = @statement_of_applicability_id
|
||||||
|
AND snapshot_id IS NULL
|
||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -121,16 +118,13 @@ func (s *StatementsOfApplicability) LoadByOrganizationID(
|
|||||||
scope Scoper,
|
scope Scoper,
|
||||||
organizationID gid.GID,
|
organizationID gid.GID,
|
||||||
cursor *page.Cursor[StatementOfApplicabilityOrderField],
|
cursor *page.Cursor[StatementOfApplicabilityOrderField],
|
||||||
filter *StatementOfApplicabilityFilter,
|
|
||||||
) error {
|
) error {
|
||||||
q := `
|
q := `
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
organization_id,
|
organization_id,
|
||||||
name,
|
name,
|
||||||
source_id,
|
document_id,
|
||||||
snapshot_id,
|
|
||||||
owner_profile_id,
|
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
FROM
|
FROM
|
||||||
@@ -138,14 +132,13 @@ 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.NamedArgs{"organization_id": organizationID}
|
args := pgx.NamedArgs{"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)
|
||||||
@@ -167,7 +160,6 @@ func (s *StatementsOfApplicability) CountByOrganizationID(
|
|||||||
conn pg.Querier,
|
conn pg.Querier,
|
||||||
scope Scoper,
|
scope Scoper,
|
||||||
organizationID gid.GID,
|
organizationID gid.GID,
|
||||||
filter *StatementOfApplicabilityFilter,
|
|
||||||
) (int, error) {
|
) (int, error) {
|
||||||
q := `
|
q := `
|
||||||
SELECT
|
SELECT
|
||||||
@@ -177,15 +169,14 @@ 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{
|
args := pgx.StrictNamedArgs{
|
||||||
"organization_id": organizationID,
|
"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)
|
||||||
var count int
|
var count int
|
||||||
@@ -208,9 +199,7 @@ INSERT INTO
|
|||||||
id,
|
id,
|
||||||
organization_id,
|
organization_id,
|
||||||
name,
|
name,
|
||||||
source_id,
|
document_id,
|
||||||
snapshot_id,
|
|
||||||
owner_profile_id,
|
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
)
|
)
|
||||||
@@ -219,9 +208,7 @@ VALUES (
|
|||||||
@statement_of_applicability_id,
|
@statement_of_applicability_id,
|
||||||
@organization_id,
|
@organization_id,
|
||||||
@name,
|
@name,
|
||||||
@source_id,
|
@document_id,
|
||||||
@snapshot_id,
|
|
||||||
@owner_profile_id,
|
|
||||||
@created_at,
|
@created_at,
|
||||||
@updated_at
|
@updated_at
|
||||||
);
|
);
|
||||||
@@ -232,9 +219,7 @@ VALUES (
|
|||||||
"statement_of_applicability_id": s.ID,
|
"statement_of_applicability_id": s.ID,
|
||||||
"organization_id": s.OrganizationID,
|
"organization_id": s.OrganizationID,
|
||||||
"name": s.Name,
|
"name": s.Name,
|
||||||
"source_id": s.SourceID,
|
"document_id": s.DocumentID,
|
||||||
"snapshot_id": s.SnapshotID,
|
|
||||||
"owner_profile_id": s.OwnerID,
|
|
||||||
"created_at": s.CreatedAt,
|
"created_at": s.CreatedAt,
|
||||||
"updated_at": s.UpdatedAt,
|
"updated_at": s.UpdatedAt,
|
||||||
}
|
}
|
||||||
@@ -262,11 +247,12 @@ func (s *StatementOfApplicability) Update(
|
|||||||
UPDATE statements_of_applicability
|
UPDATE statements_of_applicability
|
||||||
SET
|
SET
|
||||||
name = @name,
|
name = @name,
|
||||||
owner_profile_id = @owner_profile_id,
|
document_id = @document_id,
|
||||||
updated_at = @updated_at
|
updated_at = @updated_at
|
||||||
WHERE
|
WHERE
|
||||||
%s
|
%s
|
||||||
AND id = @statement_of_applicability_id
|
AND id = @statement_of_applicability_id
|
||||||
|
AND snapshot_id IS NULL
|
||||||
`
|
`
|
||||||
|
|
||||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||||
@@ -274,7 +260,7 @@ WHERE
|
|||||||
args := pgx.StrictNamedArgs{
|
args := pgx.StrictNamedArgs{
|
||||||
"statement_of_applicability_id": s.ID,
|
"statement_of_applicability_id": s.ID,
|
||||||
"name": s.Name,
|
"name": s.Name,
|
||||||
"owner_profile_id": s.OwnerID,
|
"document_id": s.DocumentID,
|
||||||
"updated_at": s.UpdatedAt,
|
"updated_at": s.UpdatedAt,
|
||||||
}
|
}
|
||||||
maps.Copy(args, scope.SQLArguments())
|
maps.Copy(args, scope.SQLArguments())
|
||||||
@@ -307,6 +293,7 @@ DELETE FROM statements_of_applicability
|
|||||||
WHERE
|
WHERE
|
||||||
%s
|
%s
|
||||||
AND id = @statement_of_applicability_id
|
AND id = @statement_of_applicability_id
|
||||||
|
AND snapshot_id IS NULL
|
||||||
`
|
`
|
||||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||||
|
|
||||||
@@ -326,139 +313,3 @@ WHERE
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (soas StatementsOfApplicability) Snapshot(ctx context.Context, conn pg.Tx, scope Scoper, organizationID, snapshotID gid.GID) error {
|
|
||||||
if err := soas.insertStatementOfApplicabilitySnapshots(ctx, conn, scope, organizationID, snapshotID); err != nil {
|
|
||||||
return fmt.Errorf("cannot insert statement_of_applicability snapshots: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := soas.insertStatementOfApplicabilityControlSnapshots(ctx, conn, scope, organizationID, snapshotID); err != nil {
|
|
||||||
return fmt.Errorf("cannot insert statement_of_applicability_control snapshots: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (soas StatementsOfApplicability) insertStatementOfApplicabilitySnapshots(
|
|
||||||
ctx context.Context,
|
|
||||||
conn pg.Querier,
|
|
||||||
scope Scoper,
|
|
||||||
organizationID gid.GID,
|
|
||||||
snapshotID gid.GID,
|
|
||||||
) error {
|
|
||||||
query := `
|
|
||||||
INSERT INTO statements_of_applicability (
|
|
||||||
id,
|
|
||||||
tenant_id,
|
|
||||||
organization_id,
|
|
||||||
name,
|
|
||||||
source_id,
|
|
||||||
snapshot_id,
|
|
||||||
owner_profile_id,
|
|
||||||
created_at,
|
|
||||||
updated_at
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
generate_gid(decode_base64_unpadded(@tenant_id), @statement_of_applicability_entity_type),
|
|
||||||
@tenant_id,
|
|
||||||
soa.organization_id,
|
|
||||||
soa.name,
|
|
||||||
soa.id,
|
|
||||||
@snapshot_id,
|
|
||||||
soa.owner_profile_id,
|
|
||||||
soa.created_at,
|
|
||||||
soa.updated_at
|
|
||||||
FROM statements_of_applicability soa
|
|
||||||
WHERE
|
|
||||||
%s
|
|
||||||
AND soa.organization_id = @organization_id
|
|
||||||
AND soa.snapshot_id IS NULL
|
|
||||||
`
|
|
||||||
|
|
||||||
query = fmt.Sprintf(query, scope.SQLFragment())
|
|
||||||
|
|
||||||
args := pgx.StrictNamedArgs{
|
|
||||||
"tenant_id": scope.GetTenantID(),
|
|
||||||
"snapshot_id": snapshotID,
|
|
||||||
"organization_id": organizationID,
|
|
||||||
"statement_of_applicability_entity_type": StatementOfApplicabilityEntityType,
|
|
||||||
}
|
|
||||||
maps.Copy(args, scope.SQLArguments())
|
|
||||||
|
|
||||||
_, err := conn.Exec(ctx, query, args)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("cannot insert statement_of_applicability snapshots: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (soas StatementsOfApplicability) insertStatementOfApplicabilityControlSnapshots(
|
|
||||||
ctx context.Context,
|
|
||||||
conn pg.Querier,
|
|
||||||
scope Scoper,
|
|
||||||
organizationID gid.GID,
|
|
||||||
snapshotID gid.GID,
|
|
||||||
) error {
|
|
||||||
query := `
|
|
||||||
WITH source_soa AS (
|
|
||||||
SELECT id, organization_id
|
|
||||||
FROM statements_of_applicability
|
|
||||||
WHERE
|
|
||||||
%s
|
|
||||||
AND organization_id = @organization_id
|
|
||||||
AND snapshot_id IS NULL
|
|
||||||
),
|
|
||||||
snapshot_soa AS (
|
|
||||||
SELECT id, source_id
|
|
||||||
FROM statements_of_applicability
|
|
||||||
WHERE snapshot_id = @snapshot_id
|
|
||||||
)
|
|
||||||
INSERT INTO applicability_statements (
|
|
||||||
id,
|
|
||||||
statement_of_applicability_id,
|
|
||||||
control_id,
|
|
||||||
organization_id,
|
|
||||||
tenant_id,
|
|
||||||
snapshot_id,
|
|
||||||
applicability,
|
|
||||||
justification,
|
|
||||||
created_at,
|
|
||||||
updated_at
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
generate_gid(decode_base64_unpadded(@tenant_id), @applicability_statement_entity_type),
|
|
||||||
snapshot_soa.id,
|
|
||||||
soac.control_id,
|
|
||||||
soac.organization_id,
|
|
||||||
@tenant_id,
|
|
||||||
@snapshot_id,
|
|
||||||
soac.applicability,
|
|
||||||
soac.justification,
|
|
||||||
soac.created_at,
|
|
||||||
soac.updated_at
|
|
||||||
FROM applicability_statements soac
|
|
||||||
INNER JOIN source_soa
|
|
||||||
ON soac.statement_of_applicability_id = source_soa.id
|
|
||||||
INNER JOIN snapshot_soa
|
|
||||||
ON snapshot_soa.source_id = source_soa.id
|
|
||||||
WHERE soac.snapshot_id IS NULL
|
|
||||||
`
|
|
||||||
|
|
||||||
query = fmt.Sprintf(query, scope.SQLFragment())
|
|
||||||
|
|
||||||
args := pgx.StrictNamedArgs{
|
|
||||||
"tenant_id": scope.GetTenantID(),
|
|
||||||
"snapshot_id": snapshotID,
|
|
||||||
"organization_id": organizationID,
|
|
||||||
"applicability_statement_entity_type": ApplicabilityStatementEntityType,
|
|
||||||
}
|
|
||||||
maps.Copy(args, scope.SQLArguments())
|
|
||||||
|
|
||||||
_, err := conn.Exec(ctx, query, args)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("cannot insert statement_of_applicability_control snapshots: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
// 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 coredata
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"go.probo.inc/probo/pkg/gid"
|
|
||||||
)
|
|
||||||
|
|
||||||
type (
|
|
||||||
StatementOfApplicabilityFilter struct {
|
|
||||||
snapshotID **gid.GID
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
func NewStatementOfApplicabilityFilter(snapshotID **gid.GID) *StatementOfApplicabilityFilter {
|
|
||||||
return &StatementOfApplicabilityFilter{
|
|
||||||
snapshotID: snapshotID,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *StatementOfApplicabilityFilter) SQLArguments() pgx.StrictNamedArgs {
|
|
||||||
args := pgx.StrictNamedArgs{}
|
|
||||||
|
|
||||||
if f.snapshotID == nil {
|
|
||||||
args["has_snapshot_filter"] = false
|
|
||||||
args["filter_snapshot_id"] = nil
|
|
||||||
} else if *f.snapshotID == nil {
|
|
||||||
args["has_snapshot_filter"] = true
|
|
||||||
args["filter_snapshot_id"] = nil
|
|
||||||
} else {
|
|
||||||
args["has_snapshot_filter"] = true
|
|
||||||
args["filter_snapshot_id"] = **f.snapshotID
|
|
||||||
}
|
|
||||||
|
|
||||||
return args
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *StatementOfApplicabilityFilter) SQLFragment() string {
|
|
||||||
return `
|
|
||||||
CASE
|
|
||||||
WHEN @has_snapshot_filter::boolean = false THEN TRUE
|
|
||||||
WHEN @has_snapshot_filter::boolean = true AND @filter_snapshot_id::text IS NOT NULL THEN
|
|
||||||
snapshot_id = @filter_snapshot_id::text
|
|
||||||
WHEN @has_snapshot_filter::boolean = true AND @filter_snapshot_id::text IS NULL THEN
|
|
||||||
snapshot_id IS NULL
|
|
||||||
ELSE TRUE
|
|
||||||
END`
|
|
||||||
}
|
|
||||||
@@ -44,9 +44,6 @@ var (
|
|||||||
//go:embed transfer_impact_assessments_template.html
|
//go:embed transfer_impact_assessments_template.html
|
||||||
transferImpactAssessmentsTemplateContent string
|
transferImpactAssessmentsTemplateContent string
|
||||||
|
|
||||||
//go:embed soa_template.html
|
|
||||||
soaTemplateContent string
|
|
||||||
|
|
||||||
templateFuncs = template.FuncMap{
|
templateFuncs = template.FuncMap{
|
||||||
"now": func() time.Time { return time.Now() },
|
"now": func() time.Time { return time.Now() },
|
||||||
"eq": func(a, b any) bool { return a == b },
|
"eq": func(a, b any) bool { return a == b },
|
||||||
@@ -186,8 +183,6 @@ var (
|
|||||||
dataProtectionImpactAssessmentsTemplate = template.Must(template.New("dataProtectionImpactAssessments").Funcs(templateFuncs).Parse(dataProtectionImpactAssessmentsTemplateContent))
|
dataProtectionImpactAssessmentsTemplate = template.Must(template.New("dataProtectionImpactAssessments").Funcs(templateFuncs).Parse(dataProtectionImpactAssessmentsTemplateContent))
|
||||||
|
|
||||||
transferImpactAssessmentsTemplate = template.Must(template.New("transferImpactAssessments").Funcs(templateFuncs).Parse(transferImpactAssessmentsTemplateContent))
|
transferImpactAssessmentsTemplate = template.Must(template.New("transferImpactAssessments").Funcs(templateFuncs).Parse(transferImpactAssessmentsTemplateContent))
|
||||||
|
|
||||||
statementOfApplicabilityTemplate = template.Must(template.New("statement-of-applicability").Funcs(templateFuncs).Parse(soaTemplateContent))
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
@@ -205,6 +200,7 @@ type (
|
|||||||
Signatures []SignatureData
|
Signatures []SignatureData
|
||||||
CompanyHorizontalLogoBase64 string
|
CompanyHorizontalLogoBase64 string
|
||||||
MermaidJS template.JS
|
MermaidJS template.JS
|
||||||
|
Landscape bool
|
||||||
}
|
}
|
||||||
|
|
||||||
SignatureData struct {
|
SignatureData struct {
|
||||||
@@ -280,37 +276,35 @@ type (
|
|||||||
}
|
}
|
||||||
|
|
||||||
StatementOfApplicabilityData struct {
|
StatementOfApplicabilityData struct {
|
||||||
Title string
|
Title string
|
||||||
OrganizationName string
|
OrganizationName string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
TotalControls int
|
TotalControls int
|
||||||
FrameworkGroups []FrameworkControlGroup
|
Rows []SOARow
|
||||||
CompanyHorizontalLogoBase64 string
|
|
||||||
Version int
|
|
||||||
PublishedAt time.Time
|
|
||||||
Approver string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
FrameworkControlGroup struct {
|
SOARow struct {
|
||||||
FrameworkName string
|
FrameworkName string
|
||||||
Controls []ControlData
|
ControlSection string
|
||||||
}
|
ControlName string
|
||||||
|
Applicability string
|
||||||
ControlData struct {
|
Justification string
|
||||||
FrameworkName string
|
Implemented string
|
||||||
SectionTitle string
|
NotImplJustification string
|
||||||
Name string
|
Regulatory string
|
||||||
Applicability *bool
|
Contractual string
|
||||||
Justification *string
|
BestPractice string
|
||||||
BestPractice *bool
|
RiskAssessment string
|
||||||
Implemented *string
|
|
||||||
NotImplementedJustification *string
|
|
||||||
Regulatory *bool
|
|
||||||
Contractual *bool
|
|
||||||
RiskAssessment *bool
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func BoolLabel(v bool) string {
|
||||||
|
if v {
|
||||||
|
return "Yes"
|
||||||
|
}
|
||||||
|
return "No"
|
||||||
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ClassificationPublic Classification = "PUBLIC"
|
ClassificationPublic Classification = "PUBLIC"
|
||||||
ClassificationInternal Classification = "INTERNAL"
|
ClassificationInternal Classification = "INTERNAL"
|
||||||
@@ -381,12 +375,3 @@ func RenderTransferImpactAssessmentsTableHTML(data TransferImpactAssessmentTable
|
|||||||
|
|
||||||
return buf.Bytes(), nil
|
return buf.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func RenderStatementOfApplicabilityHTML(data StatementOfApplicabilityData) ([]byte, error) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
if err := statementOfApplicabilityTemplate.Execute(&buf, data); err != nil {
|
|
||||||
return nil, fmt.Errorf("cannot execute SOA template: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return buf.Bytes(), nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,523 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Statement of Applicability</title>
|
|
||||||
<style>
|
|
||||||
@page {
|
|
||||||
size: A4 landscape;
|
|
||||||
margin: 2.5cm;
|
|
||||||
@bottom-right {
|
|
||||||
content: "Page " counter(page) " of " counter(pages);
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
font-size: 9pt;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
font-size: 7.5pt;
|
|
||||||
line-height: 1.4;
|
|
||||||
color: #333;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Cover page */
|
|
||||||
.cover-page {
|
|
||||||
page-break-after: always;
|
|
||||||
}
|
|
||||||
|
|
||||||
.company-header {
|
|
||||||
margin-bottom: 30px;
|
|
||||||
page-break-after: avoid;
|
|
||||||
}
|
|
||||||
|
|
||||||
.company-logo {
|
|
||||||
max-height: 50px;
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
width: auto;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.export-title {
|
|
||||||
font-size: 22pt;
|
|
||||||
font-weight: normal;
|
|
||||||
color: #1a1a1a;
|
|
||||||
margin: 0 0 25px 0;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.export-subtitle {
|
|
||||||
font-size: 18pt;
|
|
||||||
font-weight: normal;
|
|
||||||
color: #1a1a1a;
|
|
||||||
margin: 0 0 25px 0;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-meta {
|
|
||||||
margin: 0 0 30px 0;
|
|
||||||
font-size: 9pt;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
border: 1px solid #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-table td {
|
|
||||||
padding: 6px 8px;
|
|
||||||
border: 1px solid #333;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-table td:first-child {
|
|
||||||
font-weight: 600;
|
|
||||||
width: 25%;
|
|
||||||
background: #f8f8f8;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.classification {
|
|
||||||
font-weight: bold;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.purpose-section {
|
|
||||||
margin: 30px 0;
|
|
||||||
page-break-after: avoid;
|
|
||||||
}
|
|
||||||
|
|
||||||
.purpose-title {
|
|
||||||
font-size: 15pt;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #000;
|
|
||||||
margin: 0 0 15px 0;
|
|
||||||
page-break-after: avoid;
|
|
||||||
}
|
|
||||||
|
|
||||||
.purpose-text {
|
|
||||||
font-size: 10pt;
|
|
||||||
color: #333;
|
|
||||||
line-height: 1.5;
|
|
||||||
text-align: justify;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Controls page */
|
|
||||||
.controls-page {
|
|
||||||
page-break-before: always;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls-title {
|
|
||||||
font-size: 15pt;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #000;
|
|
||||||
margin: 0 0 15px 0;
|
|
||||||
page-break-after: avoid;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 8pt;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls-table th,
|
|
||||||
.controls-table td {
|
|
||||||
padding: 5px 6px;
|
|
||||||
text-align: left;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls-table th {
|
|
||||||
background: #f5f5f5;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls-table tr {
|
|
||||||
page-break-inside: avoid;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-tag {
|
|
||||||
display: inline-block;
|
|
||||||
background: #e0e0e0;
|
|
||||||
color: #333;
|
|
||||||
padding: 2px 5px;
|
|
||||||
border-radius: 3px;
|
|
||||||
font-size: 7pt;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-right: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.state-tag {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 2px 5px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 8pt;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.state-tag-success {
|
|
||||||
background: #eefadc;
|
|
||||||
color: #5d770d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.state-tag-warning {
|
|
||||||
background: #fff4d5;
|
|
||||||
color: #ad5700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.state-tag-danger {
|
|
||||||
background: #ffefef;
|
|
||||||
color: #cd2b31;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Annex page */
|
|
||||||
.annex-page {
|
|
||||||
page-break-before: always;
|
|
||||||
}
|
|
||||||
|
|
||||||
.annex-title {
|
|
||||||
font-size: 15pt;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #000;
|
|
||||||
margin: 0 0 15px 0;
|
|
||||||
page-break-after: avoid;
|
|
||||||
}
|
|
||||||
|
|
||||||
.annex-section {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.annex-section-title {
|
|
||||||
font-size: 13pt;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #000;
|
|
||||||
margin: 15px 0 10px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.annex-subsection-title {
|
|
||||||
font-size: 10pt;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #333;
|
|
||||||
margin: 12px 0 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.annex-enum-list {
|
|
||||||
margin: 10px 0;
|
|
||||||
padding-left: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.annex-enum-item {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
font-size: 10pt;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.annex-enum-name {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.annex-enum-description {
|
|
||||||
color: #000;
|
|
||||||
margin-left: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Prevent bad page breaks */
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
|
||||||
page-break-after: avoid;
|
|
||||||
page-break-inside: avoid;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media print {
|
|
||||||
body {
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="cover-page">
|
|
||||||
<div class="company-header">
|
|
||||||
{{- if .CompanyHorizontalLogoBase64}}
|
|
||||||
{{imgTag .CompanyHorizontalLogoBase64 "Company Logo" "company-logo"}}
|
|
||||||
{{- end}}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 class="export-title">Statement of Applicability</h1>
|
|
||||||
<h2 class="export-subtitle">{{.Title}}</h2>
|
|
||||||
|
|
||||||
<div class="document-meta">
|
|
||||||
<table class="meta-table">
|
|
||||||
<tr>
|
|
||||||
<td>Classification</td>
|
|
||||||
<td>
|
|
||||||
<span class="classification">CONFIDENTIAL</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Approver</td>
|
|
||||||
<td>{{.Approver}}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Version</td>
|
|
||||||
<td>{{.Version}}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Published</td>
|
|
||||||
<td>{{.PublishedAt.Format "January 2, 2006"}}</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="purpose-section">
|
|
||||||
<div class="purpose-title">1. Purpose</div>
|
|
||||||
<div class="purpose-text">
|
|
||||||
This document provides a comprehensive overview of the statement of applicability for controls within the organization.
|
|
||||||
It serves as a record of which controls are applicable or not applicable to the organization, along with their
|
|
||||||
relationships to regulatory requirements, contractual obligations, risk assessments, and best practices.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{- if .FrameworkGroups}}
|
|
||||||
<div class="controls-page">
|
|
||||||
<h1 class="controls-title">2. Controls</h1>
|
|
||||||
<table class="controls-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th rowspan="2" style="width: 12%;">Framework</th>
|
|
||||||
<th rowspan="2" style="width: 24%;">Control</th>
|
|
||||||
<th rowspan="2" style="width: 8%;">Applicability</th>
|
|
||||||
<th rowspan="2" style="width: 14%;">Justification for non-applicability</th>
|
|
||||||
<th rowspan="2" style="width: 8%;">Implemented</th>
|
|
||||||
<th rowspan="2" style="width: 10%;">Justification for non-implementation</th>
|
|
||||||
<th colspan="4" style="width: 24%; text-align: center;">Justification for inclusion</th>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th style="width: 6%;">Regulatory</th>
|
|
||||||
<th style="width: 6%;">Contractual</th>
|
|
||||||
<th style="width: 6%;">Best Practice</th>
|
|
||||||
<th style="width: 6%;">Risk Assessment</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{{- range $group := .FrameworkGroups}}
|
|
||||||
{{- range $group.Controls}}
|
|
||||||
<tr>
|
|
||||||
<td>{{$group.FrameworkName}}</td>
|
|
||||||
<td><span class="section-tag">{{.SectionTitle}}</span>{{.Name}}</td>
|
|
||||||
<td>
|
|
||||||
{{- $state := boolToYesNo .Applicability}}
|
|
||||||
{{- if eq $state "yes"}}
|
|
||||||
<span class="state-tag state-tag-success">Yes</span>
|
|
||||||
{{- else if eq $state "no"}}
|
|
||||||
<span class="state-tag state-tag-danger">No</span>
|
|
||||||
{{- else}}
|
|
||||||
<span class="state-tag">-</span>
|
|
||||||
{{- end}}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{{- $appStateJ := boolToYesNo .Applicability}}
|
|
||||||
{{- if and (eq $appStateJ "no") .Justification}}
|
|
||||||
{{.Justification}}
|
|
||||||
{{- else}}
|
|
||||||
-
|
|
||||||
{{- end}}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{{- $appState := boolToYesNo .Applicability}}
|
|
||||||
{{- if eq $appState "no"}}
|
|
||||||
<span class="state-tag">-</span>
|
|
||||||
{{- else if .Implemented}}
|
|
||||||
{{- if eq (derefString .Implemented) "IMPLEMENTED"}}
|
|
||||||
<span class="state-tag state-tag-success">Yes</span>
|
|
||||||
{{- else}}
|
|
||||||
<span class="state-tag state-tag-danger">No</span>
|
|
||||||
{{- end}}
|
|
||||||
{{- else}}
|
|
||||||
<span class="state-tag">-</span>
|
|
||||||
{{- end}}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{{- $appState2 := boolToYesNo .Applicability}}
|
|
||||||
{{- if eq $appState2 "no"}}
|
|
||||||
-
|
|
||||||
{{- else if and .Implemented (eq (derefString .Implemented) "NOT_IMPLEMENTED") .NotImplementedJustification}}
|
|
||||||
{{.NotImplementedJustification}}
|
|
||||||
{{- else}}
|
|
||||||
-
|
|
||||||
{{- end}}
|
|
||||||
</td>
|
|
||||||
<td>{{boolToYesNoDash .Regulatory}}</td>
|
|
||||||
<td>{{boolToYesNoDash .Contractual}}</td>
|
|
||||||
<td>{{boolToYesNoDash .BestPractice}}</td>
|
|
||||||
<td>{{boolToYesNoDash .RiskAssessment}}</td>
|
|
||||||
</tr>
|
|
||||||
{{- end}}
|
|
||||||
{{- end}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{{- end}}
|
|
||||||
|
|
||||||
<div class="annex-page">
|
|
||||||
<h1 class="annex-title">3. Annexes</h1>
|
|
||||||
|
|
||||||
<div class="annex-section">
|
|
||||||
<div class="annex-section-title">3.1 Column Definitions</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="annex-section">
|
|
||||||
<div class="annex-subsection-title">Framework</div>
|
|
||||||
<ul class="annex-enum-list">
|
|
||||||
<li class="annex-enum-item">
|
|
||||||
<span class="annex-enum-description">The name of the compliance framework or standard to which the control belongs (e.g., ISO 27001, SOC 2, GDPR).</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="annex-section">
|
|
||||||
<div class="annex-subsection-title">Control</div>
|
|
||||||
<ul class="annex-enum-list">
|
|
||||||
<li class="annex-enum-item">
|
|
||||||
<span class="annex-enum-description">The specific control identifier and name within the framework, including its section reference.</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="annex-section">
|
|
||||||
<div class="annex-subsection-title">Applicability</div>
|
|
||||||
<ul class="annex-enum-list">
|
|
||||||
<li class="annex-enum-item">
|
|
||||||
<span class="annex-enum-name">Yes:</span>
|
|
||||||
<span class="annex-enum-description">The control is applicable to the organization.</span>
|
|
||||||
</li>
|
|
||||||
<li class="annex-enum-item">
|
|
||||||
<span class="annex-enum-name">No:</span>
|
|
||||||
<span class="annex-enum-description">The control is not applicable to the organization (with justification provided).</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="annex-section">
|
|
||||||
<div class="annex-subsection-title">Justification for non-applicability</div>
|
|
||||||
<ul class="annex-enum-list">
|
|
||||||
<li class="annex-enum-item">
|
|
||||||
<span class="annex-enum-description">Provides the rationale when a control is not applicable. This field is empty for applicable controls.</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="annex-section">
|
|
||||||
<div class="annex-subsection-title">Implemented</div>
|
|
||||||
<ul class="annex-enum-list">
|
|
||||||
<li class="annex-enum-item">
|
|
||||||
<span class="annex-enum-name">Yes:</span>
|
|
||||||
<span class="annex-enum-description">The control has been implemented by the organization.</span>
|
|
||||||
</li>
|
|
||||||
<li class="annex-enum-item">
|
|
||||||
<span class="annex-enum-name">No:</span>
|
|
||||||
<span class="annex-enum-description">The control has not been implemented (with justification provided).</span>
|
|
||||||
</li>
|
|
||||||
<li class="annex-enum-item">
|
|
||||||
<span class="annex-enum-name">-:</span>
|
|
||||||
<span class="annex-enum-description">Not applicable (control is not applicable).</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="annex-section">
|
|
||||||
<div class="annex-subsection-title">Justification for non-implementation</div>
|
|
||||||
<ul class="annex-enum-list">
|
|
||||||
<li class="annex-enum-item">
|
|
||||||
<span class="annex-enum-description">Provides the rationale when a control is not implemented. This field is empty for implemented controls or when the control is not applicable.</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="annex-section">
|
|
||||||
<div class="annex-subsection-title">Justification for inclusion</div>
|
|
||||||
<div class="annex-enum-description" style="margin-bottom: 12px;">
|
|
||||||
For applicable controls, this section provides additional context on why the control is included, based on regulatory requirements, contractual obligations, best practices, or risk assessments.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="margin-left: 20px;">
|
|
||||||
<div class="annex-subsection-title" style="font-size: 9pt; margin-top: 10px;">Regulatory</div>
|
|
||||||
<ul class="annex-enum-list">
|
|
||||||
<li class="annex-enum-item">
|
|
||||||
<span class="annex-enum-name">Yes:</span>
|
|
||||||
<span class="annex-enum-description">The control is linked to one or more legal or regulatory obligations.</span>
|
|
||||||
</li>
|
|
||||||
<li class="annex-enum-item">
|
|
||||||
<span class="annex-enum-name">No:</span>
|
|
||||||
<span class="annex-enum-description">The control is not associated with any legal or regulatory obligations.</span>
|
|
||||||
</li>
|
|
||||||
<li class="annex-enum-item">
|
|
||||||
<span class="annex-enum-name">-:</span>
|
|
||||||
<span class="annex-enum-description">Not applicable (control is not applicable).</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div class="annex-subsection-title" style="font-size: 9pt; margin-top: 10px;">Contractual</div>
|
|
||||||
<ul class="annex-enum-list">
|
|
||||||
<li class="annex-enum-item">
|
|
||||||
<span class="annex-enum-name">Yes:</span>
|
|
||||||
<span class="annex-enum-description">The control is linked to one or more contractual obligations.</span>
|
|
||||||
</li>
|
|
||||||
<li class="annex-enum-item">
|
|
||||||
<span class="annex-enum-name">No:</span>
|
|
||||||
<span class="annex-enum-description">The control is not associated with any contractual obligations.</span>
|
|
||||||
</li>
|
|
||||||
<li class="annex-enum-item">
|
|
||||||
<span class="annex-enum-name">-:</span>
|
|
||||||
<span class="annex-enum-description">Not applicable (control is not applicable).</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div class="annex-subsection-title" style="font-size: 9pt; margin-top: 10px;">Best Practice</div>
|
|
||||||
<ul class="annex-enum-list">
|
|
||||||
<li class="annex-enum-item">
|
|
||||||
<span class="annex-enum-name">Yes:</span>
|
|
||||||
<span class="annex-enum-description">The control is designated as a best practice recommendation.</span>
|
|
||||||
</li>
|
|
||||||
<li class="annex-enum-item">
|
|
||||||
<span class="annex-enum-name">No:</span>
|
|
||||||
<span class="annex-enum-description">The control is not designated as a best practice.</span>
|
|
||||||
</li>
|
|
||||||
<li class="annex-enum-item">
|
|
||||||
<span class="annex-enum-name">-:</span>
|
|
||||||
<span class="annex-enum-description">Not applicable (control is not applicable).</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div class="annex-subsection-title" style="font-size: 9pt; margin-top: 10px;">Risk Assessment</div>
|
|
||||||
<ul class="annex-enum-list">
|
|
||||||
<li class="annex-enum-item">
|
|
||||||
<span class="annex-enum-name">Yes:</span>
|
|
||||||
<span class="annex-enum-description">The control is associated with one or more identified risks through risk mitigation measures.</span>
|
|
||||||
</li>
|
|
||||||
<li class="annex-enum-item">
|
|
||||||
<span class="annex-enum-name">No:</span>
|
|
||||||
<span class="annex-enum-description">The control is not currently associated with any identified risks.</span>
|
|
||||||
</li>
|
|
||||||
<li class="annex-enum-item">
|
|
||||||
<span class="annex-enum-name">-:</span>
|
|
||||||
<span class="annex-enum-description">Not applicable (control is not applicable).</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
<style>
|
<style>
|
||||||
/* A4 Page Setup for printing */
|
/* A4 Page Setup for printing */
|
||||||
@page {
|
@page {
|
||||||
size: A4;
|
size: A4{{if .Landscape}} landscape{{end}};
|
||||||
margin: 2.5cm;
|
margin: 2.5cm;
|
||||||
|
|
||||||
@bottom-right {
|
@bottom-right {
|
||||||
@@ -118,8 +118,16 @@
|
|||||||
widows: 3;
|
widows: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.document-content hr {
|
||||||
|
border: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 0;
|
||||||
|
page-break-after: always;
|
||||||
|
}
|
||||||
|
|
||||||
.document-content h1 {
|
.document-content h1 {
|
||||||
font-size: 14pt;
|
font-size: 18pt;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #000;
|
color: #000;
|
||||||
margin: 20px 0 12px 0;
|
margin: 20px 0 12px 0;
|
||||||
|
|||||||
@@ -338,12 +338,12 @@ const (
|
|||||||
ActionRightsRequestDelete = "core:rights-request:delete"
|
ActionRightsRequestDelete = "core:rights-request:delete"
|
||||||
|
|
||||||
// StatementOfApplicability actions
|
// StatementOfApplicability actions
|
||||||
ActionStatementOfApplicabilityList = "core:statement-of-applicability:list"
|
ActionStatementOfApplicabilityList = "core:statement-of-applicability:list"
|
||||||
ActionStatementOfApplicabilityGet = "core:statement-of-applicability:get"
|
ActionStatementOfApplicabilityGet = "core:statement-of-applicability:get"
|
||||||
ActionStatementOfApplicabilityCreate = "core:statement-of-applicability:create"
|
ActionStatementOfApplicabilityCreate = "core:statement-of-applicability:create"
|
||||||
ActionStatementOfApplicabilityUpdate = "core:statement-of-applicability:update"
|
ActionStatementOfApplicabilityUpdate = "core:statement-of-applicability:update"
|
||||||
ActionStatementOfApplicabilityDelete = "core:statement-of-applicability:delete"
|
ActionStatementOfApplicabilityDelete = "core:statement-of-applicability:delete"
|
||||||
ActionStatementOfApplicabilityExport = "core:statement-of-applicability:export"
|
ActionStatementOfApplicabilityPublish = "core:statement-of-applicability:publish"
|
||||||
|
|
||||||
ActionApplicabilityStatementGet = "core:applicability-statement:get"
|
ActionApplicabilityStatementGet = "core:applicability-statement:get"
|
||||||
ActionApplicabilityStatementList = "core:applicability-statement:list"
|
ActionApplicabilityStatementList = "core:applicability-statement:list"
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ func (s *DocumentApprovalService) RequestApproval(
|
|||||||
return &ErrDocumentVersionNotDraft{}
|
return &ErrDocumentVersionNotDraft{}
|
||||||
}
|
}
|
||||||
|
|
||||||
q, err := s.requestApprovalInTx(ctx, tx, document, documentVersion, req.ApproverIDs, req.Changelog)
|
q, err := s.RequestApprovalInTx(ctx, tx, document, documentVersion, req.ApproverIDs, req.Changelog)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -158,7 +158,7 @@ func (s *DocumentApprovalService) RequestApproval(
|
|||||||
return quorum, nil
|
return quorum, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *DocumentApprovalService) requestApprovalInTx(
|
func (s *DocumentApprovalService) RequestApprovalInTx(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
tx pg.Tx,
|
tx pg.Tx,
|
||||||
document *coredata.Document,
|
document *coredata.Document,
|
||||||
@@ -264,7 +264,7 @@ func (s *DocumentApprovalService) BulkPublishMajorVersions(
|
|||||||
approverIDs[i] = a.ApproverProfileID
|
approverIDs[i] = a.ApproverProfileID
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := s.requestApprovalInTx(ctx, tx, document, dv, approverIDs, &req.Changelog); err != nil {
|
if _, err := s.RequestApprovalInTx(ctx, tx, document, dv, approverIDs, &req.Changelog); err != nil {
|
||||||
return fmt.Errorf("cannot request approval for %q: %w", documentID, err)
|
return fmt.Errorf("cannot request approval for %q: %w", documentID, err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -77,6 +77,12 @@ type (
|
|||||||
ErrDocumentNotArchived struct {
|
ErrDocumentNotArchived struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ErrDocumentGenerated struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrDocumentVersionGenerated struct {
|
||||||
|
}
|
||||||
|
|
||||||
ErrDocumentVersionSignatureAlreadySigned struct {
|
ErrDocumentVersionSignatureAlreadySigned struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,6 +223,14 @@ func (e ErrDocumentNotArchived) Error() string {
|
|||||||
return "cannot unarchive a document that is not archived"
|
return "cannot unarchive a document that is not archived"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e ErrDocumentGenerated) Error() string {
|
||||||
|
return "cannot create draft for a generated document"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ErrDocumentVersionGenerated) Error() string {
|
||||||
|
return "cannot edit a generated document version"
|
||||||
|
}
|
||||||
|
|
||||||
func (e ErrDocumentVersionSignatureAlreadySigned) Error() string {
|
func (e ErrDocumentVersionSignatureAlreadySigned) Error() string {
|
||||||
return "document version signature already signed"
|
return "document version signature already signed"
|
||||||
}
|
}
|
||||||
@@ -587,6 +601,7 @@ func (s *DocumentService) Create(
|
|||||||
|
|
||||||
document := &coredata.Document{
|
document := &coredata.Document{
|
||||||
ID: documentID,
|
ID: documentID,
|
||||||
|
WriteMode: coredata.DocumentWriteModeAuthored,
|
||||||
TrustCenterVisibility: coredata.TrustCenterVisibilityNone,
|
TrustCenterVisibility: coredata.TrustCenterVisibilityNone,
|
||||||
Status: coredata.DocumentStatusActive,
|
Status: coredata.DocumentStatusActive,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
@@ -616,6 +631,7 @@ func (s *DocumentService) Create(
|
|||||||
Status: coredata.DocumentVersionStatusDraft,
|
Status: coredata.DocumentVersionStatusDraft,
|
||||||
Classification: req.Classification,
|
Classification: req.Classification,
|
||||||
DocumentType: req.DocumentType,
|
DocumentType: req.DocumentType,
|
||||||
|
Orientation: coredata.DocumentVersionOrientationPortrait,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
@@ -1100,6 +1116,7 @@ func (s *DocumentService) createDraftInTx(
|
|||||||
Classification: latestVersion.Classification,
|
Classification: latestVersion.Classification,
|
||||||
DocumentType: latestVersion.DocumentType,
|
DocumentType: latestVersion.DocumentType,
|
||||||
Content: latestVersion.Content,
|
Content: latestVersion.Content,
|
||||||
|
Orientation: latestVersion.Orientation,
|
||||||
Status: coredata.DocumentVersionStatusDraft,
|
Status: coredata.DocumentVersionStatusDraft,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
@@ -1677,6 +1694,10 @@ func (s *DocumentService) Update(
|
|||||||
|
|
||||||
hasVersionChanges := req.Title != nil || req.Content != nil || req.Classification != nil || req.DocumentType != nil
|
hasVersionChanges := req.Title != nil || req.Content != nil || req.Classification != nil || req.DocumentType != nil
|
||||||
|
|
||||||
|
if hasVersionChanges && document.WriteMode == coredata.DocumentWriteModeGenerated {
|
||||||
|
return &ErrDocumentVersionGenerated{}
|
||||||
|
}
|
||||||
|
|
||||||
if !hasVersionChanges {
|
if !hasVersionChanges {
|
||||||
if req.DefaultApproverIDs != nil {
|
if req.DefaultApproverIDs != nil {
|
||||||
defaultApprovers := &coredata.DocumentDefaultApprovers{}
|
defaultApprovers := &coredata.DocumentDefaultApprovers{}
|
||||||
@@ -2191,6 +2212,8 @@ func exportDocumentPDF(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isLandscape := version.Orientation == coredata.DocumentVersionOrientationLandscape
|
||||||
|
|
||||||
docData := docgen.DocumentData{
|
docData := docgen.DocumentData{
|
||||||
Title: version.Title,
|
Title: version.Title,
|
||||||
Content: json.RawMessage([]byte(version.Content)),
|
Content: json.RawMessage([]byte(version.Content)),
|
||||||
@@ -2201,6 +2224,7 @@ func exportDocumentPDF(
|
|||||||
PublishedAt: version.PublishedAt,
|
PublishedAt: version.PublishedAt,
|
||||||
Signatures: signatureData,
|
Signatures: signatureData,
|
||||||
CompanyHorizontalLogoBase64: horizontalLogoBase64,
|
CompanyHorizontalLogoBase64: horizontalLogoBase64,
|
||||||
|
Landscape: isLandscape,
|
||||||
}
|
}
|
||||||
|
|
||||||
htmlContent, err := docgen.RenderHTML(docData)
|
htmlContent, err := docgen.RenderHTML(docData)
|
||||||
@@ -2208,9 +2232,14 @@ func exportDocumentPDF(
|
|||||||
return nil, fmt.Errorf("cannot generate HTML: %w", err)
|
return nil, fmt.Errorf("cannot generate HTML: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
orientation := html2pdf.OrientationPortrait
|
||||||
|
if isLandscape {
|
||||||
|
orientation = html2pdf.OrientationLandscape
|
||||||
|
}
|
||||||
|
|
||||||
cfg := html2pdf.RenderConfig{
|
cfg := html2pdf.RenderConfig{
|
||||||
PageFormat: html2pdf.PageFormatA4,
|
PageFormat: html2pdf.PageFormatA4,
|
||||||
Orientation: html2pdf.OrientationPortrait,
|
Orientation: orientation,
|
||||||
MarginTop: html2pdf.NewMarginInches(1.0),
|
MarginTop: html2pdf.NewMarginInches(1.0),
|
||||||
MarginBottom: html2pdf.NewMarginInches(1.0),
|
MarginBottom: html2pdf.NewMarginInches(1.0),
|
||||||
MarginLeft: html2pdf.NewMarginInches(1.0),
|
MarginLeft: html2pdf.NewMarginInches(1.0),
|
||||||
|
|||||||
362
pkg/probo/generated_document_service.go
Normal file
362
pkg/probo/generated_document_service.go
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
// 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 probo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"text/template"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.gearno.de/kit/pg"
|
||||||
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
|
"go.probo.inc/probo/pkg/docgen"
|
||||||
|
"go.probo.inc/probo/pkg/gid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GeneratedDocumentService struct {
|
||||||
|
svc *TenantService
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GeneratedDocumentService) PublishStatementOfApplicability(
|
||||||
|
ctx context.Context,
|
||||||
|
statementOfApplicabilityID 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 {
|
||||||
|
soa := &coredata.StatementOfApplicability{}
|
||||||
|
if err := soa.LoadByID(ctx, tx, s.svc.scope, statementOfApplicabilityID); err != nil {
|
||||||
|
return fmt.Errorf("cannot load statement of applicability: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
documentData, err := s.buildStatementOfApplicabilityDocumentData(ctx, tx, soa)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot build document data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
prosemirrorJSON, err := BuildStatementOfApplicabilityDocument(documentData)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot build prosemirror document: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
var existingDoc *coredata.Document
|
||||||
|
if soa.DocumentID != nil {
|
||||||
|
doc := &coredata.Document{}
|
||||||
|
err = doc.LoadByID(ctx, tx, s.svc.scope, *soa.DocumentID)
|
||||||
|
if err != nil && !errors.Is(err, coredata.ErrResourceNotFound) {
|
||||||
|
return fmt.Errorf("cannot load statement of applicability document: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == nil && doc.ArchivedAt == nil {
|
||||||
|
existingDoc = doc
|
||||||
|
} else {
|
||||||
|
soa.DocumentID = nil
|
||||||
|
soa.UpdatedAt = now
|
||||||
|
if err := soa.Update(ctx, tx, s.svc.scope); 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: soa.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)
|
||||||
|
}
|
||||||
|
|
||||||
|
soa.DocumentID = &documentID
|
||||||
|
soa.UpdatedAt = now
|
||||||
|
if err := soa.Update(ctx, tx, s.svc.scope); err != nil {
|
||||||
|
return fmt.Errorf("cannot update document reference: %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: soa.OrganizationID,
|
||||||
|
DocumentID: document.ID,
|
||||||
|
Title: soa.Name,
|
||||||
|
Major: newMajor,
|
||||||
|
Minor: 0,
|
||||||
|
Content: prosemirrorJSON,
|
||||||
|
Status: versionStatus,
|
||||||
|
Classification: coredata.DocumentClassificationConfidential,
|
||||||
|
DocumentType: coredata.DocumentTypeStatementOfApplicability,
|
||||||
|
Orientation: coredata.DocumentVersionOrientationLandscape,
|
||||||
|
PublishedAt: publishedAt,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := documentVersion.Insert(ctx, tx, s.svc.scope); err != nil {
|
||||||
|
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
|
||||||
|
return fmt.Errorf("a version is pending approval, approve or reject it before publishing a new one: %w", err)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("cannot insert document version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasApprovers {
|
||||||
|
defaultApprovers := &coredata.DocumentDefaultApprovers{}
|
||||||
|
if err := defaultApprovers.MergeByDocumentID(ctx, tx, s.svc.scope, document.ID, soa.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) buildStatementOfApplicabilityDocumentData(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Querier,
|
||||||
|
statementOfApplicability *coredata.StatementOfApplicability,
|
||||||
|
) (docgen.StatementOfApplicabilityData, error) {
|
||||||
|
organization := &coredata.Organization{}
|
||||||
|
if err := organization.LoadByID(ctx, conn, s.svc.scope, statementOfApplicability.OrganizationID); err != nil {
|
||||||
|
return docgen.StatementOfApplicabilityData{}, fmt.Errorf("cannot load organization: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var applicabilityStatements coredata.ApplicabilityStatements
|
||||||
|
if err := applicabilityStatements.LoadAllByStatementOfApplicabilityID(ctx, conn, s.svc.scope, statementOfApplicability.ID); err != nil {
|
||||||
|
return docgen.StatementOfApplicabilityData{}, fmt.Errorf("cannot load applicability statements: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(applicabilityStatements) == 0 {
|
||||||
|
return docgen.StatementOfApplicabilityData{
|
||||||
|
Title: statementOfApplicability.Name,
|
||||||
|
OrganizationName: organization.Name,
|
||||||
|
CreatedAt: statementOfApplicability.CreatedAt,
|
||||||
|
TotalControls: 0,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
controlIDs := make([]gid.GID, len(applicabilityStatements))
|
||||||
|
for i, stmt := range applicabilityStatements {
|
||||||
|
controlIDs[i] = stmt.ControlID
|
||||||
|
}
|
||||||
|
|
||||||
|
var controls coredata.Controls
|
||||||
|
if err := controls.LoadByIDs(ctx, conn, s.svc.scope, controlIDs); err != nil {
|
||||||
|
return docgen.StatementOfApplicabilityData{}, fmt.Errorf("cannot load controls: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
controlMap := make(map[gid.GID]*coredata.Control, len(controls))
|
||||||
|
frameworkIDSet := make(map[gid.GID]struct{})
|
||||||
|
for _, c := range controls {
|
||||||
|
controlMap[c.ID] = c
|
||||||
|
frameworkIDSet[c.FrameworkID] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
frameworkIDs := make([]gid.GID, 0, len(frameworkIDSet))
|
||||||
|
for id := range frameworkIDSet {
|
||||||
|
frameworkIDs = append(frameworkIDs, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
var frameworks coredata.Frameworks
|
||||||
|
if err := frameworks.LoadByIDs(ctx, conn, s.svc.scope, frameworkIDs); err != nil {
|
||||||
|
return docgen.StatementOfApplicabilityData{}, fmt.Errorf("cannot load frameworks: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
frameworkMap := make(map[gid.GID]*coredata.Framework, len(frameworks))
|
||||||
|
for _, f := range frameworks {
|
||||||
|
frameworkMap[f.ID] = f
|
||||||
|
}
|
||||||
|
|
||||||
|
controlOblTypes, err := coredata.LoadObligationTypesByControlIDs(ctx, conn, s.svc.scope, controlIDs)
|
||||||
|
if err != nil {
|
||||||
|
return docgen.StatementOfApplicabilityData{}, fmt.Errorf("cannot load obligation types: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type obligationKey struct {
|
||||||
|
controlID gid.GID
|
||||||
|
oblType coredata.ObligationType
|
||||||
|
}
|
||||||
|
oblSet := make(map[obligationKey]struct{}, len(controlOblTypes))
|
||||||
|
for _, co := range controlOblTypes {
|
||||||
|
oblSet[obligationKey{co.ControlID, co.ObligationType}] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var controlsWithRisk coredata.ControlsWithRisk
|
||||||
|
if err := controlsWithRisk.LoadByControlIDs(ctx, conn, s.svc.scope, controlIDs); err != nil {
|
||||||
|
return docgen.StatementOfApplicabilityData{}, fmt.Errorf("cannot load controls with risks: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
riskSet := make(map[gid.GID]struct{}, len(controlsWithRisk))
|
||||||
|
for _, cwr := range controlsWithRisk {
|
||||||
|
riskSet[cwr.ControlID] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := make([]docgen.SOARow, 0, len(applicabilityStatements))
|
||||||
|
|
||||||
|
for _, stmt := range applicabilityStatements {
|
||||||
|
control := controlMap[stmt.ControlID]
|
||||||
|
if control == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
framework := frameworkMap[control.FrameworkID]
|
||||||
|
if framework == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
applicable := stmt.Applicability
|
||||||
|
|
||||||
|
justification := "-"
|
||||||
|
if !applicable && stmt.Justification != nil {
|
||||||
|
justification = *stmt.Justification
|
||||||
|
}
|
||||||
|
|
||||||
|
implemented := "-"
|
||||||
|
if applicable {
|
||||||
|
if control.Implemented == coredata.ControlImplementationStateImplemented {
|
||||||
|
implemented = "Yes"
|
||||||
|
} else {
|
||||||
|
implemented = "No"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notImplJustification := "-"
|
||||||
|
if applicable && control.Implemented != coredata.ControlImplementationStateImplemented && control.NotImplementedJustification != nil {
|
||||||
|
notImplJustification = *control.NotImplementedJustification
|
||||||
|
}
|
||||||
|
|
||||||
|
regulatory := "-"
|
||||||
|
contractual := "-"
|
||||||
|
bestPractice := "-"
|
||||||
|
riskAssessment := "-"
|
||||||
|
if applicable {
|
||||||
|
_, hasLegal := oblSet[obligationKey{stmt.ControlID, coredata.ObligationTypeLegal}]
|
||||||
|
regulatory = docgen.BoolLabel(hasLegal)
|
||||||
|
_, hasContractual := oblSet[obligationKey{stmt.ControlID, coredata.ObligationTypeContractual}]
|
||||||
|
contractual = docgen.BoolLabel(hasContractual)
|
||||||
|
bestPractice = docgen.BoolLabel(control.BestPractice)
|
||||||
|
_, hasRisk := riskSet[stmt.ControlID]
|
||||||
|
riskAssessment = docgen.BoolLabel(hasRisk)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows = append(rows, docgen.SOARow{
|
||||||
|
FrameworkName: framework.Name,
|
||||||
|
ControlSection: control.SectionTitle,
|
||||||
|
ControlName: control.Name,
|
||||||
|
Applicability: docgen.BoolLabel(applicable),
|
||||||
|
Justification: justification,
|
||||||
|
Implemented: implemented,
|
||||||
|
NotImplJustification: notImplJustification,
|
||||||
|
Regulatory: regulatory,
|
||||||
|
Contractual: contractual,
|
||||||
|
BestPractice: bestPractice,
|
||||||
|
RiskAssessment: riskAssessment,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return docgen.StatementOfApplicabilityData{
|
||||||
|
Title: statementOfApplicability.Name,
|
||||||
|
OrganizationName: organization.Name,
|
||||||
|
CreatedAt: statementOfApplicability.CreatedAt,
|
||||||
|
TotalControls: len(applicabilityStatements),
|
||||||
|
Rows: rows,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var soaTemplate = template.Must(
|
||||||
|
template.New("statement_of_applicability.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/statement_of_applicability.json.tmpl"),
|
||||||
|
)
|
||||||
|
|
||||||
|
func BuildStatementOfApplicabilityDocument(data docgen.StatementOfApplicabilityData) (string, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := soaTemplate.Execute(&buf, data); err != nil {
|
||||||
|
return "", fmt.Errorf("cannot execute soa template: %w", err)
|
||||||
|
}
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
@@ -170,8 +170,8 @@ var AuditorPolicy = policy.NewPolicy(
|
|||||||
).WithSID("employee-document-access").When(organizationCondition),
|
).WithSID("employee-document-access").When(organizationCondition),
|
||||||
|
|
||||||
policy.Allow(
|
policy.Allow(
|
||||||
ActionStatementOfApplicabilityExport,
|
ActionStatementOfApplicabilityPublish,
|
||||||
).WithSID("soa-export").When(organizationCondition),
|
).WithSID("soa-publish").When(organizationCondition),
|
||||||
).WithDescription("Read-only probo access for auditors (excludes internal/employee content)")
|
).WithDescription("Read-only probo access for auditors (excludes internal/employee content)")
|
||||||
|
|
||||||
// EmployeePolicy defines permissions for employee role.
|
// EmployeePolicy defines permissions for employee role.
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ type (
|
|||||||
DataProtectionImpactAssessments *DataProtectionImpactAssessmentService
|
DataProtectionImpactAssessments *DataProtectionImpactAssessmentService
|
||||||
TransferImpactAssessments *TransferImpactAssessmentService
|
TransferImpactAssessments *TransferImpactAssessmentService
|
||||||
StatementsOfApplicability *StatementOfApplicabilityService
|
StatementsOfApplicability *StatementOfApplicabilityService
|
||||||
|
GeneratedDocuments *GeneratedDocumentService
|
||||||
Files *FileService
|
Files *FileService
|
||||||
CustomDomains *CustomDomainService
|
CustomDomains *CustomDomainService
|
||||||
SlackMessages *slack.SlackMessageService
|
SlackMessages *slack.SlackMessageService
|
||||||
@@ -284,8 +285,10 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService {
|
|||||||
html2pdfConverter: s.html2pdfConverter,
|
html2pdfConverter: s.html2pdfConverter,
|
||||||
}
|
}
|
||||||
tenantService.StatementsOfApplicability = &StatementOfApplicabilityService{
|
tenantService.StatementsOfApplicability = &StatementOfApplicabilityService{
|
||||||
svc: tenantService,
|
svc: tenantService,
|
||||||
html2pdfConverter: s.html2pdfConverter,
|
}
|
||||||
|
tenantService.GeneratedDocuments = &GeneratedDocumentService{
|
||||||
|
svc: tenantService,
|
||||||
}
|
}
|
||||||
tenantService.Files = &FileService{svc: tenantService}
|
tenantService.Files = &FileService{svc: tenantService}
|
||||||
tenantService.CustomDomains = &CustomDomainService{
|
tenantService.CustomDomains = &CustomDomainService{
|
||||||
|
|||||||
@@ -17,34 +17,28 @@ package probo
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"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/gid"
|
"go.probo.inc/probo/pkg/gid"
|
||||||
"go.probo.inc/probo/pkg/html2pdf"
|
|
||||||
"go.probo.inc/probo/pkg/page"
|
"go.probo.inc/probo/pkg/page"
|
||||||
"go.probo.inc/probo/pkg/validator"
|
"go.probo.inc/probo/pkg/validator"
|
||||||
)
|
)
|
||||||
|
|
||||||
type StatementOfApplicabilityService struct {
|
type StatementOfApplicabilityService struct {
|
||||||
svc *TenantService
|
svc *TenantService
|
||||||
html2pdfConverter *html2pdf.Converter
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type (
|
type (
|
||||||
CreateStatementOfApplicabilityRequest struct {
|
CreateStatementOfApplicabilityRequest struct {
|
||||||
OrganizationID gid.GID
|
OrganizationID gid.GID
|
||||||
Name string
|
Name string
|
||||||
OwnerID gid.GID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
UpdateStatementOfApplicabilityRequest struct {
|
UpdateStatementOfApplicabilityRequest struct {
|
||||||
StatementOfApplicabilityID gid.GID
|
StatementOfApplicabilityID gid.GID
|
||||||
Name *string
|
Name *string
|
||||||
OwnerID *gid.GID
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -53,7 +47,6 @@ func (csr *CreateStatementOfApplicabilityRequest) Validate() error {
|
|||||||
|
|
||||||
v.Check(csr.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
|
v.Check(csr.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
|
||||||
v.Check(csr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
|
v.Check(csr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||||
v.Check(csr.OwnerID, "owner_id", validator.Required(), validator.GID(coredata.MembershipProfileEntityType))
|
|
||||||
|
|
||||||
return v.Error()
|
return v.Error()
|
||||||
}
|
}
|
||||||
@@ -63,7 +56,6 @@ func (usr *UpdateStatementOfApplicabilityRequest) Validate() error {
|
|||||||
|
|
||||||
v.Check(usr.StatementOfApplicabilityID, "statement_of_applicability_id", validator.Required(), validator.GID(coredata.StatementOfApplicabilityEntityType))
|
v.Check(usr.StatementOfApplicabilityID, "statement_of_applicability_id", validator.Required(), validator.GID(coredata.StatementOfApplicabilityEntityType))
|
||||||
v.Check(usr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
|
v.Check(usr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||||
v.Check(usr.OwnerID, "owner_id", validator.GID(coredata.MembershipProfileEntityType))
|
|
||||||
|
|
||||||
return v.Error()
|
return v.Error()
|
||||||
}
|
}
|
||||||
@@ -72,7 +64,6 @@ func (s StatementOfApplicabilityService) ListForOrganizationID(
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
organizationID gid.GID,
|
organizationID gid.GID,
|
||||||
cursor *page.Cursor[coredata.StatementOfApplicabilityOrderField],
|
cursor *page.Cursor[coredata.StatementOfApplicabilityOrderField],
|
||||||
filter *coredata.StatementOfApplicabilityFilter,
|
|
||||||
) (*page.Page[*coredata.StatementOfApplicability, coredata.StatementOfApplicabilityOrderField], error) {
|
) (*page.Page[*coredata.StatementOfApplicability, coredata.StatementOfApplicabilityOrderField], error) {
|
||||||
var statementsOfApplicability coredata.StatementsOfApplicability
|
var statementsOfApplicability coredata.StatementsOfApplicability
|
||||||
organization := &coredata.Organization{}
|
organization := &coredata.Organization{}
|
||||||
@@ -90,7 +81,6 @@ func (s StatementOfApplicabilityService) ListForOrganizationID(
|
|||||||
s.svc.scope,
|
s.svc.scope,
|
||||||
organization.ID,
|
organization.ID,
|
||||||
cursor,
|
cursor,
|
||||||
filter,
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot load statements_of_applicability: %w", err)
|
return fmt.Errorf("cannot load statements_of_applicability: %w", err)
|
||||||
@@ -110,7 +100,6 @@ func (s StatementOfApplicabilityService) ListForOrganizationID(
|
|||||||
func (s StatementOfApplicabilityService) CountForOrganizationID(
|
func (s StatementOfApplicabilityService) CountForOrganizationID(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
organizationID gid.GID,
|
organizationID gid.GID,
|
||||||
filter *coredata.StatementOfApplicabilityFilter,
|
|
||||||
) (int, error) {
|
) (int, error) {
|
||||||
var count int
|
var count int
|
||||||
|
|
||||||
@@ -118,7 +107,7 @@ func (s StatementOfApplicabilityService) CountForOrganizationID(
|
|||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Querier) (err error) {
|
func(ctx context.Context, conn pg.Querier) (err error) {
|
||||||
statementsOfApplicability := &coredata.StatementsOfApplicability{}
|
statementsOfApplicability := &coredata.StatementsOfApplicability{}
|
||||||
count, err = statementsOfApplicability.CountByOrganizationID(ctx, conn, s.svc.scope, organizationID, filter)
|
count, err = statementsOfApplicability.CountByOrganizationID(ctx, conn, s.svc.scope, organizationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot count statements_of_applicability: %w", err)
|
return fmt.Errorf("cannot count statements_of_applicability: %w", err)
|
||||||
}
|
}
|
||||||
@@ -180,7 +169,6 @@ func (s StatementOfApplicabilityService) Create(
|
|||||||
ID: statementOfApplicabilityID,
|
ID: statementOfApplicabilityID,
|
||||||
OrganizationID: organization.ID,
|
OrganizationID: organization.ID,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
OwnerID: req.OwnerID,
|
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
@@ -223,9 +211,6 @@ func (s StatementOfApplicabilityService) Update(
|
|||||||
if req.Name != nil {
|
if req.Name != nil {
|
||||||
statementOfApplicability.Name = *req.Name
|
statementOfApplicability.Name = *req.Name
|
||||||
}
|
}
|
||||||
if req.OwnerID != nil {
|
|
||||||
statementOfApplicability.OwnerID = *req.OwnerID
|
|
||||||
}
|
|
||||||
|
|
||||||
statementOfApplicability.UpdatedAt = time.Now()
|
statementOfApplicability.UpdatedAt = time.Now()
|
||||||
|
|
||||||
@@ -444,248 +429,3 @@ func (s StatementOfApplicabilityService) ListControlLinks(
|
|||||||
|
|
||||||
return page.NewPage(controls, cursor), nil
|
return page.NewPage(controls, cursor), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s StatementOfApplicabilityService) ExportPDF(
|
|
||||||
ctx context.Context,
|
|
||||||
statementOfApplicabilityID gid.GID,
|
|
||||||
) ([]byte, error) {
|
|
||||||
var documentData docgen.StatementOfApplicabilityData
|
|
||||||
|
|
||||||
err := s.svc.pg.WithConn(
|
|
||||||
ctx,
|
|
||||||
func(ctx context.Context, conn pg.Querier) error {
|
|
||||||
statementOfApplicability := &coredata.StatementOfApplicability{}
|
|
||||||
if err := statementOfApplicability.LoadByID(ctx, conn, s.svc.scope, statementOfApplicabilityID); err != nil {
|
|
||||||
return fmt.Errorf("cannot load statement of applicability: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
organization := &coredata.Organization{}
|
|
||||||
if err := organization.LoadByID(ctx, conn, s.svc.scope, statementOfApplicability.OrganizationID); err != nil {
|
|
||||||
return fmt.Errorf("cannot load organization: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
owner := &coredata.MembershipProfile{}
|
|
||||||
if err := owner.LoadByID(ctx, conn, s.svc.scope, statementOfApplicability.OwnerID); err != nil {
|
|
||||||
return fmt.Errorf("cannot load owner profile: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load applicability statements
|
|
||||||
var applicabilityStatements coredata.ApplicabilityStatements
|
|
||||||
cursor := page.NewCursor(
|
|
||||||
10000,
|
|
||||||
nil,
|
|
||||||
page.Head,
|
|
||||||
page.OrderBy[coredata.ApplicabilityStatementOrderField]{
|
|
||||||
Field: coredata.ApplicabilityStatementOrderFieldControlSectionTitle,
|
|
||||||
Direction: page.OrderDirectionAsc,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if err := applicabilityStatements.LoadByStatementOfApplicabilityID(ctx, conn, s.svc.scope, statementOfApplicabilityID, cursor); err != nil {
|
|
||||||
return fmt.Errorf("cannot load applicability statements: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(applicabilityStatements) == 0 {
|
|
||||||
// No linked controls, skip loading additional data
|
|
||||||
documentData = docgen.StatementOfApplicabilityData{
|
|
||||||
Title: statementOfApplicability.Name,
|
|
||||||
OrganizationName: organization.Name,
|
|
||||||
CreatedAt: statementOfApplicability.CreatedAt,
|
|
||||||
TotalControls: 0,
|
|
||||||
FrameworkGroups: []docgen.FrameworkControlGroup{},
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
frameworkControlsMap := make(map[string][]docgen.ControlData)
|
|
||||||
frameworkOrder := []string{}
|
|
||||||
|
|
||||||
for _, stmt := range applicabilityStatements {
|
|
||||||
// Load control
|
|
||||||
control := &coredata.Control{}
|
|
||||||
if err := control.LoadByID(ctx, conn, s.svc.scope, stmt.ControlID); err != nil {
|
|
||||||
return fmt.Errorf("cannot load control: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load framework
|
|
||||||
framework := &coredata.Framework{}
|
|
||||||
if err := framework.LoadByID(ctx, conn, s.svc.scope, control.FrameworkID); err != nil {
|
|
||||||
return fmt.Errorf("cannot load framework: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count legal obligations
|
|
||||||
var controlObligations coredata.ControlObligations
|
|
||||||
legalType := coredata.ObligationTypeLegal
|
|
||||||
legalFilter := coredata.NewControlObligationFilter(&legalType)
|
|
||||||
legalCount, err := controlObligations.CountByControlID(ctx, conn, s.svc.scope, stmt.ControlID, legalFilter)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("cannot count legal obligations: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count contractual obligations
|
|
||||||
contractualType := coredata.ObligationTypeContractual
|
|
||||||
contractualFilter := coredata.NewControlObligationFilter(&contractualType)
|
|
||||||
contractualCount, err := controlObligations.CountByControlID(ctx, conn, s.svc.scope, stmt.ControlID, contractualFilter)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("cannot count contractual obligations: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if control has risk
|
|
||||||
var controlsWithRisk coredata.ControlsWithRisk
|
|
||||||
if err := controlsWithRisk.LoadByControlIDs(ctx, conn, s.svc.scope, []gid.GID{stmt.ControlID}); err != nil {
|
|
||||||
return fmt.Errorf("cannot load controls with risks: %w", err)
|
|
||||||
}
|
|
||||||
hasRisk := len(controlsWithRisk) > 0
|
|
||||||
|
|
||||||
if _, exists := frameworkControlsMap[framework.Name]; !exists {
|
|
||||||
frameworkOrder = append(frameworkOrder, framework.Name)
|
|
||||||
frameworkControlsMap[framework.Name] = []docgen.ControlData{}
|
|
||||||
}
|
|
||||||
|
|
||||||
var regulatory *bool
|
|
||||||
var contractual *bool
|
|
||||||
var bestPractice *bool
|
|
||||||
var riskAssessment *bool
|
|
||||||
|
|
||||||
if stmt.Applicability {
|
|
||||||
falseVal := false
|
|
||||||
trueVal := true
|
|
||||||
|
|
||||||
regulatory = &falseVal
|
|
||||||
contractual = &falseVal
|
|
||||||
riskAssessment = &falseVal
|
|
||||||
|
|
||||||
if legalCount > 0 {
|
|
||||||
regulatory = &trueVal
|
|
||||||
}
|
|
||||||
if contractualCount > 0 {
|
|
||||||
contractual = &trueVal
|
|
||||||
}
|
|
||||||
if hasRisk {
|
|
||||||
riskAssessment = &trueVal
|
|
||||||
}
|
|
||||||
|
|
||||||
bestPractice = &control.BestPractice
|
|
||||||
}
|
|
||||||
|
|
||||||
applicability := stmt.Applicability
|
|
||||||
|
|
||||||
implemented := control.Implemented.String()
|
|
||||||
frameworkControlsMap[framework.Name] = append(
|
|
||||||
frameworkControlsMap[framework.Name],
|
|
||||||
docgen.ControlData{
|
|
||||||
FrameworkName: framework.Name,
|
|
||||||
SectionTitle: control.SectionTitle,
|
|
||||||
Name: control.Name,
|
|
||||||
Applicability: &applicability,
|
|
||||||
Justification: stmt.Justification,
|
|
||||||
BestPractice: bestPractice,
|
|
||||||
Implemented: &implemented,
|
|
||||||
NotImplementedJustification: func() *string {
|
|
||||||
if control.Implemented == coredata.ControlImplementationStateImplemented {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return control.NotImplementedJustification
|
|
||||||
}(),
|
|
||||||
Regulatory: regulatory,
|
|
||||||
Contractual: contractual,
|
|
||||||
RiskAssessment: riskAssessment,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
frameworkGroups := make([]docgen.FrameworkControlGroup, len(frameworkOrder))
|
|
||||||
for i, frameworkName := range frameworkOrder {
|
|
||||||
frameworkGroups[i] = docgen.FrameworkControlGroup{
|
|
||||||
FrameworkName: frameworkName,
|
|
||||||
Controls: frameworkControlsMap[frameworkName],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var snapshots coredata.Snapshots
|
|
||||||
snapshotType := coredata.SnapshotsTypeStatementsOfApplicability
|
|
||||||
|
|
||||||
var version int
|
|
||||||
var publishedAt time.Time
|
|
||||||
|
|
||||||
if statementOfApplicability.SnapshotID != nil {
|
|
||||||
snapshot := &coredata.Snapshot{}
|
|
||||||
if err := snapshot.LoadByID(ctx, conn, s.svc.scope, *statementOfApplicability.SnapshotID); err != nil {
|
|
||||||
return fmt.Errorf("cannot load snapshot: %w", err)
|
|
||||||
}
|
|
||||||
publishedAt = snapshot.CreatedAt
|
|
||||||
snapshotFilter := coredata.NewSnapshotFilter(&snapshotType).WithBeforeDate(&snapshot.CreatedAt)
|
|
||||||
snapshotCount, err := snapshots.CountByOrganizationID(ctx, conn, s.svc.scope, statementOfApplicability.OrganizationID, snapshotFilter)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("cannot count states of applicability snapshots: %w", err)
|
|
||||||
}
|
|
||||||
version = snapshotCount
|
|
||||||
} else {
|
|
||||||
publishedAt = time.Now()
|
|
||||||
snapshotFilter := coredata.NewSnapshotFilter(&snapshotType)
|
|
||||||
snapshotCount, err := snapshots.CountByOrganizationID(ctx, conn, s.svc.scope, statementOfApplicability.OrganizationID, snapshotFilter)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("cannot count states of applicability snapshots: %w", err)
|
|
||||||
}
|
|
||||||
version = snapshotCount + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
horizontalLogoBase64 := ""
|
|
||||||
if organization.HorizontalLogoFileID != nil {
|
|
||||||
fileRecord := &coredata.File{}
|
|
||||||
fileErr := fileRecord.LoadByID(ctx, conn, s.svc.scope, *organization.HorizontalLogoFileID)
|
|
||||||
if fileErr == nil {
|
|
||||||
base64Data, mimeType, logoErr := s.svc.fileManager.GetFileBase64(ctx, fileRecord)
|
|
||||||
if logoErr == nil {
|
|
||||||
horizontalLogoBase64 = fmt.Sprintf("data:%s;base64,%s", mimeType, base64Data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
documentData = docgen.StatementOfApplicabilityData{
|
|
||||||
Title: statementOfApplicability.Name,
|
|
||||||
OrganizationName: organization.Name,
|
|
||||||
CreatedAt: statementOfApplicability.CreatedAt,
|
|
||||||
TotalControls: len(applicabilityStatements),
|
|
||||||
FrameworkGroups: frameworkGroups,
|
|
||||||
CompanyHorizontalLogoBase64: horizontalLogoBase64,
|
|
||||||
Version: version,
|
|
||||||
PublishedAt: publishedAt,
|
|
||||||
Approver: owner.FullName,
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
htmlData, err := docgen.RenderStatementOfApplicabilityHTML(documentData)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("cannot render HTML: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := html2pdf.RenderConfig{
|
|
||||||
PageFormat: html2pdf.PageFormatA4,
|
|
||||||
Orientation: html2pdf.OrientationPortrait,
|
|
||||||
MarginTop: html2pdf.NewMarginInches(1.0),
|
|
||||||
MarginBottom: html2pdf.NewMarginInches(1.0),
|
|
||||||
MarginLeft: html2pdf.NewMarginInches(1.0),
|
|
||||||
MarginRight: html2pdf.NewMarginInches(1.0),
|
|
||||||
PrintBackground: true,
|
|
||||||
Scale: 1.0,
|
|
||||||
}
|
|
||||||
|
|
||||||
pdfReader, err := s.html2pdfConverter.GeneratePDF(ctx, htmlData, cfg)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("cannot generate PDF: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
pdfData, err := io.ReadAll(pdfReader)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("cannot read PDF data: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return pdfData, nil
|
|
||||||
}
|
|
||||||
|
|||||||
189
pkg/probo/templates/statement_of_applicability.json.tmpl
Normal file
189
pkg/probo/templates/statement_of_applicability.json.tmpl
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
{
|
||||||
|
"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 overview of the statement of applicability for controls within the organization. It serves as a record of which controls are applicable or not applicable to the organization, along with their relationships to regulatory requirements, contractual obligations, risk assessments, and best practices." }]
|
||||||
|
},
|
||||||
|
{ "type": "horizontalRule" },
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"attrs": { "level": 1 },
|
||||||
|
"content": [{ "type": "text", "text": "2. Controls" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "table",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "tableRow",
|
||||||
|
"content": [
|
||||||
|
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 2, "colwidth": [120] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Framework", "marks": [{ "type": "bold" }] }] }] },
|
||||||
|
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 2, "colwidth": [250] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Control", "marks": [{ "type": "bold" }] }] }] },
|
||||||
|
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 2, "colwidth": [70] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Applicability", "marks": [{ "type": "bold" }] }] }] },
|
||||||
|
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 2, "colwidth": [130] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Justification for non-applicability", "marks": [{ "type": "bold" }] }] }] },
|
||||||
|
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 2, "colwidth": [70] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Implemented", "marks": [{ "type": "bold" }] }] }] },
|
||||||
|
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 2, "colwidth": [110] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Justification for non-implementation", "marks": [{ "type": "bold" }] }] }] },
|
||||||
|
{ "type": "tableHeader", "attrs": { "colspan": 4, "rowspan": 1 }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Justification for inclusion", "marks": [{ "type": "bold" }] }] }] }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tableRow",
|
||||||
|
"content": [
|
||||||
|
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [60] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Regulatory", "marks": [{ "type": "bold" }] }] }] },
|
||||||
|
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [60] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Contractual", "marks": [{ "type": "bold" }] }] }] },
|
||||||
|
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [60] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Best Practice", "marks": [{ "type": "bold" }] }] }] },
|
||||||
|
{ "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [60] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Risk Assessment", "marks": [{ "type": "bold" }] }] }] }
|
||||||
|
]
|
||||||
|
}{{range .Rows}},
|
||||||
|
{
|
||||||
|
"type": "tableRow",
|
||||||
|
"content": [
|
||||||
|
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [120] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .FrameworkName}} }] }] },
|
||||||
|
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [250] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json (printf "[%s] " .ControlSection)}}, "marks": [{ "type": "code" }] }, { "type": "text", "text": {{json .ControlName}} }] }] },
|
||||||
|
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [70] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .Applicability}} }] }] },
|
||||||
|
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [130] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .Justification}} }] }] },
|
||||||
|
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [70] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .Implemented}} }] }] },
|
||||||
|
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [110] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .NotImplJustification}} }] }] },
|
||||||
|
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [60] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .Regulatory}} }] }] },
|
||||||
|
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [60] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .Contractual}} }] }] },
|
||||||
|
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [60] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .BestPractice}} }] }] },
|
||||||
|
{ "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1, "colwidth": [60] }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": {{json .RiskAssessment}} }] }] }
|
||||||
|
]
|
||||||
|
}{{end}}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ "type": "horizontalRule" },
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"attrs": { "level": 1 },
|
||||||
|
"content": [{ "type": "text", "text": "3. Definitions" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"attrs": { "level": 3 },
|
||||||
|
"content": [{ "type": "text", "text": "Framework" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "paragraph",
|
||||||
|
"content": [{ "type": "text", "text": "The name of the compliance framework or standard to which the control belongs (e.g., ISO 27001, SOC 2, GDPR)." }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"attrs": { "level": 3 },
|
||||||
|
"content": [{ "type": "text", "text": "Control" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "paragraph",
|
||||||
|
"content": [{ "type": "text", "text": "The specific control identifier and name within the framework, including its section reference." }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"attrs": { "level": 3 },
|
||||||
|
"content": [{ "type": "text", "text": "Applicability" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bulletList",
|
||||||
|
"content": [
|
||||||
|
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Yes: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The control is applicable to the organization." }] }] },
|
||||||
|
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "No: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The control is not applicable to the organization (with justification provided)." }] }] }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"attrs": { "level": 3 },
|
||||||
|
"content": [{ "type": "text", "text": "Justification for non-applicability" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "paragraph",
|
||||||
|
"content": [{ "type": "text", "text": "Provides the rationale when a control is not applicable. This field is empty for applicable controls." }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"attrs": { "level": 3 },
|
||||||
|
"content": [{ "type": "text", "text": "Implemented" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bulletList",
|
||||||
|
"content": [
|
||||||
|
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Yes: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The control has been implemented by the organization." }] }] },
|
||||||
|
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "No: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The control has not been implemented (with justification provided)." }] }] },
|
||||||
|
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "-: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Not applicable (control is not applicable)." }] }] }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"attrs": { "level": 3 },
|
||||||
|
"content": [{ "type": "text", "text": "Justification for non-implementation" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "paragraph",
|
||||||
|
"content": [{ "type": "text", "text": "Provides the rationale when a control is not implemented. This field is empty for implemented controls or when the control is not applicable." }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"attrs": { "level": 3 },
|
||||||
|
"content": [{ "type": "text", "text": "Justification for inclusion" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "paragraph",
|
||||||
|
"content": [{ "type": "text", "text": "For applicable controls, this section provides additional context on why the control is included, based on regulatory requirements, contractual obligations, best practices, or risk assessments." }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"attrs": { "level": 4 },
|
||||||
|
"content": [{ "type": "text", "text": "Regulatory" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bulletList",
|
||||||
|
"content": [
|
||||||
|
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Yes: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The control is linked to one or more legal or regulatory obligations." }] }] },
|
||||||
|
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "No: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The control is not associated with any legal or regulatory obligations." }] }] },
|
||||||
|
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "-: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Not applicable (control is not applicable)." }] }] }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"attrs": { "level": 4 },
|
||||||
|
"content": [{ "type": "text", "text": "Contractual" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bulletList",
|
||||||
|
"content": [
|
||||||
|
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Yes: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The control is linked to one or more contractual obligations." }] }] },
|
||||||
|
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "No: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The control is not associated with any contractual obligations." }] }] },
|
||||||
|
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "-: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Not applicable (control is not applicable)." }] }] }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"attrs": { "level": 4 },
|
||||||
|
"content": [{ "type": "text", "text": "Best Practice" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bulletList",
|
||||||
|
"content": [
|
||||||
|
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Yes: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The control is designated as a best practice recommendation." }] }] },
|
||||||
|
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "No: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The control is not designated as a best practice." }] }] },
|
||||||
|
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "-: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Not applicable (control is not applicable)." }] }] }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"attrs": { "level": 4 },
|
||||||
|
"content": [{ "type": "text", "text": "Risk Assessment" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bulletList",
|
||||||
|
"content": [
|
||||||
|
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Yes: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The control is associated with one or more identified risks through risk mitigation measures." }] }] },
|
||||||
|
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "No: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "The control is not currently associated with any identified risks." }] }] },
|
||||||
|
{ "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "-: ", "marks": [{ "type": "bold" }] }, { "type": "text", "text": "Not applicable (control is not applicable)." }] }] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ package console_v1
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
@@ -15,7 +14,6 @@ import (
|
|||||||
"go.gearno.de/kit/log"
|
"go.gearno.de/kit/log"
|
||||||
"go.probo.inc/probo/pkg/coredata"
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
"go.probo.inc/probo/pkg/gid"
|
"go.probo.inc/probo/pkg/gid"
|
||||||
"go.probo.inc/probo/pkg/iam"
|
|
||||||
"go.probo.inc/probo/pkg/page"
|
"go.probo.inc/probo/pkg/page"
|
||||||
"go.probo.inc/probo/pkg/probo"
|
"go.probo.inc/probo/pkg/probo"
|
||||||
"go.probo.inc/probo/pkg/server/api/console/v1/dataloader"
|
"go.probo.inc/probo/pkg/server/api/console/v1/dataloader"
|
||||||
@@ -229,6 +227,7 @@ func (r *controlResolver) Documents(ctx context.Context, obj *types.Control, fir
|
|||||||
var documentFilter = coredata.NewDocumentFilter(nil)
|
var documentFilter = coredata.NewDocumentFilter(nil)
|
||||||
if filter != nil {
|
if filter != nil {
|
||||||
documentFilter = coredata.NewDocumentFilter(filter.Query).
|
documentFilter = coredata.NewDocumentFilter(filter.Query).
|
||||||
|
WithWriteModes(filter.WriteModes).
|
||||||
WithDocumentTypes(filter.DocumentTypes).
|
WithDocumentTypes(filter.DocumentTypes).
|
||||||
WithClassifications(filter.Classifications)
|
WithClassifications(filter.Classifications)
|
||||||
}
|
}
|
||||||
@@ -768,7 +767,6 @@ func (r *mutationResolver) CreateStatementOfApplicability(ctx context.Context, i
|
|||||||
probo.CreateStatementOfApplicabilityRequest{
|
probo.CreateStatementOfApplicabilityRequest{
|
||||||
OrganizationID: input.OrganizationID,
|
OrganizationID: input.OrganizationID,
|
||||||
Name: input.Name,
|
Name: input.Name,
|
||||||
OwnerID: input.OwnerID,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -805,7 +803,6 @@ func (r *mutationResolver) UpdateStatementOfApplicability(ctx context.Context, i
|
|||||||
probo.UpdateStatementOfApplicabilityRequest{
|
probo.UpdateStatementOfApplicabilityRequest{
|
||||||
StatementOfApplicabilityID: input.ID,
|
StatementOfApplicabilityID: input.ID,
|
||||||
Name: name,
|
Name: name,
|
||||||
OwnerID: input.OwnerID,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -843,28 +840,53 @@ func (r *mutationResolver) DeleteStatementOfApplicability(ctx context.Context, i
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExportStatementOfApplicabilityPDF is the resolver for the exportStatementOfApplicabilityPDF field.
|
// PublishStatementOfApplicability is the resolver for the publishStatementOfApplicability field.
|
||||||
func (r *mutationResolver) ExportStatementOfApplicabilityPDF(ctx context.Context, input types.ExportStatementOfApplicabilityPDFInput) (*types.ExportStatementOfApplicabilityPDFPayload, error) {
|
func (r *mutationResolver) PublishStatementOfApplicability(ctx context.Context, input types.PublishStatementOfApplicabilityInput) (*types.PublishStatementOfApplicabilityPayload, error) {
|
||||||
if err := r.authorize(ctx, input.StatementOfApplicabilityID, probo.ActionStatementOfApplicabilityExport); err != nil {
|
if err := r.authorize(ctx, input.StatementOfApplicabilityID, probo.ActionStatementOfApplicabilityPublish); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
prb := r.ProboService(ctx, input.StatementOfApplicabilityID.TenantID())
|
prb := r.ProboService(ctx, input.StatementOfApplicabilityID.TenantID())
|
||||||
|
|
||||||
pdfData, err := prb.StatementsOfApplicability.ExportPDF(ctx, input.StatementOfApplicabilityID)
|
document, documentVersion, err := prb.GeneratedDocuments.PublishStatementOfApplicability(ctx, input.StatementOfApplicabilityID, input.ApproverIds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.logger.ErrorCtx(ctx, "cannot export statement of applicability PDF", log.Error(err))
|
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
|
||||||
|
return nil, gqlutils.Conflict(ctx, err)
|
||||||
|
}
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot publish statement of applicability", log.Error(err))
|
||||||
return nil, gqlutils.Internal(ctx)
|
return nil, gqlutils.Internal(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
base64Data := base64.StdEncoding.EncodeToString(pdfData)
|
return &types.PublishStatementOfApplicabilityPayload{
|
||||||
dataURI := fmt.Sprintf("data:application/pdf;base64,%s", base64Data)
|
DocumentEdge: types.NewDocumentEdge(document, coredata.DocumentOrderFieldCreatedAt),
|
||||||
|
DocumentVersionEdge: types.NewDocumentVersionEdge(documentVersion, coredata.DocumentVersionOrderFieldCreatedAt),
|
||||||
return &types.ExportStatementOfApplicabilityPDFPayload{
|
|
||||||
Data: dataURI,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Document is the resolver for the document field.
|
||||||
|
func (r *statementOfApplicabilityResolver) Document(ctx context.Context, obj *types.StatementOfApplicability) (*types.Document, error) {
|
||||||
|
if obj.Document == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.authorize(ctx, obj.Document.ID, probo.ActionDocumentGet); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
prb := r.ProboService(ctx, obj.Document.ID.TenantID())
|
||||||
|
|
||||||
|
document, err := prb.Documents.Get(ctx, obj.Document.ID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, coredata.ErrResourceNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot load document", log.Error(err))
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return types.NewDocument(document), nil
|
||||||
|
}
|
||||||
|
|
||||||
// Organization is the resolver for the organization field.
|
// Organization is the resolver for the organization field.
|
||||||
func (r *statementOfApplicabilityResolver) Organization(ctx context.Context, obj *types.StatementOfApplicability) (*types.Organization, error) {
|
func (r *statementOfApplicabilityResolver) Organization(ctx context.Context, obj *types.StatementOfApplicability) (*types.Organization, error) {
|
||||||
if err := r.authorize(ctx, obj.ID, probo.ActionOrganizationGet); err != nil {
|
if err := r.authorize(ctx, obj.ID, probo.ActionOrganizationGet); err != nil {
|
||||||
@@ -885,26 +907,6 @@ func (r *statementOfApplicabilityResolver) Organization(ctx context.Context, obj
|
|||||||
return types.NewOrganization(organization), nil
|
return types.NewOrganization(organization), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Owner is the resolver for the owner field.
|
|
||||||
func (r *statementOfApplicabilityResolver) Owner(ctx context.Context, obj *types.StatementOfApplicability) (*types.Profile, error) {
|
|
||||||
if err := r.authorize(ctx, obj.ID, iam.ActionMembershipProfileGet); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
loaders := dataloader.FromContext(ctx)
|
|
||||||
|
|
||||||
owner, err := loaders.Profile.Load(ctx, obj.Owner.ID)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, coredata.ErrResourceNotFound) || errors.Is(err, dataloadgen.ErrNotFound) {
|
|
||||||
return nil, gqlutils.NotFound(ctx, err)
|
|
||||||
}
|
|
||||||
r.logger.ErrorCtx(ctx, "cannot load owner", log.Error(err))
|
|
||||||
return nil, gqlutils.Internal(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
return types.NewProfile(owner), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ApplicabilityStatements is the resolver for the applicabilityStatements field.
|
// ApplicabilityStatements is the resolver for the applicabilityStatements field.
|
||||||
func (r *statementOfApplicabilityResolver) ApplicabilityStatements(ctx context.Context, obj *types.StatementOfApplicability, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ApplicabilityStatementOrderBy) (*types.ApplicabilityStatementConnection, error) {
|
func (r *statementOfApplicabilityResolver) ApplicabilityStatements(ctx context.Context, obj *types.StatementOfApplicability, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ApplicabilityStatementOrderBy) (*types.ApplicabilityStatementConnection, error) {
|
||||||
if err := r.authorize(ctx, obj.ID, probo.ActionApplicabilityStatementList); err != nil {
|
if err := r.authorize(ctx, obj.ID, probo.ActionApplicabilityStatementList); err != nil {
|
||||||
@@ -946,7 +948,7 @@ func (r *statementOfApplicabilityConnectionResolver) TotalCount(ctx context.Cont
|
|||||||
|
|
||||||
switch obj.Resolver.(type) {
|
switch obj.Resolver.(type) {
|
||||||
case *organizationResolver:
|
case *organizationResolver:
|
||||||
count, err := prb.StatementsOfApplicability.CountForOrganizationID(ctx, obj.ParentID, obj.Filters)
|
count, err := prb.StatementsOfApplicability.CountForOrganizationID(ctx, obj.ParentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.logger.ErrorCtx(ctx, "cannot count statements_of_applicability", log.Error(err))
|
r.logger.ErrorCtx(ctx, "cannot count statements_of_applicability", log.Error(err))
|
||||||
return 0, gqlutils.Internal(ctx)
|
return 0, gqlutils.Internal(ctx)
|
||||||
|
|||||||
@@ -78,10 +78,6 @@ input ControlFilter {
|
|||||||
query: String
|
query: String
|
||||||
}
|
}
|
||||||
|
|
||||||
input StatementOfApplicabilityFilter {
|
|
||||||
snapshotId: ID
|
|
||||||
}
|
|
||||||
|
|
||||||
type Control implements Node {
|
type Control implements Node {
|
||||||
id: ID!
|
id: ID!
|
||||||
organization: Organization @goField(forceResolver: true)
|
organization: Organization @goField(forceResolver: true)
|
||||||
@@ -163,10 +159,8 @@ type ControlEdge {
|
|||||||
type StatementOfApplicability implements Node {
|
type StatementOfApplicability implements Node {
|
||||||
id: ID!
|
id: ID!
|
||||||
name: String!
|
name: String!
|
||||||
sourceId: ID
|
document: Document @goField(forceResolver: true)
|
||||||
snapshotId: ID
|
|
||||||
organization: Organization @goField(forceResolver: true)
|
organization: Organization @goField(forceResolver: true)
|
||||||
owner: Profile! @goField(forceResolver: true)
|
|
||||||
createdAt: Datetime!
|
createdAt: Datetime!
|
||||||
updatedAt: Datetime!
|
updatedAt: Datetime!
|
||||||
|
|
||||||
@@ -273,9 +267,9 @@ extend type Mutation {
|
|||||||
deleteStatementOfApplicability(
|
deleteStatementOfApplicability(
|
||||||
input: DeleteStatementOfApplicabilityInput!
|
input: DeleteStatementOfApplicabilityInput!
|
||||||
): DeleteStatementOfApplicabilityPayload!
|
): DeleteStatementOfApplicabilityPayload!
|
||||||
exportStatementOfApplicabilityPDF(
|
publishStatementOfApplicability(
|
||||||
input: ExportStatementOfApplicabilityPDFInput!
|
input: PublishStatementOfApplicabilityInput!
|
||||||
): ExportStatementOfApplicabilityPDFPayload!
|
): PublishStatementOfApplicabilityPayload!
|
||||||
}
|
}
|
||||||
|
|
||||||
input CreateControlInput {
|
input CreateControlInput {
|
||||||
@@ -372,13 +366,11 @@ input DeleteControlSnapshotMappingInput {
|
|||||||
input CreateStatementOfApplicabilityInput {
|
input CreateStatementOfApplicabilityInput {
|
||||||
organizationId: ID!
|
organizationId: ID!
|
||||||
name: String!
|
name: String!
|
||||||
ownerId: ID!
|
|
||||||
}
|
}
|
||||||
|
|
||||||
input UpdateStatementOfApplicabilityInput {
|
input UpdateStatementOfApplicabilityInput {
|
||||||
id: ID!
|
id: ID!
|
||||||
name: String
|
name: String
|
||||||
ownerId: ID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
input ApplicabilityStatementInput {
|
input ApplicabilityStatementInput {
|
||||||
@@ -391,8 +383,9 @@ input DeleteStatementOfApplicabilityInput {
|
|||||||
statementOfApplicabilityId: ID!
|
statementOfApplicabilityId: ID!
|
||||||
}
|
}
|
||||||
|
|
||||||
input ExportStatementOfApplicabilityPDFInput {
|
input PublishStatementOfApplicabilityInput {
|
||||||
statementOfApplicabilityId: ID!
|
statementOfApplicabilityId: ID!
|
||||||
|
approverIds: [ID!]
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateControlPayload {
|
type CreateControlPayload {
|
||||||
@@ -481,6 +474,7 @@ type DeleteStatementOfApplicabilityPayload {
|
|||||||
deletedStatementOfApplicabilityId: ID!
|
deletedStatementOfApplicabilityId: ID!
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExportStatementOfApplicabilityPDFPayload {
|
type PublishStatementOfApplicabilityPayload {
|
||||||
data: String!
|
documentEdge: DocumentEdge!
|
||||||
|
documentVersionEdge: DocumentVersionEdge!
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,36 @@ enum DocumentType
|
|||||||
REPORT @goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentTypeReport")
|
REPORT @goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentTypeReport")
|
||||||
TEMPLATE
|
TEMPLATE
|
||||||
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentTypeTemplate")
|
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentTypeTemplate")
|
||||||
|
STATEMENT_OF_APPLICABILITY
|
||||||
|
@goEnum(
|
||||||
|
value: "go.probo.inc/probo/pkg/coredata.DocumentTypeStatementOfApplicability"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DocumentVersionOrientation
|
||||||
|
@goModel(
|
||||||
|
model: "go.probo.inc/probo/pkg/coredata.DocumentVersionOrientation"
|
||||||
|
) {
|
||||||
|
PORTRAIT
|
||||||
|
@goEnum(
|
||||||
|
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionOrientationPortrait"
|
||||||
|
)
|
||||||
|
LANDSCAPE
|
||||||
|
@goEnum(
|
||||||
|
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionOrientationLandscape"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DocumentWriteMode
|
||||||
|
@goModel(model: "go.probo.inc/probo/pkg/coredata.DocumentWriteMode") {
|
||||||
|
AUTHORED
|
||||||
|
@goEnum(
|
||||||
|
value: "go.probo.inc/probo/pkg/coredata.DocumentWriteModeAuthored"
|
||||||
|
)
|
||||||
|
GENERATED
|
||||||
|
@goEnum(
|
||||||
|
value: "go.probo.inc/probo/pkg/coredata.DocumentWriteModeGenerated"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum DocumentClassification
|
enum DocumentClassification
|
||||||
@@ -199,6 +229,7 @@ input DocumentVersionOrder
|
|||||||
|
|
||||||
input DocumentFilter {
|
input DocumentFilter {
|
||||||
query: String
|
query: String
|
||||||
|
writeModes: [DocumentWriteMode!]
|
||||||
documentTypes: [DocumentType!]
|
documentTypes: [DocumentType!]
|
||||||
classifications: [DocumentClassification!]
|
classifications: [DocumentClassification!]
|
||||||
status: [DocumentStatus!]
|
status: [DocumentStatus!]
|
||||||
@@ -259,6 +290,7 @@ type Document implements Node {
|
|||||||
|
|
||||||
defaultApprovers: [Profile!]! @goField(forceResolver: true)
|
defaultApprovers: [Profile!]! @goField(forceResolver: true)
|
||||||
|
|
||||||
|
writeMode: DocumentWriteMode!
|
||||||
status: DocumentStatus!
|
status: DocumentStatus!
|
||||||
archivedAt: Datetime
|
archivedAt: Datetime
|
||||||
|
|
||||||
@@ -279,6 +311,7 @@ type DocumentVersion implements Node {
|
|||||||
title: String!
|
title: String!
|
||||||
classification: DocumentClassification!
|
classification: DocumentClassification!
|
||||||
documentType: DocumentType!
|
documentType: DocumentType!
|
||||||
|
orientation: DocumentVersionOrientation!
|
||||||
approvers(
|
approvers(
|
||||||
first: Int
|
first: Int
|
||||||
after: CursorKey
|
after: CursorKey
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ enum ElectronicSignatureDocumentType
|
|||||||
@goEnum(
|
@goEnum(
|
||||||
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypeOther"
|
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypeOther"
|
||||||
)
|
)
|
||||||
|
STATEMENT_OF_APPLICABILITY
|
||||||
|
@goEnum(
|
||||||
|
value: "go.probo.inc/probo/pkg/coredata.ElectronicSignatureDocumentTypeStatementOfApplicability"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ElectronicSignatureEventType
|
enum ElectronicSignatureEventType
|
||||||
|
|||||||
@@ -190,7 +190,6 @@ type Organization implements Node {
|
|||||||
last: Int
|
last: Int
|
||||||
before: CursorKey
|
before: CursorKey
|
||||||
orderBy: StatementOfApplicabilityOrder
|
orderBy: StatementOfApplicabilityOrder
|
||||||
filter: StatementOfApplicabilityFilter = { snapshotId: null }
|
|
||||||
): StatementOfApplicabilityConnection! @goField(forceResolver: true)
|
): StatementOfApplicabilityConnection! @goField(forceResolver: true)
|
||||||
|
|
||||||
dataProtectionImpactAssessments(
|
dataProtectionImpactAssessments(
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ func (r *measureResolver) Documents(ctx context.Context, obj *types.Measure, fir
|
|||||||
var documentFilter = coredata.NewDocumentFilter(nil)
|
var documentFilter = coredata.NewDocumentFilter(nil)
|
||||||
if filter != nil {
|
if filter != nil {
|
||||||
documentFilter = coredata.NewDocumentFilter(filter.Query).
|
documentFilter = coredata.NewDocumentFilter(filter.Query).
|
||||||
|
WithWriteModes(filter.WriteModes).
|
||||||
WithDocumentTypes(filter.DocumentTypes).
|
WithDocumentTypes(filter.DocumentTypes).
|
||||||
WithClassifications(filter.Classifications)
|
WithClassifications(filter.Classifications)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -540,7 +540,7 @@ func (r *organizationResolver) Controls(ctx context.Context, obj *types.Organiza
|
|||||||
}
|
}
|
||||||
|
|
||||||
// StatementsOfApplicability is the resolver for the statementsOfApplicability field.
|
// StatementsOfApplicability is the resolver for the statementsOfApplicability field.
|
||||||
func (r *organizationResolver) StatementsOfApplicability(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.StatementOfApplicabilityOrderBy, filter *types.StatementOfApplicabilityFilter) (*types.StatementOfApplicabilityConnection, error) {
|
func (r *organizationResolver) StatementsOfApplicability(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.StatementOfApplicabilityOrderBy) (*types.StatementOfApplicabilityConnection, error) {
|
||||||
if err := r.authorize(ctx, obj.ID, probo.ActionStatementOfApplicabilityList); err != nil {
|
if err := r.authorize(ctx, obj.ID, probo.ActionStatementOfApplicabilityList); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -560,18 +560,13 @@ func (r *organizationResolver) StatementsOfApplicability(ctx context.Context, ob
|
|||||||
|
|
||||||
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
||||||
|
|
||||||
var statementOfApplicabilityFilter = coredata.NewStatementOfApplicabilityFilter(nil)
|
page, err := prb.StatementsOfApplicability.ListForOrganizationID(ctx, obj.ID, cursor)
|
||||||
if filter != nil {
|
|
||||||
statementOfApplicabilityFilter = coredata.NewStatementOfApplicabilityFilter(&filter.SnapshotID)
|
|
||||||
}
|
|
||||||
|
|
||||||
page, err := prb.StatementsOfApplicability.ListForOrganizationID(ctx, obj.ID, cursor, statementOfApplicabilityFilter)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.logger.ErrorCtx(ctx, "cannot list organization statements_of_applicability", log.Error(err))
|
r.logger.ErrorCtx(ctx, "cannot list organization statements_of_applicability", log.Error(err))
|
||||||
return nil, gqlutils.Internal(ctx)
|
return nil, gqlutils.Internal(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
return types.NewStatementOfApplicabilityConnection(page, r, obj.ID, statementOfApplicabilityFilter), nil
|
return types.NewStatementOfApplicabilityConnection(page, r, obj.ID), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DataProtectionImpactAssessments is the resolver for the dataProtectionImpactAssessments field.
|
// DataProtectionImpactAssessments is the resolver for the dataProtectionImpactAssessments field.
|
||||||
@@ -670,6 +665,7 @@ func (r *organizationResolver) Documents(ctx context.Context, obj *types.Organiz
|
|||||||
var documentFilter = coredata.NewDocumentFilter(nil)
|
var documentFilter = coredata.NewDocumentFilter(nil)
|
||||||
if filter != nil {
|
if filter != nil {
|
||||||
documentFilter = coredata.NewDocumentFilter(filter.Query).
|
documentFilter = coredata.NewDocumentFilter(filter.Query).
|
||||||
|
WithWriteModes(filter.WriteModes).
|
||||||
WithDocumentTypes(filter.DocumentTypes).
|
WithDocumentTypes(filter.DocumentTypes).
|
||||||
WithClassifications(filter.Classifications).
|
WithClassifications(filter.Classifications).
|
||||||
WithStatus(filter.Status)
|
WithStatus(filter.Status)
|
||||||
|
|||||||
@@ -344,6 +344,7 @@ func (r *riskResolver) Documents(ctx context.Context, obj *types.Risk, first *in
|
|||||||
var documentFilter = coredata.NewDocumentFilter(nil)
|
var documentFilter = coredata.NewDocumentFilter(nil)
|
||||||
if filter != nil {
|
if filter != nil {
|
||||||
documentFilter = coredata.NewDocumentFilter(filter.Query).
|
documentFilter = coredata.NewDocumentFilter(filter.Query).
|
||||||
|
WithWriteModes(filter.WriteModes).
|
||||||
WithDocumentTypes(filter.DocumentTypes).
|
WithDocumentTypes(filter.DocumentTypes).
|
||||||
WithClassifications(filter.Classifications)
|
WithClassifications(filter.Classifications)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ func NewDocument(document *coredata.Document) *Document {
|
|||||||
},
|
},
|
||||||
CurrentPublishedMajor: document.CurrentPublishedMajor,
|
CurrentPublishedMajor: document.CurrentPublishedMajor,
|
||||||
CurrentPublishedMinor: document.CurrentPublishedMinor,
|
CurrentPublishedMinor: document.CurrentPublishedMinor,
|
||||||
|
WriteMode: document.WriteMode,
|
||||||
TrustCenterVisibility: document.TrustCenterVisibility,
|
TrustCenterVisibility: document.TrustCenterVisibility,
|
||||||
Status: document.Status,
|
Status: document.Status,
|
||||||
ArchivedAt: document.ArchivedAt,
|
ArchivedAt: document.ArchivedAt,
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ func NewDocumentVersion(documentVersion *coredata.DocumentVersion) *DocumentVers
|
|||||||
Status: documentVersion.Status,
|
Status: documentVersion.Status,
|
||||||
Classification: documentVersion.Classification,
|
Classification: documentVersion.Classification,
|
||||||
DocumentType: documentVersion.DocumentType,
|
DocumentType: documentVersion.DocumentType,
|
||||||
|
Orientation: documentVersion.Orientation,
|
||||||
PublishedAt: documentVersion.PublishedAt,
|
PublishedAt: documentVersion.PublishedAt,
|
||||||
Changelog: documentVersion.Changelog,
|
Changelog: documentVersion.Changelog,
|
||||||
CreatedAt: documentVersion.CreatedAt,
|
CreatedAt: documentVersion.CreatedAt,
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ type (
|
|||||||
|
|
||||||
Resolver any
|
Resolver any
|
||||||
ParentID gid.GID
|
ParentID gid.GID
|
||||||
Filters *coredata.StatementOfApplicabilityFilter
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,7 +37,6 @@ func NewStatementOfApplicabilityConnection(
|
|||||||
p *page.Page[*coredata.StatementOfApplicability, coredata.StatementOfApplicabilityOrderField],
|
p *page.Page[*coredata.StatementOfApplicability, coredata.StatementOfApplicabilityOrderField],
|
||||||
parentType any,
|
parentType any,
|
||||||
parentID gid.GID,
|
parentID gid.GID,
|
||||||
filters *coredata.StatementOfApplicabilityFilter,
|
|
||||||
) *StatementOfApplicabilityConnection {
|
) *StatementOfApplicabilityConnection {
|
||||||
var edges = make([]*StatementOfApplicabilityEdge, len(p.Data))
|
var edges = make([]*StatementOfApplicabilityEdge, len(p.Data))
|
||||||
|
|
||||||
@@ -52,7 +50,6 @@ func NewStatementOfApplicabilityConnection(
|
|||||||
|
|
||||||
Resolver: parentType,
|
Resolver: parentType,
|
||||||
ParentID: parentID,
|
ParentID: parentID,
|
||||||
Filters: filters,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,18 +61,21 @@ func NewStatementOfApplicabilityEdge(soa *coredata.StatementOfApplicability, ord
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewStatementOfApplicability(soa *coredata.StatementOfApplicability) *StatementOfApplicability {
|
func NewStatementOfApplicability(soa *coredata.StatementOfApplicability) *StatementOfApplicability {
|
||||||
return &StatementOfApplicability{
|
s := &StatementOfApplicability{
|
||||||
ID: soa.ID,
|
ID: soa.ID,
|
||||||
Organization: &Organization{
|
Organization: &Organization{
|
||||||
ID: soa.OrganizationID,
|
ID: soa.OrganizationID,
|
||||||
},
|
},
|
||||||
Owner: &Profile{
|
Name: soa.Name,
|
||||||
ID: soa.OwnerID,
|
CreatedAt: soa.CreatedAt,
|
||||||
},
|
UpdatedAt: soa.UpdatedAt,
|
||||||
Name: soa.Name,
|
|
||||||
SourceID: soa.SourceID,
|
|
||||||
SnapshotID: soa.SnapshotID,
|
|
||||||
CreatedAt: soa.CreatedAt,
|
|
||||||
UpdatedAt: soa.UpdatedAt,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if soa.DocumentID != nil {
|
||||||
|
s.Document = &Document{
|
||||||
|
ID: *soa.DocumentID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ package mcp_v1
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
@@ -2045,6 +2044,7 @@ func (r *Resolver) ListDocumentsTool(ctx context.Context, req *mcp.CallToolReque
|
|||||||
}
|
}
|
||||||
|
|
||||||
documentFilter = coredata.NewDocumentFilter(query).
|
documentFilter = coredata.NewDocumentFilter(query).
|
||||||
|
WithWriteModes(input.Filter.WriteModes).
|
||||||
WithDocumentTypes(input.Filter.DocumentTypes).
|
WithDocumentTypes(input.Filter.DocumentTypes).
|
||||||
WithClassifications(input.Filter.Classifications).
|
WithClassifications(input.Filter.Classifications).
|
||||||
WithStatus(input.Filter.Status)
|
WithStatus(input.Filter.Status)
|
||||||
@@ -2818,13 +2818,7 @@ func (r *Resolver) ListStatementsOfApplicabilityTool(ctx context.Context, req *m
|
|||||||
|
|
||||||
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
|
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
|
||||||
|
|
||||||
noSnapshot := (*gid.GID)(nil)
|
pg, err := prb.StatementsOfApplicability.ListForOrganizationID(ctx, input.OrganizationID, cursor)
|
||||||
filter := coredata.NewStatementOfApplicabilityFilter(&noSnapshot)
|
|
||||||
if input.Filter != nil {
|
|
||||||
filter = coredata.NewStatementOfApplicabilityFilter(&input.Filter.SnapshotID)
|
|
||||||
}
|
|
||||||
|
|
||||||
pg, err := prb.StatementsOfApplicability.ListForOrganizationID(ctx, input.OrganizationID, cursor, filter)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, types.ListStatementsOfApplicabilityOutput{}, fmt.Errorf("failed to list statements of applicability: %w", err)
|
return nil, types.ListStatementsOfApplicabilityOutput{}, fmt.Errorf("failed to list statements of applicability: %w", err)
|
||||||
}
|
}
|
||||||
@@ -2855,7 +2849,6 @@ func (r *Resolver) AddStatementOfApplicabilityTool(ctx context.Context, req *mcp
|
|||||||
soa, err := svc.StatementsOfApplicability.Create(ctx, probo.CreateStatementOfApplicabilityRequest{
|
soa, err := svc.StatementsOfApplicability.Create(ctx, probo.CreateStatementOfApplicabilityRequest{
|
||||||
OrganizationID: input.OrganizationID,
|
OrganizationID: input.OrganizationID,
|
||||||
Name: input.Name,
|
Name: input.Name,
|
||||||
OwnerID: input.OwnerID,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, types.AddStatementOfApplicabilityOutput{}, fmt.Errorf("failed to create statement of applicability: %w", err)
|
return nil, types.AddStatementOfApplicabilityOutput{}, fmt.Errorf("failed to create statement of applicability: %w", err)
|
||||||
@@ -2874,7 +2867,6 @@ func (r *Resolver) UpdateStatementOfApplicabilityTool(ctx context.Context, req *
|
|||||||
soa, err := svc.StatementsOfApplicability.Update(ctx, probo.UpdateStatementOfApplicabilityRequest{
|
soa, err := svc.StatementsOfApplicability.Update(ctx, probo.UpdateStatementOfApplicabilityRequest{
|
||||||
StatementOfApplicabilityID: input.ID,
|
StatementOfApplicabilityID: input.ID,
|
||||||
Name: input.Name,
|
Name: input.Name,
|
||||||
OwnerID: input.OwnerID,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, types.UpdateStatementOfApplicabilityOutput{}, fmt.Errorf("failed to update statement of applicability: %w", err)
|
return nil, types.UpdateStatementOfApplicabilityOutput{}, fmt.Errorf("failed to update statement of applicability: %w", err)
|
||||||
@@ -2900,27 +2892,6 @@ func (r *Resolver) DeleteStatementOfApplicabilityTool(ctx context.Context, req *
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Resolver) ExportStatementOfApplicabilityPDFTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ExportStatementOfApplicabilityPDFInput) (*mcp.CallToolResult, types.ExportStatementOfApplicabilityPDFOutput, error) {
|
|
||||||
r.MustAuthorize(ctx, input.ID, probo.ActionStatementOfApplicabilityExport)
|
|
||||||
|
|
||||||
svc := r.ProboService(ctx, input.ID)
|
|
||||||
|
|
||||||
soa, err := svc.StatementsOfApplicability.Get(ctx, input.ID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, types.ExportStatementOfApplicabilityPDFOutput{}, fmt.Errorf("failed to get statement of applicability: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
pdfData, err := svc.StatementsOfApplicability.ExportPDF(ctx, input.ID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, types.ExportStatementOfApplicabilityPDFOutput{}, fmt.Errorf("failed to export statement of applicability PDF: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, types.ExportStatementOfApplicabilityPDFOutput{
|
|
||||||
PdfBase64: base64.StdEncoding.EncodeToString(pdfData),
|
|
||||||
Filename: soa.Name + ".pdf",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Resolver) ListApplicabilityStatementsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListApplicabilityStatementsInput) (*mcp.CallToolResult, types.ListApplicabilityStatementsOutput, error) {
|
func (r *Resolver) ListApplicabilityStatementsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListApplicabilityStatementsInput) (*mcp.CallToolResult, types.ListApplicabilityStatementsOutput, error) {
|
||||||
r.MustAuthorize(ctx, input.StatementOfApplicabilityID, probo.ActionApplicabilityStatementList)
|
r.MustAuthorize(ctx, input.StatementOfApplicabilityID, probo.ActionApplicabilityStatementList)
|
||||||
|
|
||||||
@@ -3935,3 +3906,19 @@ func (r *Resolver) DeleteDocumentDraftTool(ctx context.Context, req *mcp.CallToo
|
|||||||
Document: types.NewDocument(document),
|
Document: types.NewDocument(document),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Resolver) PublishStatementOfApplicabilityTool(ctx context.Context, req *mcp.CallToolRequest, input *types.PublishStatementOfApplicabilityInput) (*mcp.CallToolResult, types.PublishStatementOfApplicabilityOutput, error) {
|
||||||
|
r.MustAuthorize(ctx, input.ID, probo.ActionStatementOfApplicabilityPublish)
|
||||||
|
|
||||||
|
svc := r.ProboService(ctx, input.ID)
|
||||||
|
|
||||||
|
document, documentVersion, err := svc.GeneratedDocuments.PublishStatementOfApplicability(ctx, input.ID, input.ApproverIds)
|
||||||
|
if err != nil {
|
||||||
|
return nil, types.PublishStatementOfApplicabilityOutput{}, fmt.Errorf("cannot publish statement of applicability: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, types.PublishStatementOfApplicabilityOutput{
|
||||||
|
DocumentID: document.ID,
|
||||||
|
DocumentVersionID: documentVersion.ID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -5144,6 +5144,7 @@ components:
|
|||||||
- RECORD
|
- RECORD
|
||||||
- REPORT
|
- REPORT
|
||||||
- TEMPLATE
|
- TEMPLATE
|
||||||
|
- STATEMENT_OF_APPLICABILITY
|
||||||
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.DocumentType
|
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.DocumentType
|
||||||
|
|
||||||
DocumentClassification:
|
DocumentClassification:
|
||||||
@@ -5170,6 +5171,13 @@ components:
|
|||||||
- ARCHIVED
|
- ARCHIVED
|
||||||
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.DocumentStatus
|
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.DocumentStatus
|
||||||
|
|
||||||
|
DocumentWriteMode:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- AUTHORED
|
||||||
|
- GENERATED
|
||||||
|
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.DocumentWriteMode
|
||||||
|
|
||||||
DocumentVersionSignatureState:
|
DocumentVersionSignatureState:
|
||||||
type: string
|
type: string
|
||||||
enum:
|
enum:
|
||||||
@@ -5244,6 +5252,7 @@ components:
|
|||||||
- id
|
- id
|
||||||
- organization_id
|
- organization_id
|
||||||
- trust_center_visibility
|
- trust_center_visibility
|
||||||
|
- write_mode
|
||||||
- status
|
- status
|
||||||
- created_at
|
- created_at
|
||||||
- updated_at
|
- updated_at
|
||||||
@@ -5267,6 +5276,9 @@ components:
|
|||||||
trust_center_visibility:
|
trust_center_visibility:
|
||||||
$ref: "#/components/schemas/TrustCenterVisibility"
|
$ref: "#/components/schemas/TrustCenterVisibility"
|
||||||
description: Trust center visibility
|
description: Trust center visibility
|
||||||
|
write_mode:
|
||||||
|
$ref: "#/components/schemas/DocumentWriteMode"
|
||||||
|
description: Write mode (authored or generated)
|
||||||
status:
|
status:
|
||||||
$ref: "#/components/schemas/DocumentStatus"
|
$ref: "#/components/schemas/DocumentStatus"
|
||||||
description: Document status
|
description: Document status
|
||||||
@@ -5419,6 +5431,11 @@ components:
|
|||||||
query:
|
query:
|
||||||
type: string
|
type: string
|
||||||
description: Search query
|
description: Search query
|
||||||
|
write_modes:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/DocumentWriteMode"
|
||||||
|
description: Filter by write mode (AUTHORED or GENERATED)
|
||||||
trust_center_visibilities:
|
trust_center_visibilities:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
@@ -6119,7 +6136,6 @@ components:
|
|||||||
- id
|
- id
|
||||||
- organization_id
|
- organization_id
|
||||||
- name
|
- name
|
||||||
- owner_id
|
|
||||||
- created_at
|
- created_at
|
||||||
- updated_at
|
- updated_at
|
||||||
properties:
|
properties:
|
||||||
@@ -6132,14 +6148,11 @@ components:
|
|||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
description: Statement of applicability name
|
description: Statement of applicability name
|
||||||
owner_id:
|
document_id:
|
||||||
$ref: "#/components/schemas/GID"
|
|
||||||
description: Owner profile ID
|
|
||||||
snapshot_id:
|
|
||||||
anyOf:
|
anyOf:
|
||||||
- $ref: "#/components/schemas/GID"
|
- $ref: "#/components/schemas/GID"
|
||||||
- type: "null"
|
- type: "null"
|
||||||
description: Snapshot ID
|
description: Associated document ID
|
||||||
created_at:
|
created_at:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
@@ -6211,7 +6224,6 @@ components:
|
|||||||
required:
|
required:
|
||||||
- organization_id
|
- organization_id
|
||||||
- name
|
- name
|
||||||
- owner_id
|
|
||||||
properties:
|
properties:
|
||||||
organization_id:
|
organization_id:
|
||||||
$ref: "#/components/schemas/GID"
|
$ref: "#/components/schemas/GID"
|
||||||
@@ -6219,9 +6231,6 @@ components:
|
|||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
description: Statement of applicability name
|
description: Statement of applicability name
|
||||||
owner_id:
|
|
||||||
$ref: "#/components/schemas/GID"
|
|
||||||
description: Owner profile ID
|
|
||||||
|
|
||||||
AddStatementOfApplicabilityOutput:
|
AddStatementOfApplicabilityOutput:
|
||||||
type: object
|
type: object
|
||||||
@@ -6242,9 +6251,6 @@ components:
|
|||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
description: Statement of applicability name
|
description: Statement of applicability name
|
||||||
owner_id:
|
|
||||||
$ref: "#/components/schemas/GID"
|
|
||||||
description: Owner profile ID
|
|
||||||
|
|
||||||
UpdateStatementOfApplicabilityOutput:
|
UpdateStatementOfApplicabilityOutput:
|
||||||
type: object
|
type: object
|
||||||
@@ -6272,7 +6278,8 @@ components:
|
|||||||
$ref: "#/components/schemas/GID"
|
$ref: "#/components/schemas/GID"
|
||||||
description: Deleted statement of applicability ID
|
description: Deleted statement of applicability ID
|
||||||
|
|
||||||
ExportStatementOfApplicabilityPDFInput:
|
|
||||||
|
PublishStatementOfApplicabilityInput:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
- id
|
- id
|
||||||
@@ -6280,19 +6287,24 @@ components:
|
|||||||
id:
|
id:
|
||||||
$ref: "#/components/schemas/GID"
|
$ref: "#/components/schemas/GID"
|
||||||
description: Statement of applicability ID
|
description: Statement of applicability 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.
|
||||||
|
|
||||||
ExportStatementOfApplicabilityPDFOutput:
|
PublishStatementOfApplicabilityOutput:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
- pdf_base64
|
- document_id
|
||||||
- filename
|
- document_version_id
|
||||||
properties:
|
properties:
|
||||||
pdf_base64:
|
document_id:
|
||||||
type: string
|
$ref: "#/components/schemas/GID"
|
||||||
description: Base64-encoded PDF content
|
description: Created or updated document ID
|
||||||
filename:
|
document_version_id:
|
||||||
type: string
|
$ref: "#/components/schemas/GID"
|
||||||
description: Suggested filename for the PDF
|
description: Created document version ID
|
||||||
|
|
||||||
ApplicabilityStatementOrderField:
|
ApplicabilityStatementOrderField:
|
||||||
type: string
|
type: string
|
||||||
@@ -8597,15 +8609,14 @@ tools:
|
|||||||
$ref: "#/components/schemas/DeleteStatementOfApplicabilityInput"
|
$ref: "#/components/schemas/DeleteStatementOfApplicabilityInput"
|
||||||
outputSchema:
|
outputSchema:
|
||||||
$ref: "#/components/schemas/DeleteStatementOfApplicabilityOutput"
|
$ref: "#/components/schemas/DeleteStatementOfApplicabilityOutput"
|
||||||
- name: exportStatementOfApplicabilityPDF
|
- name: publishStatementOfApplicability
|
||||||
description: Export a statement of applicability as a PDF document
|
description: Publish a statement of applicability as a document. If a document already exists, a new version is created.
|
||||||
hints:
|
hints:
|
||||||
readonly: true
|
readonly: false
|
||||||
idempotent: true
|
|
||||||
inputSchema:
|
inputSchema:
|
||||||
$ref: "#/components/schemas/ExportStatementOfApplicabilityPDFInput"
|
$ref: "#/components/schemas/PublishStatementOfApplicabilityInput"
|
||||||
outputSchema:
|
outputSchema:
|
||||||
$ref: "#/components/schemas/ExportStatementOfApplicabilityPDFOutput"
|
$ref: "#/components/schemas/PublishStatementOfApplicabilityOutput"
|
||||||
- name: listApplicabilityStatements
|
- name: listApplicabilityStatements
|
||||||
description: List all applicability statements for a statement of applicability
|
description: List all applicability statements for a statement of applicability
|
||||||
hints:
|
hints:
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ func NewDocument(d *coredata.Document) *Document {
|
|||||||
OrganizationID: d.OrganizationID,
|
OrganizationID: d.OrganizationID,
|
||||||
CurrentPublishedMajor: d.CurrentPublishedMajor,
|
CurrentPublishedMajor: d.CurrentPublishedMajor,
|
||||||
CurrentPublishedMinor: d.CurrentPublishedMinor,
|
CurrentPublishedMinor: d.CurrentPublishedMinor,
|
||||||
|
WriteMode: d.WriteMode,
|
||||||
TrustCenterVisibility: d.TrustCenterVisibility,
|
TrustCenterVisibility: d.TrustCenterVisibility,
|
||||||
Status: d.Status,
|
Status: d.Status,
|
||||||
ArchivedAt: d.ArchivedAt,
|
ArchivedAt: d.ArchivedAt,
|
||||||
|
|||||||
@@ -23,8 +23,7 @@ func NewStatementOfApplicability(s *coredata.StatementOfApplicability) *Statemen
|
|||||||
ID: s.ID,
|
ID: s.ID,
|
||||||
OrganizationID: s.OrganizationID,
|
OrganizationID: s.OrganizationID,
|
||||||
Name: s.Name,
|
Name: s.Name,
|
||||||
OwnerID: s.OwnerID,
|
DocumentID: s.DocumentID,
|
||||||
SnapshotID: s.SnapshotID,
|
|
||||||
CreatedAt: s.CreatedAt,
|
CreatedAt: s.CreatedAt,
|
||||||
UpdatedAt: s.UpdatedAt,
|
UpdatedAt: s.UpdatedAt,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,10 @@ enum DocumentType
|
|||||||
REPORT @goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentTypeReport")
|
REPORT @goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentTypeReport")
|
||||||
TEMPLATE
|
TEMPLATE
|
||||||
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentTypeTemplate")
|
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentTypeTemplate")
|
||||||
|
STATEMENT_OF_APPLICABILITY
|
||||||
|
@goEnum(
|
||||||
|
value: "go.probo.inc/probo/pkg/coredata.DocumentTypeStatementOfApplicability"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Document implements Node @nda {
|
type Document implements Node @nda {
|
||||||
|
|||||||
Reference in New Issue
Block a user