Add document archiving
Documents can be archived and unarchived. Archived documents are read-only, excluded from the trust center, and moved to a dedicated Archived tab in the document list. - Add archived_at timestamp and status (ACTIVE/ARCHIVED) PG enum column - Rename DocumentStatus → DocumentVersionStatus, introduce DocumentStatus - Archive/unarchive mutations in GraphQL, MCP, and CLI - Bulk archive/unarchive mutations with Active/Archived tabs in the list - ABAC policies: write actions denied on archived docs, unarchive denied on active docs - Remove control/risk mappings and reset trust center visibility on archive - Exclude archived documents from mapping dialogs and trust center tab Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
@@ -52,6 +52,7 @@ const documentsFragment = graphql`
|
|||||||
last: $last
|
last: $last
|
||||||
before: $before
|
before: $before
|
||||||
orderBy: $order
|
orderBy: $order
|
||||||
|
filter: { status: [ACTIVE] }
|
||||||
) @connection(key: "LinkedDocumentsDialogQuery_documents") {
|
) @connection(key: "LinkedDocumentsDialogQuery_documents") {
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ const bulkDeleteDocumentsMutation = graphql`
|
|||||||
$input: BulkDeleteDocumentsInput!
|
$input: BulkDeleteDocumentsInput!
|
||||||
) {
|
) {
|
||||||
bulkDeleteDocuments(input: $input) {
|
bulkDeleteDocuments(input: $input) {
|
||||||
deletedDocumentIds @deleteRecord
|
deletedDocumentIds
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const fragment = graphql`
|
|||||||
compliancePage: trustCenter @required(action: THROW) {
|
compliancePage: trustCenter @required(action: THROW) {
|
||||||
...CompliancePageDocumentListItem_compliancePageFragment
|
...CompliancePageDocumentListItem_compliancePageFragment
|
||||||
}
|
}
|
||||||
documents(first: 100) {
|
documents(first: 100 filter: { status: [ACTIVE] }) {
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
id
|
id
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export const documentLayoutQuery = graphql`
|
|||||||
... on Document {
|
... on Document {
|
||||||
id
|
id
|
||||||
title
|
title
|
||||||
|
status
|
||||||
canPublish: permission(action: "core:document-version:publish")
|
canPublish: permission(action: "core:document-version:publish")
|
||||||
controlInfo: controls(first: 0) {
|
controlInfo: controls(first: 0) {
|
||||||
totalCount
|
totalCount
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import {
|
|||||||
IconBell2,
|
IconBell2,
|
||||||
IconPlusLarge,
|
IconPlusLarge,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
|
TabItem,
|
||||||
|
Tabs,
|
||||||
} from "@probo/ui";
|
} from "@probo/ui";
|
||||||
import { useMemo, useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
type PreloadedQuery,
|
type PreloadedQuery,
|
||||||
usePreloadedQuery,
|
usePreloadedQuery,
|
||||||
@@ -29,15 +31,6 @@ export const documentsPageQuery = graphql`
|
|||||||
... on Organization {
|
... on Organization {
|
||||||
canCreateDocument: permission(action: "core:document:create")
|
canCreateDocument: permission(action: "core:document:create")
|
||||||
...DocumentListFragment @arguments(first: 50, order: { field: TITLE, direction: ASC })
|
...DocumentListFragment @arguments(first: 50, order: { field: TITLE, direction: ASC })
|
||||||
allDocuments: documents(first: 50, orderBy: { field: TITLE, direction: ASC }) {
|
|
||||||
edges {
|
|
||||||
node {
|
|
||||||
canSendSigningNotifications: permission(
|
|
||||||
action: "core:document:send-signing-notifications"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,25 +56,16 @@ export default function DocumentsPage(props: {
|
|||||||
|
|
||||||
usePageTitle(__("Documents"));
|
usePageTitle(__("Documents"));
|
||||||
|
|
||||||
const canSendAnySignatureNotifications = organization.allDocuments.edges.some(
|
const [canSendAnySignatureNotifications, setCanSendAnySignatureNotifications] = useState(false);
|
||||||
({ node: { canSendSigningNotifications } }) => canSendSigningNotifications,
|
const [tab, setTab] = useState<"ACTIVE" | "ARCHIVED">("ACTIVE");
|
||||||
|
const [documentListConnectionId, setDocumentListConnectionId] = useState(
|
||||||
|
ConnectionHandler.getConnectionID(
|
||||||
|
organizationId,
|
||||||
|
"DocumentsListQuery_documents",
|
||||||
|
{ orderBy: { direction: "ASC", field: "TITLE" } },
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const unfilteredConnectionId = useMemo(
|
|
||||||
() =>
|
|
||||||
ConnectionHandler.getConnectionID(
|
|
||||||
organizationId,
|
|
||||||
"DocumentsListQuery_documents",
|
|
||||||
{
|
|
||||||
orderBy: { direction: "ASC", field: "TITLE" },
|
|
||||||
filter: { documentTypes: null },
|
|
||||||
},
|
|
||||||
),
|
|
||||||
[organizationId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const [, setDocumentListConnectionId] = useState(unfilteredConnectionId);
|
|
||||||
|
|
||||||
const handleSendSigningNotifications = async () => {
|
const handleSendSigningNotifications = async () => {
|
||||||
await sendSigningNotifications({
|
await sendSigningNotifications({
|
||||||
variables: {
|
variables: {
|
||||||
@@ -106,9 +90,9 @@ export default function DocumentsPage(props: {
|
|||||||
{__("Send signing notifications")}
|
{__("Send signing notifications")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{organization.canCreateDocument && (
|
{organization.canCreateDocument && tab === "ACTIVE" && (
|
||||||
<CreateDocumentDialog
|
<CreateDocumentDialog
|
||||||
connection={unfilteredConnectionId}
|
connection={documentListConnectionId}
|
||||||
trigger={
|
trigger={
|
||||||
<Button icon={IconPlusLarge}>{__("New document")}</Button>
|
<Button icon={IconPlusLarge}>{__("New document")}</Button>
|
||||||
}
|
}
|
||||||
@@ -116,9 +100,19 @@ export default function DocumentsPage(props: {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
<Tabs>
|
||||||
|
<TabItem active={tab === "ACTIVE"} onClick={() => setTab("ACTIVE")}>
|
||||||
|
{__("Active")}
|
||||||
|
</TabItem>
|
||||||
|
<TabItem active={tab === "ARCHIVED"} onClick={() => setTab("ARCHIVED")}>
|
||||||
|
{__("Archived")}
|
||||||
|
</TabItem>
|
||||||
|
</Tabs>
|
||||||
<DocumentList
|
<DocumentList
|
||||||
fKey={organization}
|
fKey={organization}
|
||||||
onConnectionIdChange={setDocumentListConnectionId}
|
onConnectionIdChange={setDocumentListConnectionId}
|
||||||
|
onCanSendNotificationsChange={setCanSendAnySignatureNotifications}
|
||||||
|
tab={tab}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ const createDocumentMutation = graphql`
|
|||||||
documentEdge @prependEdge(connections: $connections) {
|
documentEdge @prependEdge(connections: $connections) {
|
||||||
node {
|
node {
|
||||||
id
|
id
|
||||||
|
canUpdate: permission(action: "core:document:update")
|
||||||
|
canDelete: permission(action: "core:document:delete")
|
||||||
|
canRequestSignatures: permission(action: "core:document-version:request-signature")
|
||||||
|
canArchive: permission(action: "core:document:archive")
|
||||||
|
canUnarchive: permission(action: "core:document:unarchive")
|
||||||
|
canSendSigningNotifications: permission(action: "core:document:send-signing-notifications")
|
||||||
...DocumentListItemFragment
|
...DocumentListItemFragment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { sprintf } from "@probo/helpers";
|
import { formatError, sprintf } from "@probo/helpers";
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import { ActionDropdown, DropdownItem, IconArrowDown, IconPencil, IconTrashCan, useConfirm } from "@probo/ui";
|
import { ActionDropdown, DropdownItem, IconArchive, IconArrowDown, IconPencil, IconTrashCan, useConfirm, useToast } from "@probo/ui";
|
||||||
import { use, useRef } from "react";
|
import { use, useRef } from "react";
|
||||||
import { useFragment } from "react-relay";
|
import { useFragment, useMutation } from "react-relay";
|
||||||
import { useNavigate, useParams } from "react-router";
|
import { useNavigate, useParams } from "react-router";
|
||||||
import { ConnectionHandler, graphql } from "relay-runtime";
|
import { ConnectionHandler, graphql } from "relay-runtime";
|
||||||
|
|
||||||
|
import type { DocumentActionsDropdown_archiveMutation } from "#/__generated__/core/DocumentActionsDropdown_archiveMutation.graphql";
|
||||||
import type { DocumentActionsDropdown_documentFragment$key } from "#/__generated__/core/DocumentActionsDropdown_documentFragment.graphql";
|
import type { DocumentActionsDropdown_documentFragment$key } from "#/__generated__/core/DocumentActionsDropdown_documentFragment.graphql";
|
||||||
|
import type { DocumentActionsDropdown_unarchiveMutation } from "#/__generated__/core/DocumentActionsDropdown_unarchiveMutation.graphql";
|
||||||
import type { DocumentActionsDropdown_versionFragment$key } from "#/__generated__/core/DocumentActionsDropdown_versionFragment.graphql";
|
import type { DocumentActionsDropdown_versionFragment$key } from "#/__generated__/core/DocumentActionsDropdown_versionFragment.graphql";
|
||||||
import type { DocumentActionsDropdownn_exportVersionMutation } from "#/__generated__/core/DocumentActionsDropdownn_exportVersionMutation.graphql";
|
import type { DocumentActionsDropdownn_exportVersionMutation } from "#/__generated__/core/DocumentActionsDropdownn_exportVersionMutation.graphql";
|
||||||
import { PdfDownloadDialog, type PdfDownloadDialogRef } from "#/components/documents/PdfDownloadDialog";
|
import { PdfDownloadDialog, type PdfDownloadDialogRef } from "#/components/documents/PdfDownloadDialog";
|
||||||
@@ -21,7 +23,10 @@ const documentFragment = graphql`
|
|||||||
fragment DocumentActionsDropdown_documentFragment on Document {
|
fragment DocumentActionsDropdown_documentFragment on Document {
|
||||||
id
|
id
|
||||||
title
|
title
|
||||||
|
status
|
||||||
canUpdate: permission(action: "core:document:update")
|
canUpdate: permission(action: "core:document:update")
|
||||||
|
canArchive: permission(action: "core:document:archive")
|
||||||
|
canUnarchive: permission(action: "core:document:unarchive")
|
||||||
canDelete: permission(action: "core:document:delete")
|
canDelete: permission(action: "core:document:delete")
|
||||||
versions(first: 20) {
|
versions(first: 20) {
|
||||||
__id
|
__id
|
||||||
@@ -31,6 +36,42 @@ const documentFragment = graphql`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const archiveDocumentMutation = graphql`
|
||||||
|
mutation DocumentActionsDropdown_archiveMutation(
|
||||||
|
$input: ArchiveDocumentInput!
|
||||||
|
) {
|
||||||
|
archiveDocument(input: $input) {
|
||||||
|
document {
|
||||||
|
id
|
||||||
|
status
|
||||||
|
archivedAt
|
||||||
|
canUpdate: permission(action: "core:document:update")
|
||||||
|
canArchive: permission(action: "core:document:archive")
|
||||||
|
canUnarchive: permission(action: "core:document:unarchive")
|
||||||
|
canDelete: permission(action: "core:document:delete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const unarchiveDocumentMutation = graphql`
|
||||||
|
mutation DocumentActionsDropdown_unarchiveMutation(
|
||||||
|
$input: UnarchiveDocumentInput!
|
||||||
|
) {
|
||||||
|
unarchiveDocument(input: $input) {
|
||||||
|
document {
|
||||||
|
id
|
||||||
|
status
|
||||||
|
archivedAt
|
||||||
|
canUpdate: permission(action: "core:document:update")
|
||||||
|
canArchive: permission(action: "core:document:archive")
|
||||||
|
canUnarchive: permission(action: "core:document:unarchive")
|
||||||
|
canDelete: permission(action: "core:document:delete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
const versionFragment = graphql`
|
const versionFragment = graphql`
|
||||||
fragment DocumentActionsDropdown_versionFragment on DocumentVersion {
|
fragment DocumentActionsDropdown_versionFragment on DocumentVersion {
|
||||||
id
|
id
|
||||||
@@ -65,6 +106,7 @@ export function DocumentActionsDropdownn(props: {
|
|||||||
const updateDialogRef = useRef<{ open: () => void }>(null);
|
const updateDialogRef = useRef<{ open: () => void }>(null);
|
||||||
const pdfDownloadDialogRef = useRef<PdfDownloadDialogRef>(null);
|
const pdfDownloadDialogRef = useRef<PdfDownloadDialogRef>(null);
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
const document = useFragment<DocumentActionsDropdown_documentFragment$key>(documentFragment, documentFragmentRef);
|
const document = useFragment<DocumentActionsDropdown_documentFragment$key>(documentFragment, documentFragmentRef);
|
||||||
const version = useFragment<DocumentActionsDropdown_versionFragment$key>(versionFragment, versionFragmentRef);
|
const version = useFragment<DocumentActionsDropdown_versionFragment$key>(versionFragment, versionFragmentRef);
|
||||||
@@ -72,6 +114,10 @@ export function DocumentActionsDropdownn(props: {
|
|||||||
const isDraft = version.status === "DRAFT";
|
const isDraft = version.status === "DRAFT";
|
||||||
|
|
||||||
const [deleteDocument, isDeleting] = useDeleteDocumentMutation();
|
const [deleteDocument, isDeleting] = useDeleteDocumentMutation();
|
||||||
|
const [archiveDocument, isArchiving]
|
||||||
|
= useMutation<DocumentActionsDropdown_archiveMutation>(archiveDocumentMutation);
|
||||||
|
const [unarchiveDocument, isUnarchiving]
|
||||||
|
= useMutation<DocumentActionsDropdown_unarchiveMutation>(unarchiveDocumentMutation);
|
||||||
const [deleteDraftDocumentVersion, isDeletingDraft]
|
const [deleteDraftDocumentVersion, isDeletingDraft]
|
||||||
= useDeleteDraftDocumentVersionMutation();
|
= useDeleteDraftDocumentVersionMutation();
|
||||||
const [exportDocumentVersion, isExporting]
|
const [exportDocumentVersion, isExporting]
|
||||||
@@ -83,6 +129,53 @@ export function DocumentActionsDropdownn(props: {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleArchive = () => {
|
||||||
|
confirm(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
archiveDocument({
|
||||||
|
variables: { input: { documentId: document.id } },
|
||||||
|
onCompleted(_, errors) {
|
||||||
|
if (errors?.length) {
|
||||||
|
toast({ title: __("Error"), description: formatError(__("Failed to archive document"), errors), variant: "error" });
|
||||||
|
} else {
|
||||||
|
toast({ title: __("Success"), description: __("Document archived successfully."), variant: "success" });
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
toast({ title: __("Error"), description: error.message, variant: "error" });
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
message: sprintf(
|
||||||
|
__("This will archive the document \"%s\". It will no longer be editable."),
|
||||||
|
document.title,
|
||||||
|
),
|
||||||
|
variant: "danger",
|
||||||
|
label: __("Archive"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnarchive = () => {
|
||||||
|
unarchiveDocument({
|
||||||
|
variables: { input: { documentId: document.id } },
|
||||||
|
onCompleted(_, errors) {
|
||||||
|
if (errors?.length) {
|
||||||
|
toast({ title: __("Error"), description: formatError(__("Failed to unarchive document"), errors), variant: "error" });
|
||||||
|
} else {
|
||||||
|
toast({ title: __("Success"), description: __("Document unarchived successfully."), variant: "success" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
toast({ title: __("Error"), description: error.message, variant: "error" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
const connectionId = ConnectionHandler.getConnectionID(
|
const connectionId = ConnectionHandler.getConnectionID(
|
||||||
organizationId,
|
organizationId,
|
||||||
@@ -214,6 +307,24 @@ export function DocumentActionsDropdownn(props: {
|
|||||||
>
|
>
|
||||||
{__("Download PDF")}
|
{__("Download PDF")}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
|
{document.canArchive && (
|
||||||
|
<DropdownItem
|
||||||
|
icon={IconArchive}
|
||||||
|
disabled={isArchiving}
|
||||||
|
onClick={handleArchive}
|
||||||
|
>
|
||||||
|
{__("Archive document")}
|
||||||
|
</DropdownItem>
|
||||||
|
)}
|
||||||
|
{document.canUnarchive && (
|
||||||
|
<DropdownItem
|
||||||
|
icon={IconArchive}
|
||||||
|
disabled={isUnarchiving}
|
||||||
|
onClick={handleUnarchive}
|
||||||
|
>
|
||||||
|
{__("Unarchive document")}
|
||||||
|
</DropdownItem>
|
||||||
|
)}
|
||||||
{document.canDelete && (
|
{document.canDelete && (
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
variant="danger"
|
variant="danger"
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ const documentFragment = graphql`
|
|||||||
fragment DocumentLayoutDrawer_documentFragment on Document {
|
fragment DocumentLayoutDrawer_documentFragment on Document {
|
||||||
id
|
id
|
||||||
documentType
|
documentType
|
||||||
|
status
|
||||||
|
archivedAt
|
||||||
canUpdate: permission(action: "core:document:update")
|
canUpdate: permission(action: "core:document:update")
|
||||||
approvers(first: 100) {
|
approvers(first: 100) {
|
||||||
edges {
|
edges {
|
||||||
@@ -87,6 +89,7 @@ export function DocumentLayoutDrawer(props: {
|
|||||||
const version = useFragment<DocumentLayoutDrawer_versionFragment$key>(versionFragment, versionFragmentRef);
|
const version = useFragment<DocumentLayoutDrawer_versionFragment$key>(versionFragment, versionFragmentRef);
|
||||||
|
|
||||||
const isDraft = version.status === "DRAFT";
|
const isDraft = version.status === "DRAFT";
|
||||||
|
const canEdit = document.canUpdate;
|
||||||
|
|
||||||
const approvers = document.approvers.edges.map(e => e.node);
|
const approvers = document.approvers.edges.map(e => e.node);
|
||||||
|
|
||||||
@@ -184,7 +187,7 @@ export function DocumentLayoutDrawer(props: {
|
|||||||
: (
|
: (
|
||||||
<ReadOnlyPropertyContent
|
<ReadOnlyPropertyContent
|
||||||
onEdit={() => setIsEditingApprover(true)}
|
onEdit={() => setIsEditingApprover(true)}
|
||||||
canEdit={document.canUpdate}
|
canEdit={canEdit}
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{approvers.map(approver => (
|
{approvers.map(approver => (
|
||||||
@@ -220,7 +223,7 @@ export function DocumentLayoutDrawer(props: {
|
|||||||
: (
|
: (
|
||||||
<ReadOnlyPropertyContent
|
<ReadOnlyPropertyContent
|
||||||
onEdit={() => setIsEditingType(true)}
|
onEdit={() => setIsEditingType(true)}
|
||||||
canEdit={document.canUpdate}
|
canEdit={canEdit}
|
||||||
>
|
>
|
||||||
<div className="text-sm text-txt-secondary">
|
<div className="text-sm text-txt-secondary">
|
||||||
{getDocumentTypeLabel(__, document.documentType)}
|
{getDocumentTypeLabel(__, document.documentType)}
|
||||||
@@ -251,7 +254,7 @@ export function DocumentLayoutDrawer(props: {
|
|||||||
: (
|
: (
|
||||||
<ReadOnlyPropertyContent
|
<ReadOnlyPropertyContent
|
||||||
onEdit={() => setIsEditingClassification(true)}
|
onEdit={() => setIsEditingClassification(true)}
|
||||||
canEdit={document.canUpdate}
|
canEdit={canEdit}
|
||||||
>
|
>
|
||||||
<div className="text-sm text-txt-secondary">
|
<div className="text-sm text-txt-secondary">
|
||||||
{getDocumentClassificationLabel(
|
{getDocumentClassificationLabel(
|
||||||
@@ -288,6 +291,13 @@ export function DocumentLayoutDrawer(props: {
|
|||||||
</div>
|
</div>
|
||||||
</PropertyRow>
|
</PropertyRow>
|
||||||
)}
|
)}
|
||||||
|
{document.archivedAt && (
|
||||||
|
<PropertyRow label={__("Archived on")}>
|
||||||
|
<Badge variant="danger" size="md" className="gap-2">
|
||||||
|
{formatDate(document.archivedAt)}
|
||||||
|
</Badge>
|
||||||
|
</PropertyRow>
|
||||||
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import { documentTypes, getDocumentTypeLabel, sprintf } from "@probo/helpers";
|
import { documentTypes, getDocumentTypeLabel, 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, IconArrowDown, IconCheckmark1, IconCrossLargeX, IconSignature, IconTrashCan, Option, Select, Tbody, Th, Thead, Tr, useConfirm } from "@probo/ui";
|
import { Button, Card, Checkbox, IconArchive, IconArrowDown, IconCheckmark1, IconCrossLargeX, IconSignature, IconTrashCan, Option, Select, Tbody, Th, Thead, Tr, useConfirm } from "@probo/ui";
|
||||||
import { type ComponentProps, use, useRef, useState, useTransition } from "react";
|
import { type ComponentProps, use, useEffect, useRef, useState, useTransition } from "react";
|
||||||
import { usePaginationFragment } from "react-relay";
|
import { usePaginationFragment } from "react-relay";
|
||||||
import { ConnectionHandler, graphql } from "relay-runtime";
|
import { ConnectionHandler, graphql } from "relay-runtime";
|
||||||
|
|
||||||
|
import type { DocumentListBulkArchiveMutation } from "#/__generated__/core/DocumentListBulkArchiveMutation.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 { DocumentsListQuery, DocumentType } from "#/__generated__/core/DocumentsListQuery.graphql";
|
import type { DocumentType, DocumentsListQuery } 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";
|
||||||
|
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||||
import { CurrentUser } from "#/providers/CurrentUser";
|
import { CurrentUser } from "#/providers/CurrentUser";
|
||||||
|
|
||||||
@@ -30,6 +33,7 @@ const fragment = 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 }
|
||||||
|
status: { type: "[DocumentStatus!]", defaultValue: [ACTIVE] }
|
||||||
documentTypes: { type: "[DocumentType!]", defaultValue: null }
|
documentTypes: { type: "[DocumentType!]", defaultValue: null }
|
||||||
) {
|
) {
|
||||||
documents(
|
documents(
|
||||||
@@ -38,7 +42,7 @@ const fragment = graphql`
|
|||||||
last: $last
|
last: $last
|
||||||
before: $before
|
before: $before
|
||||||
orderBy: $order
|
orderBy: $order
|
||||||
filter: { documentTypes: $documentTypes }
|
filter: { status: $status documentTypes: $documentTypes }
|
||||||
) @connection(key: "DocumentsListQuery_documents" filters: ["orderBy", "filter"]) {
|
) @connection(key: "DocumentsListQuery_documents" filters: ["orderBy", "filter"]) {
|
||||||
__id
|
__id
|
||||||
edges {
|
edges {
|
||||||
@@ -49,6 +53,11 @@ const fragment = graphql`
|
|||||||
canRequestSignatures: permission(
|
canRequestSignatures: permission(
|
||||||
action: "core:document-version:request-signature"
|
action: "core:document-version:request-signature"
|
||||||
)
|
)
|
||||||
|
canArchive: permission(action: "core:document:archive")
|
||||||
|
canUnarchive: permission(action: "core:document:unarchive")
|
||||||
|
canSendSigningNotifications: permission(
|
||||||
|
action: "core:document:send-signing-notifications"
|
||||||
|
)
|
||||||
...DocumentListItemFragment
|
...DocumentListItemFragment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,11 +65,43 @@ const fragment = graphql`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const bulkArchiveMutation = graphql`
|
||||||
|
mutation DocumentListBulkArchiveMutation($input: BulkArchiveDocumentsInput!) {
|
||||||
|
bulkArchiveDocuments(input: $input) {
|
||||||
|
documents {
|
||||||
|
id
|
||||||
|
status
|
||||||
|
archivedAt
|
||||||
|
canUpdate: permission(action: "core:document:update")
|
||||||
|
canArchive: permission(action: "core:document:archive")
|
||||||
|
canUnarchive: permission(action: "core:document:unarchive")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const bulkUnarchiveMutation = graphql`
|
||||||
|
mutation DocumentListBulkUnarchiveMutation($input: BulkUnarchiveDocumentsInput!) {
|
||||||
|
bulkUnarchiveDocuments(input: $input) {
|
||||||
|
documents {
|
||||||
|
id
|
||||||
|
status
|
||||||
|
archivedAt
|
||||||
|
canUpdate: permission(action: "core:document:update")
|
||||||
|
canArchive: permission(action: "core:document:archive")
|
||||||
|
canUnarchive: permission(action: "core:document:unarchive")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
export function DocumentList(props: {
|
export function DocumentList(props: {
|
||||||
fKey: DocumentListFragment$key;
|
fKey: DocumentListFragment$key;
|
||||||
onConnectionIdChange: (connectionId: string) => void;
|
onConnectionIdChange: (connectionId: string) => void;
|
||||||
|
onCanSendNotificationsChange?: (can: boolean) => void;
|
||||||
|
tab: "ACTIVE" | "ARCHIVED";
|
||||||
}) {
|
}) {
|
||||||
const { fKey, onConnectionIdChange } = props;
|
const { fKey, onConnectionIdChange, onCanSendNotificationsChange, tab } = props;
|
||||||
|
|
||||||
const organizationId = useOrganizationId();
|
const organizationId = useOrganizationId();
|
||||||
const { email: defaultEmail } = use(CurrentUser);
|
const { email: defaultEmail } = use(CurrentUser);
|
||||||
@@ -72,17 +113,48 @@ export function DocumentList(props: {
|
|||||||
fKey,
|
fKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
const documents = pagination.data.documents.edges
|
const [documentTypeFilter, setDocumentTypeFilter] = useState<DocumentType | null>(null);
|
||||||
.map(({ node }) => node);
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const refetch = pagination.refetch;
|
||||||
|
useEffect(() => {
|
||||||
|
refetch(
|
||||||
|
{ status: [tab], documentTypes: documentTypeFilter ? [documentTypeFilter] : null },
|
||||||
|
{ fetchPolicy: "store-and-network" },
|
||||||
|
);
|
||||||
|
}, [tab, refetch]);
|
||||||
|
|
||||||
|
const documents = pagination.data.documents.edges.map(({ node }) => node);
|
||||||
const connectionId = pagination.data.documents.__id;
|
const connectionId = pagination.data.documents.__id;
|
||||||
|
|
||||||
const [bulkDeleteDocuments] = useBulkDeleteDocumentsMutation();
|
const [bulkDeleteDocuments] = useBulkDeleteDocumentsMutation();
|
||||||
const [bulkExportDocuments, isBulkExporting]
|
const [bulkExportDocuments, isBulkExporting] = useBulkExportDocumentsMutation();
|
||||||
= useBulkExportDocumentsMutation();
|
const [bulkArchiveDocuments, isBulkArchiving] = useMutationWithToasts<DocumentListBulkArchiveMutation>(
|
||||||
|
bulkArchiveMutation,
|
||||||
|
{ successMessage: __("Documents archived successfully."), errorMessage: __("Failed to archive documents") },
|
||||||
|
);
|
||||||
|
const [bulkUnarchiveDocuments, isBulkUnarchiving] = useMutationWithToasts<DocumentListBulkUnarchiveMutation>(
|
||||||
|
bulkUnarchiveMutation,
|
||||||
|
{ successMessage: __("Documents unarchived successfully."), errorMessage: __("Failed to unarchive documents") },
|
||||||
|
);
|
||||||
const { list: selection, toggle, clear, reset } = useList<string>([]);
|
const { list: selection, toggle, clear, reset } = useList<string>([]);
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const [isPending, startTransition] = useTransition();
|
|
||||||
const [documentTypeFilter, setDocumentTypeFilter] = useState<DocumentType | null>(null);
|
const canDeleteAny = documents.some(({ canDelete }) => canDelete);
|
||||||
|
const canUpdateAny = documents.some(({ canUpdate }) => canUpdate);
|
||||||
|
const canRequestAnySignatures = documents.some(({ canRequestSignatures }) => canRequestSignatures);
|
||||||
|
const canArchiveAny = documents.some(({ canArchive }) => canArchive);
|
||||||
|
const canUnarchiveAny = documents.some(({ canUnarchive }) => canUnarchive);
|
||||||
|
const canSendAnySignatureNotifications = documents.some(({ canSendSigningNotifications }) => canSendSigningNotifications);
|
||||||
|
const hasAnyAction = tab === "ARCHIVED" ? canUnarchiveAny || canDeleteAny : canDeleteAny || canUpdateAny;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onConnectionIdChange(connectionId);
|
||||||
|
}, [connectionId, onConnectionIdChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onCanSendNotificationsChange?.(canSendAnySignatureNotifications);
|
||||||
|
}, [canSendAnySignatureNotifications, onCanSendNotificationsChange]);
|
||||||
|
|
||||||
const handleDocumentTypeFilterChange = (value: string) => {
|
const handleDocumentTypeFilterChange = (value: string) => {
|
||||||
const newType = value === "ALL" ? null : (value as DocumentType);
|
const newType = value === "ALL" ? null : (value as DocumentType);
|
||||||
@@ -94,41 +166,36 @@ export function DocumentList(props: {
|
|||||||
"DocumentsListQuery_documents",
|
"DocumentsListQuery_documents",
|
||||||
{
|
{
|
||||||
orderBy: { direction: "ASC", field: "TITLE" },
|
orderBy: { direction: "ASC", field: "TITLE" },
|
||||||
filter: { documentTypes: newType ? [newType] : null },
|
filter: { status: [tab], documentTypes: newType ? [newType] : null },
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
pagination.refetch(
|
pagination.refetch(
|
||||||
{ documentTypes: newType ? [newType] : null },
|
{ status: [tab], documentTypes: newType ? [newType] : null },
|
||||||
{ fetchPolicy: "network-only" },
|
{ fetchPolicy: "store-and-network" },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const canDeleteAny = documents.some(({ canDelete }) => canDelete);
|
|
||||||
const canUpdateAny = documents.some(({ canUpdate }) => canUpdate);
|
|
||||||
const canRequestAnySignatures = documents.some(
|
|
||||||
({ canRequestSignatures }) => canRequestSignatures,
|
|
||||||
);
|
|
||||||
const hasAnyAction = canDeleteAny || canUpdateAny;
|
|
||||||
|
|
||||||
const handleBulkDelete = () => {
|
const handleBulkDelete = () => {
|
||||||
const documentCount = selection.length;
|
const documentCount = selection.length;
|
||||||
confirm(
|
confirm(
|
||||||
() =>
|
() =>
|
||||||
bulkDeleteDocuments({
|
bulkDeleteDocuments({
|
||||||
variables: {
|
variables: { input: { documentIds: selection } },
|
||||||
input: { documentIds: selection },
|
updater: (store) => {
|
||||||
|
const conn = store.get(connectionId);
|
||||||
|
if (conn) {
|
||||||
|
selection.forEach(id => ConnectionHandler.deleteNode(conn, id));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
clear();
|
clear();
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
message: sprintf(
|
message: sprintf(
|
||||||
__(
|
__("This will permanently delete %s document%s. This action cannot be undone."),
|
||||||
"This will permanently delete %s document%s. This action cannot be undone.",
|
|
||||||
),
|
|
||||||
documentCount,
|
documentCount,
|
||||||
documentCount > 1 ? "s" : "",
|
documentCount > 1 ? "s" : "",
|
||||||
),
|
),
|
||||||
@@ -136,6 +203,32 @@ export function DocumentList(props: {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBulkArchive = () => {
|
||||||
|
void bulkArchiveDocuments({
|
||||||
|
variables: { input: { documentIds: selection } },
|
||||||
|
updater: (store) => {
|
||||||
|
const conn = store.get(connectionId);
|
||||||
|
if (conn) {
|
||||||
|
selection.forEach(id => ConnectionHandler.deleteNode(conn, id));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: clear,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkUnarchive = () => {
|
||||||
|
void bulkUnarchiveDocuments({
|
||||||
|
variables: { input: { documentIds: selection } },
|
||||||
|
updater: (store) => {
|
||||||
|
const conn = store.get(connectionId);
|
||||||
|
if (conn) {
|
||||||
|
selection.forEach(id => ConnectionHandler.deleteNode(conn, id));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: clear,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleBulkExport = async (options: {
|
const handleBulkExport = async (options: {
|
||||||
withWatermark: boolean;
|
withWatermark: boolean;
|
||||||
withSignatures: boolean;
|
withSignatures: boolean;
|
||||||
@@ -145,13 +238,9 @@ export function DocumentList(props: {
|
|||||||
documentIds: selection,
|
documentIds: selection,
|
||||||
withWatermark: options.withWatermark,
|
withWatermark: options.withWatermark,
|
||||||
withSignatures: options.withSignatures,
|
withSignatures: options.withSignatures,
|
||||||
...(options.withWatermark
|
...(options.withWatermark && options.watermarkEmail && { watermarkEmail: options.watermarkEmail }),
|
||||||
&& options.watermarkEmail && { watermarkEmail: options.watermarkEmail }),
|
|
||||||
};
|
};
|
||||||
|
await bulkExportDocuments({ variables: { input } });
|
||||||
await bulkExportDocuments({
|
|
||||||
variables: { input },
|
|
||||||
});
|
|
||||||
clear();
|
clear();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -162,12 +251,21 @@ export function DocumentList(props: {
|
|||||||
"DocumentsListQuery_documents",
|
"DocumentsListQuery_documents",
|
||||||
{
|
{
|
||||||
orderBy: order,
|
orderBy: order,
|
||||||
filter: { documentTypes: documentTypeFilter ? [documentTypeFilter] : null },
|
filter: { status: [tab], documentTypes: documentTypeFilter ? [documentTypeFilter] : null },
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const refetchWithFilters: ComponentProps<typeof SortableTable>["refetch"] = ({ order }) => {
|
||||||
|
pagination.refetch({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
order: order as any,
|
||||||
|
status: [tab],
|
||||||
|
documentTypes: documentTypeFilter ? [documentTypeFilter] : 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">
|
||||||
@@ -188,7 +286,7 @@ export function DocumentList(props: {
|
|||||||
? (
|
? (
|
||||||
<SortableTable
|
<SortableTable
|
||||||
{...pagination}
|
{...pagination}
|
||||||
refetch={pagination.refetch as ComponentProps<typeof SortableTable>["refetch"]}
|
refetch={refetchWithFilters}
|
||||||
>
|
>
|
||||||
<Thead>
|
<Thead>
|
||||||
{selection.length === 0
|
{selection.length === 0
|
||||||
@@ -196,10 +294,7 @@ export function DocumentList(props: {
|
|||||||
<Tr>
|
<Tr>
|
||||||
<Th className="w-18">
|
<Th className="w-18">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={
|
checked={selection.length === documents.length && documents.length > 0}
|
||||||
selection.length === documents.length
|
|
||||||
&& documents.length > 0
|
|
||||||
}
|
|
||||||
onChange={() => reset(documents.map(d => d.id))}
|
onChange={() => reset(documents.map(d => d.id))}
|
||||||
/>
|
/>
|
||||||
</Th>
|
</Th>
|
||||||
@@ -235,58 +330,90 @@ export function DocumentList(props: {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
{canUpdateAny && (
|
{tab === "ARCHIVED"
|
||||||
<PublishDocumentsDialog
|
? (
|
||||||
documentIds={selection}
|
<>
|
||||||
onSave={clear}
|
{canUnarchiveAny && (
|
||||||
>
|
<Button
|
||||||
<Button
|
variant="secondary"
|
||||||
icon={IconCheckmark1}
|
icon={IconArchive}
|
||||||
className="py-0.5 px-2 text-xs h-6 min-h-6"
|
onClick={handleBulkUnarchive}
|
||||||
>
|
disabled={isBulkUnarchiving}
|
||||||
{__("Publish")}
|
className="py-0.5 px-2 text-xs h-6 min-h-6"
|
||||||
</Button>
|
>
|
||||||
</PublishDocumentsDialog>
|
{__("Unarchive")}
|
||||||
)}
|
</Button>
|
||||||
{canRequestAnySignatures && (
|
)}
|
||||||
<SignatureDocumentsDialog
|
{canDeleteAny && (
|
||||||
documentIds={selection}
|
<Button
|
||||||
onSave={clear}
|
variant="danger"
|
||||||
>
|
icon={IconTrashCan}
|
||||||
<Button
|
onClick={handleBulkDelete}
|
||||||
variant="secondary"
|
className="py-0.5 px-2 text-xs h-6 min-h-6"
|
||||||
icon={IconSignature}
|
>
|
||||||
className="py-0.5 px-2 text-xs h-6 min-h-6"
|
{__("Delete")}
|
||||||
>
|
</Button>
|
||||||
{__("Request signature")}
|
)}
|
||||||
</Button>
|
</>
|
||||||
</SignatureDocumentsDialog>
|
)
|
||||||
)}
|
: (
|
||||||
<BulkExportDialog
|
<>
|
||||||
ref={bulkExportDialogRef}
|
{canUpdateAny && (
|
||||||
onExport={handleBulkExport}
|
<PublishDocumentsDialog documentIds={selection} onSave={clear}>
|
||||||
isLoading={isBulkExporting}
|
<Button icon={IconCheckmark1} className="py-0.5 px-2 text-xs h-6 min-h-6">
|
||||||
defaultEmail={defaultEmail}
|
{__("Publish")}
|
||||||
selectedCount={selection.length}
|
</Button>
|
||||||
>
|
</PublishDocumentsDialog>
|
||||||
<Button
|
)}
|
||||||
variant="secondary"
|
{canRequestAnySignatures && (
|
||||||
icon={IconArrowDown}
|
<SignatureDocumentsDialog documentIds={selection} onSave={clear}>
|
||||||
className="py-0.5 px-2 text-xs h-6 min-h-6"
|
<Button
|
||||||
>
|
variant="secondary"
|
||||||
{__("Export")}
|
icon={IconSignature}
|
||||||
</Button>
|
className="py-0.5 px-2 text-xs h-6 min-h-6"
|
||||||
</BulkExportDialog>
|
>
|
||||||
{canDeleteAny && (
|
{__("Request signature")}
|
||||||
<Button
|
</Button>
|
||||||
variant="danger"
|
</SignatureDocumentsDialog>
|
||||||
icon={IconTrashCan}
|
)}
|
||||||
onClick={handleBulkDelete}
|
<BulkExportDialog
|
||||||
className="py-0.5 px-2 text-xs h-6 min-h-6"
|
ref={bulkExportDialogRef}
|
||||||
>
|
onExport={handleBulkExport}
|
||||||
{__("Delete")}
|
isLoading={isBulkExporting}
|
||||||
</Button>
|
defaultEmail={defaultEmail}
|
||||||
)}
|
selectedCount={selection.length}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
icon={IconArrowDown}
|
||||||
|
className="py-0.5 px-2 text-xs h-6 min-h-6"
|
||||||
|
>
|
||||||
|
{__("Export")}
|
||||||
|
</Button>
|
||||||
|
</BulkExportDialog>
|
||||||
|
{canArchiveAny && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
icon={IconArchive}
|
||||||
|
onClick={handleBulkArchive}
|
||||||
|
disabled={isBulkArchiving}
|
||||||
|
className="py-0.5 px-2 text-xs h-6 min-h-6"
|
||||||
|
>
|
||||||
|
{__("Archive")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canDeleteAny && (
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
icon={IconTrashCan}
|
||||||
|
onClick={handleBulkDelete}
|
||||||
|
className="py-0.5 px-2 text-xs h-6 min-h-6"
|
||||||
|
>
|
||||||
|
{__("Delete")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Th>
|
</Th>
|
||||||
@@ -311,11 +438,13 @@ export function DocumentList(props: {
|
|||||||
<Card padded>
|
<Card padded>
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<h3 className="text-lg font-semibold mb-2">
|
<h3 className="text-lg font-semibold mb-2">
|
||||||
{__("No documents yet")}
|
{tab === "ARCHIVED" ? __("No archived documents") : __("No documents yet")}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-txt-tertiary mb-4">
|
{tab !== "ARCHIVED" && (
|
||||||
{__("Create your first document to get started.")}
|
<p className="text-txt-tertiary mb-4">
|
||||||
</p>
|
{__("Create your first document to get started.")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -109,7 +109,9 @@ export function DocumentListItem(props: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tr to={`/organizations/${organizationId}/documents/${document.id}`}>
|
<Tr
|
||||||
|
to={`/organizations/${organizationId}/documents/${document.id}`}
|
||||||
|
>
|
||||||
<Td noLink className="w-18">
|
<Td noLink className="w-18">
|
||||||
<Checkbox checked={checked} onChange={onCheck} />
|
<Checkbox checked={checked} onChange={onCheck} />
|
||||||
</Td>
|
</Td>
|
||||||
|
|||||||
@@ -113,3 +113,33 @@ WHERE
|
|||||||
_, err := conn.Exec(ctx, q, args)
|
_, err := conn.Exec(ctx, q, args)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (cp ControlDocument) DeleteByDocumentIDs(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Conn,
|
||||||
|
scope Scoper,
|
||||||
|
documentIDs []gid.GID,
|
||||||
|
) error {
|
||||||
|
q := `
|
||||||
|
DELETE
|
||||||
|
FROM
|
||||||
|
controls_documents
|
||||||
|
WHERE
|
||||||
|
%s
|
||||||
|
AND document_id = ANY(@document_ids);
|
||||||
|
`
|
||||||
|
|
||||||
|
args := pgx.StrictNamedArgs{
|
||||||
|
"document_ids": documentIDs,
|
||||||
|
}
|
||||||
|
maps.Copy(args, scope.SQLArguments())
|
||||||
|
|
||||||
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||||
|
|
||||||
|
if _, err := conn.Exec(ctx, q, args); err != nil {
|
||||||
|
return fmt.Errorf("cannot delete control document mappings by document ids: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ type (
|
|||||||
Classification DocumentClassification `db:"classification"`
|
Classification DocumentClassification `db:"classification"`
|
||||||
CurrentPublishedVersion *int `db:"current_published_version"`
|
CurrentPublishedVersion *int `db:"current_published_version"`
|
||||||
TrustCenterVisibility TrustCenterVisibility `db:"trust_center_visibility"`
|
TrustCenterVisibility TrustCenterVisibility `db:"trust_center_visibility"`
|
||||||
|
Status DocumentStatus `db:"status"`
|
||||||
|
ArchivedAt *time.Time `db:"archived_at"`
|
||||||
CreatedAt time.Time `db:"created_at"`
|
CreatedAt time.Time `db:"created_at"`
|
||||||
UpdatedAt time.Time `db:"updated_at"`
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -59,17 +61,21 @@ func (p Document) CursorKey(orderBy DocumentOrderField) page.CursorKey {
|
|||||||
|
|
||||||
// AuthorizationAttributes returns the authorization attributes for policy evaluation.
|
// AuthorizationAttributes returns the authorization attributes for policy evaluation.
|
||||||
func (d *Document) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (map[string]string, error) {
|
func (d *Document) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (map[string]string, error) {
|
||||||
q := `SELECT organization_id FROM documents WHERE id = $1 LIMIT 1;`
|
q := `SELECT organization_id, status FROM documents WHERE id = $1 LIMIT 1;`
|
||||||
|
|
||||||
var organizationID gid.GID
|
var organizationID gid.GID
|
||||||
if err := conn.QueryRow(ctx, q, d.ID).Scan(&organizationID); err != nil {
|
var documentStatus DocumentStatus
|
||||||
|
if err := conn.QueryRow(ctx, q, d.ID).Scan(&organizationID, &documentStatus); err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, ErrResourceNotFound
|
return nil, ErrResourceNotFound
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("cannot query document authorization attributes: %w", err)
|
return nil, fmt.Errorf("cannot query document authorization attributes: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return map[string]string{"organization_id": organizationID.String()}, nil
|
return map[string]string{
|
||||||
|
"organization_id": organizationID.String(),
|
||||||
|
"document_status": documentStatus.String(),
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Document) LoadByID(
|
func (p *Document) LoadByID(
|
||||||
@@ -87,6 +93,8 @@ SELECT
|
|||||||
classification,
|
classification,
|
||||||
current_published_version,
|
current_published_version,
|
||||||
trust_center_visibility,
|
trust_center_visibility,
|
||||||
|
status,
|
||||||
|
archived_at,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
FROM
|
FROM
|
||||||
@@ -138,6 +146,8 @@ SELECT
|
|||||||
classification,
|
classification,
|
||||||
current_published_version,
|
current_published_version,
|
||||||
trust_center_visibility,
|
trust_center_visibility,
|
||||||
|
status,
|
||||||
|
archived_at,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
FROM
|
FROM
|
||||||
@@ -190,6 +200,8 @@ SELECT
|
|||||||
classification,
|
classification,
|
||||||
current_published_version,
|
current_published_version,
|
||||||
trust_center_visibility,
|
trust_center_visibility,
|
||||||
|
status,
|
||||||
|
archived_at,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
FROM
|
FROM
|
||||||
@@ -271,6 +283,8 @@ SELECT
|
|||||||
classification,
|
classification,
|
||||||
current_published_version,
|
current_published_version,
|
||||||
trust_center_visibility,
|
trust_center_visibility,
|
||||||
|
status,
|
||||||
|
archived_at,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
FROM
|
FROM
|
||||||
@@ -321,6 +335,8 @@ SELECT
|
|||||||
classification,
|
classification,
|
||||||
current_published_version,
|
current_published_version,
|
||||||
trust_center_visibility,
|
trust_center_visibility,
|
||||||
|
status,
|
||||||
|
archived_at,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
FROM
|
FROM
|
||||||
@@ -384,6 +400,8 @@ SELECT
|
|||||||
classification,
|
classification,
|
||||||
current_published_version,
|
current_published_version,
|
||||||
trust_center_visibility,
|
trust_center_visibility,
|
||||||
|
status,
|
||||||
|
archived_at,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
FROM
|
FROM
|
||||||
@@ -431,6 +449,8 @@ INSERT INTO
|
|||||||
classification,
|
classification,
|
||||||
current_published_version,
|
current_published_version,
|
||||||
trust_center_visibility,
|
trust_center_visibility,
|
||||||
|
status,
|
||||||
|
archived_at,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
)
|
)
|
||||||
@@ -443,6 +463,8 @@ VALUES (
|
|||||||
@classification,
|
@classification,
|
||||||
@current_published_version,
|
@current_published_version,
|
||||||
@trust_center_visibility,
|
@trust_center_visibility,
|
||||||
|
@status,
|
||||||
|
@archived_at,
|
||||||
@created_at,
|
@created_at,
|
||||||
@updated_at
|
@updated_at
|
||||||
);
|
);
|
||||||
@@ -457,6 +479,8 @@ VALUES (
|
|||||||
"classification": p.Classification,
|
"classification": p.Classification,
|
||||||
"current_published_version": p.CurrentPublishedVersion,
|
"current_published_version": p.CurrentPublishedVersion,
|
||||||
"trust_center_visibility": p.TrustCenterVisibility,
|
"trust_center_visibility": p.TrustCenterVisibility,
|
||||||
|
"status": p.Status,
|
||||||
|
"archived_at": p.ArchivedAt,
|
||||||
"created_at": p.CreatedAt,
|
"created_at": p.CreatedAt,
|
||||||
"updated_at": p.UpdatedAt,
|
"updated_at": p.UpdatedAt,
|
||||||
}
|
}
|
||||||
@@ -515,6 +539,8 @@ SET
|
|||||||
document_type = @document_type,
|
document_type = @document_type,
|
||||||
classification = @classification,
|
classification = @classification,
|
||||||
trust_center_visibility = @trust_center_visibility,
|
trust_center_visibility = @trust_center_visibility,
|
||||||
|
status = @status,
|
||||||
|
archived_at = @archived_at,
|
||||||
updated_at = @updated_at
|
updated_at = @updated_at
|
||||||
WHERE
|
WHERE
|
||||||
%s
|
%s
|
||||||
@@ -531,6 +557,8 @@ WHERE
|
|||||||
"document_type": p.DocumentType,
|
"document_type": p.DocumentType,
|
||||||
"classification": p.Classification,
|
"classification": p.Classification,
|
||||||
"trust_center_visibility": p.TrustCenterVisibility,
|
"trust_center_visibility": p.TrustCenterVisibility,
|
||||||
|
"status": p.Status,
|
||||||
|
"archived_at": p.ArchivedAt,
|
||||||
}
|
}
|
||||||
maps.Copy(args, scope.SQLArguments())
|
maps.Copy(args, scope.SQLArguments())
|
||||||
|
|
||||||
@@ -603,6 +631,8 @@ SELECT
|
|||||||
scoped_documents.classification,
|
scoped_documents.classification,
|
||||||
scoped_documents.current_published_version,
|
scoped_documents.current_published_version,
|
||||||
scoped_documents.trust_center_visibility,
|
scoped_documents.trust_center_visibility,
|
||||||
|
scoped_documents.status,
|
||||||
|
scoped_documents.archived_at,
|
||||||
scoped_documents.created_at,
|
scoped_documents.created_at,
|
||||||
scoped_documents.updated_at
|
scoped_documents.updated_at
|
||||||
FROM scoped_documents
|
FROM scoped_documents
|
||||||
@@ -692,6 +722,8 @@ SELECT
|
|||||||
scoped_documents.classification,
|
scoped_documents.classification,
|
||||||
scoped_documents.current_published_version,
|
scoped_documents.current_published_version,
|
||||||
scoped_documents.trust_center_visibility,
|
scoped_documents.trust_center_visibility,
|
||||||
|
scoped_documents.status,
|
||||||
|
scoped_documents.archived_at,
|
||||||
scoped_documents.created_at,
|
scoped_documents.created_at,
|
||||||
scoped_documents.updated_at
|
scoped_documents.updated_at
|
||||||
FROM scoped_documents
|
FROM scoped_documents
|
||||||
@@ -744,6 +776,59 @@ UPDATE documents SET deleted_at = @deleted_at WHERE %s AND id = ANY(@document_id
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *Documents) BulkArchive(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Conn,
|
||||||
|
scope Scoper,
|
||||||
|
) error {
|
||||||
|
q := `
|
||||||
|
UPDATE documents SET status = 'ARCHIVED', archived_at = @archived_at, trust_center_visibility = 'NONE' WHERE %s AND id = ANY(@document_ids)
|
||||||
|
`
|
||||||
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||||
|
|
||||||
|
ids := make([]gid.GID, len(*p))
|
||||||
|
for i, doc := range *p {
|
||||||
|
ids[i] = doc.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
args := pgx.StrictNamedArgs{
|
||||||
|
"document_ids": ids,
|
||||||
|
"archived_at": time.Now(),
|
||||||
|
}
|
||||||
|
maps.Copy(args, scope.SQLArguments())
|
||||||
|
|
||||||
|
if _, err := conn.Exec(ctx, q, args); err != nil {
|
||||||
|
return fmt.Errorf("cannot bulk archive documents: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Documents) BulkUnarchive(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Conn,
|
||||||
|
scope Scoper,
|
||||||
|
) error {
|
||||||
|
q := `
|
||||||
|
UPDATE documents SET status = 'ACTIVE', archived_at = NULL WHERE %s AND id = ANY(@document_ids)
|
||||||
|
`
|
||||||
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||||
|
|
||||||
|
ids := make([]gid.GID, len(*p))
|
||||||
|
for i, doc := range *p {
|
||||||
|
ids[i] = doc.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
args := pgx.StrictNamedArgs{
|
||||||
|
"document_ids": ids,
|
||||||
|
}
|
||||||
|
maps.Copy(args, scope.SQLArguments())
|
||||||
|
|
||||||
|
if _, err := conn.Exec(ctx, q, args); err != nil {
|
||||||
|
return fmt.Errorf("cannot bulk unarchive documents: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Document) IsLastSignableVersionSignedByUserEmail(
|
func (p *Document) IsLastSignableVersionSignedByUserEmail(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
conn pg.Conn,
|
conn pg.Conn,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type (
|
|||||||
published *bool
|
published *bool
|
||||||
userEmail *mail.Addr
|
userEmail *mail.Addr
|
||||||
documentTypes []DocumentType
|
documentTypes []DocumentType
|
||||||
|
status []DocumentStatus
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ func NewDocumentTrustCenterFilter() *DocumentFilter {
|
|||||||
TrustCenterVisibilityPublic,
|
TrustCenterVisibilityPublic,
|
||||||
},
|
},
|
||||||
published: &published,
|
published: &published,
|
||||||
|
status: []DocumentStatus{DocumentStatusActive},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +63,11 @@ func (f *DocumentFilter) WithDocumentTypes(documentTypes []DocumentType) *Docume
|
|||||||
return f
|
return f
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *DocumentFilter) WithStatus(status []DocumentStatus) *DocumentFilter {
|
||||||
|
f.status = status
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
func (f *DocumentFilter) SQLArguments() pgx.NamedArgs {
|
func (f *DocumentFilter) SQLArguments() pgx.NamedArgs {
|
||||||
var visibilities []string
|
var visibilities []string
|
||||||
if f.trustCenterVisibilities != nil {
|
if f.trustCenterVisibilities != nil {
|
||||||
@@ -78,12 +85,21 @@ func (f *DocumentFilter) SQLArguments() pgx.NamedArgs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var status []string
|
||||||
|
if f.status != nil {
|
||||||
|
status = make([]string, len(f.status))
|
||||||
|
for i, s := range f.status {
|
||||||
|
status[i] = s.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return pgx.NamedArgs{
|
return pgx.NamedArgs{
|
||||||
"query": f.query,
|
"query": f.query,
|
||||||
"trust_center_visibilities": visibilities,
|
"trust_center_visibilities": visibilities,
|
||||||
"published": f.published,
|
"published": f.published,
|
||||||
"user_email": f.userEmail,
|
"user_email": f.userEmail,
|
||||||
"document_types": documentTypes,
|
"document_types": documentTypes,
|
||||||
|
"document_status": status,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,5 +147,10 @@ func (f *DocumentFilter) SQLFragment() string {
|
|||||||
document_type = ANY(@document_types::document_type[])
|
document_type = ANY(@document_types::document_type[])
|
||||||
ELSE TRUE
|
ELSE TRUE
|
||||||
END
|
END
|
||||||
|
AND
|
||||||
|
CASE
|
||||||
|
WHEN @document_status::text[] IS NULL THEN TRUE
|
||||||
|
ELSE status::text = ANY(@document_status::text[])
|
||||||
|
END
|
||||||
)`
|
)`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,56 +19,43 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type DocumentStatus string
|
||||||
DocumentStatus uint8
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DocumentStatusDraft DocumentStatus = iota
|
DocumentStatusActive DocumentStatus = "ACTIVE"
|
||||||
DocumentStatusPublished
|
DocumentStatusArchived DocumentStatus = "ARCHIVED"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (ps DocumentStatus) MarshalText() ([]byte, error) {
|
func (s DocumentStatus) IsValid() bool {
|
||||||
return []byte(ps.String()), nil
|
switch s {
|
||||||
|
case DocumentStatusActive, DocumentStatusArchived:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ps *DocumentStatus) UnmarshalText(data []byte) error {
|
func (s DocumentStatus) String() string { return string(s) }
|
||||||
val := string(data)
|
|
||||||
|
|
||||||
switch val {
|
func (s *DocumentStatus) UnmarshalText(text []byte) error {
|
||||||
case DocumentStatusDraft.String():
|
*s = DocumentStatus(text)
|
||||||
*ps = DocumentStatusDraft
|
if !s.IsValid() {
|
||||||
case DocumentStatusPublished.String():
|
return fmt.Errorf("%s is not a valid DocumentStatus", string(text))
|
||||||
*ps = DocumentStatusPublished
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("invalid DocumentStatus value: %q", val)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ps DocumentStatus) String() string {
|
func (s DocumentStatus) MarshalText() ([]byte, error) {
|
||||||
var val string
|
return []byte(s.String()), nil
|
||||||
|
|
||||||
switch ps {
|
|
||||||
case DocumentStatusDraft:
|
|
||||||
val = "DRAFT"
|
|
||||||
case DocumentStatusPublished:
|
|
||||||
val = "PUBLISHED"
|
|
||||||
}
|
|
||||||
|
|
||||||
return val
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ps *DocumentStatus) Scan(value any) error {
|
func (s *DocumentStatus) Scan(value any) error {
|
||||||
val, ok := value.(string)
|
val, ok := value.(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("invalid scan source for DocumentStatus, expected string got %T", value)
|
return fmt.Errorf("invalid scan source for DocumentStatus, expected string got %T", value)
|
||||||
}
|
}
|
||||||
|
return s.UnmarshalText([]byte(val))
|
||||||
return ps.UnmarshalText([]byte(val))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ps DocumentStatus) Value() (driver.Value, error) {
|
func (s DocumentStatus) Value() (driver.Value, error) {
|
||||||
return ps.String(), nil
|
return s.String(), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ type (
|
|||||||
Classification DocumentClassification `db:"classification"`
|
Classification DocumentClassification `db:"classification"`
|
||||||
Content string `db:"content"`
|
Content string `db:"content"`
|
||||||
Changelog string `db:"changelog"`
|
Changelog string `db:"changelog"`
|
||||||
Status DocumentStatus `db:"status"`
|
Status DocumentVersionStatus `db:"status"`
|
||||||
PublishedAt *time.Time `db:"published_at"`
|
PublishedAt *time.Time `db:"published_at"`
|
||||||
CreatedAt time.Time `db:"created_at"`
|
CreatedAt time.Time `db:"created_at"`
|
||||||
UpdatedAt time.Time `db:"updated_at"`
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
@@ -49,17 +49,29 @@ type (
|
|||||||
|
|
||||||
// AuthorizationAttributes returns the authorization attributes for policy evaluation.
|
// AuthorizationAttributes returns the authorization attributes for policy evaluation.
|
||||||
func (dv *DocumentVersion) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (map[string]string, error) {
|
func (dv *DocumentVersion) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (map[string]string, error) {
|
||||||
q := `SELECT organization_id FROM document_versions WHERE id = $1 LIMIT 1;`
|
q := `
|
||||||
|
SELECT
|
||||||
|
dv.organization_id,
|
||||||
|
d.status
|
||||||
|
FROM document_versions dv
|
||||||
|
INNER JOIN documents d ON d.id = dv.document_id
|
||||||
|
WHERE dv.id = $1
|
||||||
|
LIMIT 1;
|
||||||
|
`
|
||||||
|
|
||||||
var organizationID gid.GID
|
var organizationID gid.GID
|
||||||
if err := conn.QueryRow(ctx, q, dv.ID).Scan(&organizationID); err != nil {
|
var documentStatus DocumentStatus
|
||||||
|
if err := conn.QueryRow(ctx, q, dv.ID).Scan(&organizationID, &documentStatus); err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, ErrResourceNotFound
|
return nil, ErrResourceNotFound
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("cannot query document version authorization attributes: %w", err)
|
return nil, fmt.Errorf("cannot query document version authorization attributes: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return map[string]string{"organization_id": organizationID.String()}, nil
|
return map[string]string{
|
||||||
|
"organization_id": organizationID.String(),
|
||||||
|
"document_status": documentStatus.String(),
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (dv *DocumentVersions) LoadByDocumentID(
|
func (dv *DocumentVersions) LoadByDocumentID(
|
||||||
@@ -377,7 +389,7 @@ LIMIT 1;
|
|||||||
|
|
||||||
args := pgx.StrictNamedArgs{
|
args := pgx.StrictNamedArgs{
|
||||||
"document_id": documentID,
|
"document_id": documentID,
|
||||||
"status": DocumentStatusPublished,
|
"status": DocumentVersionStatusPublished,
|
||||||
}
|
}
|
||||||
maps.Copy(args, scope.SQLArguments())
|
maps.Copy(args, scope.SQLArguments())
|
||||||
|
|
||||||
|
|||||||
74
pkg/coredata/document_version_status.go
Normal file
74
pkg/coredata/document_version_status.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// Copyright (c) 2025 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 (
|
||||||
|
DocumentVersionStatus uint8
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DocumentVersionStatusDraft DocumentVersionStatus = iota
|
||||||
|
DocumentVersionStatusPublished
|
||||||
|
)
|
||||||
|
|
||||||
|
func (ps DocumentVersionStatus) MarshalText() ([]byte, error) {
|
||||||
|
return []byte(ps.String()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ps *DocumentVersionStatus) UnmarshalText(data []byte) error {
|
||||||
|
val := string(data)
|
||||||
|
|
||||||
|
switch val {
|
||||||
|
case DocumentVersionStatusDraft.String():
|
||||||
|
*ps = DocumentVersionStatusDraft
|
||||||
|
case DocumentVersionStatusPublished.String():
|
||||||
|
*ps = DocumentVersionStatusPublished
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid DocumentVersionStatus value: %q", val)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ps DocumentVersionStatus) String() string {
|
||||||
|
var val string
|
||||||
|
|
||||||
|
switch ps {
|
||||||
|
case DocumentVersionStatusDraft:
|
||||||
|
val = "DRAFT"
|
||||||
|
case DocumentVersionStatusPublished:
|
||||||
|
val = "PUBLISHED"
|
||||||
|
}
|
||||||
|
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ps *DocumentVersionStatus) Scan(value any) error {
|
||||||
|
val, ok := value.(string)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("invalid scan source for DocumentVersionStatus, expected string got %T", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ps.UnmarshalText([]byte(val))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ps DocumentVersionStatus) Value() (driver.Value, error) {
|
||||||
|
return ps.String(), nil
|
||||||
|
}
|
||||||
7
pkg/coredata/migrations/20260317T120000Z.sql
Normal file
7
pkg/coredata/migrations/20260317T120000Z.sql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
ALTER TYPE policy_status RENAME TO document_version_status;
|
||||||
|
|
||||||
|
CREATE TYPE document_status AS ENUM ('ACTIVE', 'ARCHIVED');
|
||||||
|
|
||||||
|
ALTER TABLE documents ADD COLUMN archived_at TIMESTAMP WITH TIME ZONE;
|
||||||
|
ALTER TABLE documents ADD COLUMN status document_status NOT NULL DEFAULT 'ACTIVE';
|
||||||
|
ALTER TABLE documents ALTER COLUMN status DROP DEFAULT;
|
||||||
@@ -99,3 +99,33 @@ WHERE
|
|||||||
_, err := conn.Exec(ctx, q, args)
|
_, err := conn.Exec(ctx, q, args)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (rp RiskDocument) DeleteByDocumentIDs(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Conn,
|
||||||
|
scope Scoper,
|
||||||
|
documentIDs []gid.GID,
|
||||||
|
) error {
|
||||||
|
q := `
|
||||||
|
DELETE
|
||||||
|
FROM
|
||||||
|
risks_documents
|
||||||
|
WHERE
|
||||||
|
%s
|
||||||
|
AND document_id = ANY(@document_ids);
|
||||||
|
`
|
||||||
|
|
||||||
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||||
|
|
||||||
|
args := pgx.StrictNamedArgs{
|
||||||
|
"document_ids": documentIDs,
|
||||||
|
}
|
||||||
|
maps.Copy(args, scope.SQLArguments())
|
||||||
|
|
||||||
|
if _, err := conn.Exec(ctx, q, args); err != nil {
|
||||||
|
return fmt.Errorf("cannot delete risk document mappings by document ids: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -181,6 +181,8 @@ const (
|
|||||||
ActionDocumentUpdate = "core:document:update"
|
ActionDocumentUpdate = "core:document:update"
|
||||||
ActionDocumentDelete = "core:document:delete"
|
ActionDocumentDelete = "core:document:delete"
|
||||||
ActionDocumentChangelogGenerate = "core:document:generate-changelog"
|
ActionDocumentChangelogGenerate = "core:document:generate-changelog"
|
||||||
|
ActionDocumentArchive = "core:document:archive"
|
||||||
|
ActionDocumentUnarchive = "core:document:unarchive"
|
||||||
ActionDocumentDraftVersionCreate = "core:document:create-draft-version"
|
ActionDocumentDraftVersionCreate = "core:document:create-draft-version"
|
||||||
ActionDocumentSendSigningNotifications = "core:document:send-signing-notifications"
|
ActionDocumentSendSigningNotifications = "core:document:send-signing-notifications"
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ type (
|
|||||||
ErrDocumentVersionNotDraft struct {
|
ErrDocumentVersionNotDraft struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ErrDocumentArchived struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrDocumentNotArchived struct {
|
||||||
|
}
|
||||||
|
|
||||||
ErrDocumentVersionSignatureAlreadySigned struct {
|
ErrDocumentVersionSignatureAlreadySigned struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,6 +170,14 @@ func (e ErrDocumentVersionNotDraft) Error() string {
|
|||||||
return "cannot update a published document version"
|
return "cannot update a published document version"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e ErrDocumentArchived) Error() string {
|
||||||
|
return "cannot modify an archived document"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ErrDocumentNotArchived) Error() string {
|
||||||
|
return "cannot unarchive a document that is not archived"
|
||||||
|
}
|
||||||
|
|
||||||
func (e ErrDocumentVersionSignatureAlreadySigned) Error() string {
|
func (e ErrDocumentVersionSignatureAlreadySigned) Error() string {
|
||||||
return "document version signature already signed"
|
return "document version signature already signed"
|
||||||
}
|
}
|
||||||
@@ -331,7 +345,7 @@ func (s DocumentService) GenerateChangelog(
|
|||||||
return fmt.Errorf("cannot load draft version: %w", err)
|
return fmt.Errorf("cannot load draft version: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if draftVersion.Status != coredata.DocumentStatusDraft {
|
if draftVersion.Status != coredata.DocumentVersionStatusDraft {
|
||||||
return fmt.Errorf("latest version is not a draft")
|
return fmt.Errorf("latest version is not a draft")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,15 +464,19 @@ func (s *DocumentService) publishVersionInTx(
|
|||||||
return nil, nil, fmt.Errorf("cannot load document %q: %w", documentID, err)
|
return nil, nil, fmt.Errorf("cannot load document %q: %w", documentID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if document.ArchivedAt != nil {
|
||||||
|
return nil, nil, &ErrDocumentArchived{}
|
||||||
|
}
|
||||||
|
|
||||||
if err := documentVersion.LoadLatestVersion(ctx, tx, s.svc.scope, documentID); err != nil {
|
if err := documentVersion.LoadLatestVersion(ctx, tx, s.svc.scope, documentID); err != nil {
|
||||||
return nil, nil, fmt.Errorf("cannot load current draft: %w", err)
|
return nil, nil, fmt.Errorf("cannot load current draft: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if ignoreExisting && documentVersion.Status == coredata.DocumentStatusPublished {
|
if ignoreExisting && documentVersion.Status == coredata.DocumentVersionStatusPublished {
|
||||||
return document, documentVersion, nil
|
return document, documentVersion, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if documentVersion.Status != coredata.DocumentStatusDraft {
|
if documentVersion.Status != coredata.DocumentVersionStatusDraft {
|
||||||
return nil, nil, fmt.Errorf("cannot publish version")
|
return nil, nil, fmt.Errorf("cannot publish version")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,7 +497,7 @@ func (s *DocumentService) publishVersionInTx(
|
|||||||
document.CurrentPublishedVersion = &documentVersion.VersionNumber
|
document.CurrentPublishedVersion = &documentVersion.VersionNumber
|
||||||
document.UpdatedAt = now
|
document.UpdatedAt = now
|
||||||
|
|
||||||
documentVersion.Status = coredata.DocumentStatusPublished
|
documentVersion.Status = coredata.DocumentVersionStatusPublished
|
||||||
documentVersion.PublishedAt = &now
|
documentVersion.PublishedAt = &now
|
||||||
documentVersion.UpdatedAt = now
|
documentVersion.UpdatedAt = now
|
||||||
|
|
||||||
@@ -514,6 +532,7 @@ func (s *DocumentService) Create(
|
|||||||
DocumentType: req.DocumentType,
|
DocumentType: req.DocumentType,
|
||||||
TrustCenterVisibility: coredata.TrustCenterVisibilityNone,
|
TrustCenterVisibility: coredata.TrustCenterVisibilityNone,
|
||||||
Classification: req.Classification,
|
Classification: req.Classification,
|
||||||
|
Status: coredata.DocumentStatusActive,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
@@ -528,7 +547,7 @@ func (s *DocumentService) Create(
|
|||||||
Title: req.Title,
|
Title: req.Title,
|
||||||
VersionNumber: 1,
|
VersionNumber: 1,
|
||||||
Content: req.Content,
|
Content: req.Content,
|
||||||
Status: coredata.DocumentStatusDraft,
|
Status: coredata.DocumentVersionStatusDraft,
|
||||||
Classification: req.Classification,
|
Classification: req.Classification,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
@@ -741,7 +760,7 @@ func (s *DocumentService) signDocumentVersionInTx(
|
|||||||
return nil, fmt.Errorf("cannot load document version %q: %w", documentVersionID, err)
|
return nil, fmt.Errorf("cannot load document version %q: %w", documentVersionID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if documentVersion.Status != coredata.DocumentStatusPublished {
|
if documentVersion.Status != coredata.DocumentVersionStatusPublished {
|
||||||
return nil, fmt.Errorf("cannot sign unpublished version")
|
return nil, fmt.Errorf("cannot sign unpublished version")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -790,7 +809,11 @@ func (s *DocumentService) UpdateVersion(
|
|||||||
return fmt.Errorf("cannot load document %q: %w", documentVersion.DocumentID, err)
|
return fmt.Errorf("cannot load document %q: %w", documentVersion.DocumentID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if documentVersion.Status != coredata.DocumentStatusDraft {
|
if document.ArchivedAt != nil {
|
||||||
|
return &ErrDocumentArchived{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if documentVersion.Status != coredata.DocumentVersionStatusDraft {
|
||||||
return &ErrDocumentVersionNotDraft{}
|
return &ErrDocumentVersionNotDraft{}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -871,7 +894,7 @@ func (s *DocumentService) BulkRequestSignatures(
|
|||||||
return fmt.Errorf("cannot load latest version for document %q: %w", documentID, err)
|
return fmt.Errorf("cannot load latest version for document %q: %w", documentID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if documentVersion.Status != coredata.DocumentStatusPublished {
|
if documentVersion.Status != coredata.DocumentVersionStatusPublished {
|
||||||
return fmt.Errorf("cannot request signature for unpublished document %q", documentID)
|
return fmt.Errorf("cannot request signature for unpublished document %q", documentID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -948,7 +971,7 @@ func (s *DocumentService) RequestSignature(
|
|||||||
return nil, fmt.Errorf("cannot get document version: %w", err)
|
return nil, fmt.Errorf("cannot get document version: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if documentVersion.Status != coredata.DocumentStatusPublished {
|
if documentVersion.Status != coredata.DocumentVersionStatusPublished {
|
||||||
return nil, fmt.Errorf("cannot request signature for unpublished version")
|
return nil, fmt.Errorf("cannot request signature for unpublished version")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1046,7 +1069,7 @@ func (s *DocumentService) CreateDraft(
|
|||||||
return fmt.Errorf("cannot load latest version: %w", err)
|
return fmt.Errorf("cannot load latest version: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if latestVersion.Status != coredata.DocumentStatusPublished {
|
if latestVersion.Status != coredata.DocumentVersionStatusPublished {
|
||||||
return fmt.Errorf("cannot create draft from unpublished version")
|
return fmt.Errorf("cannot create draft from unpublished version")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1057,7 +1080,7 @@ func (s *DocumentService) CreateDraft(
|
|||||||
draftVersion.VersionNumber = latestVersion.VersionNumber + 1
|
draftVersion.VersionNumber = latestVersion.VersionNumber + 1
|
||||||
draftVersion.Classification = document.Classification
|
draftVersion.Classification = document.Classification
|
||||||
draftVersion.Content = latestVersion.Content
|
draftVersion.Content = latestVersion.Content
|
||||||
draftVersion.Status = coredata.DocumentStatusDraft
|
draftVersion.Status = coredata.DocumentVersionStatusDraft
|
||||||
draftVersion.CreatedAt = now
|
draftVersion.CreatedAt = now
|
||||||
draftVersion.UpdatedAt = now
|
draftVersion.UpdatedAt = now
|
||||||
|
|
||||||
@@ -1106,7 +1129,7 @@ func (s *DocumentService) DeleteDraft(
|
|||||||
return fmt.Errorf("cannot load document version: %w", err)
|
return fmt.Errorf("cannot load document version: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if documentVersion.Status != coredata.DocumentStatusDraft {
|
if documentVersion.Status != coredata.DocumentVersionStatusDraft {
|
||||||
return fmt.Errorf("cannot delete published document version")
|
return fmt.Errorf("cannot delete published document version")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1155,6 +1178,52 @@ func (s *DocumentService) BulkSoftDelete(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *DocumentService) BulkArchive(
|
||||||
|
ctx context.Context,
|
||||||
|
documentIDs []gid.GID,
|
||||||
|
) error {
|
||||||
|
documents := coredata.Documents{}
|
||||||
|
|
||||||
|
for _, documentID := range documentIDs {
|
||||||
|
documents = append(documents, &coredata.Document{ID: documentID})
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.svc.pg.WithTx(
|
||||||
|
ctx,
|
||||||
|
func(tx pg.Conn) error {
|
||||||
|
controlDocument := coredata.ControlDocument{}
|
||||||
|
if err := controlDocument.DeleteByDocumentIDs(ctx, tx, s.svc.scope, documentIDs); err != nil {
|
||||||
|
return fmt.Errorf("cannot delete control mappings: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
riskDocument := coredata.RiskDocument{}
|
||||||
|
if err := riskDocument.DeleteByDocumentIDs(ctx, tx, s.svc.scope, documentIDs); err != nil {
|
||||||
|
return fmt.Errorf("cannot delete risk mappings: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return documents.BulkArchive(ctx, tx, s.svc.scope)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DocumentService) BulkUnarchive(
|
||||||
|
ctx context.Context,
|
||||||
|
documentIDs []gid.GID,
|
||||||
|
) error {
|
||||||
|
documents := coredata.Documents{}
|
||||||
|
|
||||||
|
for _, documentID := range documentIDs {
|
||||||
|
documents = append(documents, &coredata.Document{ID: documentID})
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.svc.pg.WithConn(
|
||||||
|
ctx,
|
||||||
|
func(conn pg.Conn) error {
|
||||||
|
return documents.BulkUnarchive(ctx, conn, s.svc.scope)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *DocumentService) RequestExport(
|
func (s *DocumentService) RequestExport(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
documentIDs []gid.GID,
|
documentIDs []gid.GID,
|
||||||
@@ -1519,6 +1588,10 @@ func (s *DocumentService) Update(
|
|||||||
return fmt.Errorf("cannot load document %q: %w", req.DocumentID, err)
|
return fmt.Errorf("cannot load document %q: %w", req.DocumentID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if document.ArchivedAt != nil {
|
||||||
|
return &ErrDocumentArchived{}
|
||||||
|
}
|
||||||
|
|
||||||
if req.Title != nil {
|
if req.Title != nil {
|
||||||
document.Title = *req.Title
|
document.Title = *req.Title
|
||||||
}
|
}
|
||||||
@@ -1574,7 +1647,7 @@ func (s *DocumentService) Update(
|
|||||||
|
|
||||||
draftVersion := &coredata.DocumentVersion{}
|
draftVersion := &coredata.DocumentVersion{}
|
||||||
err := draftVersion.LoadLatestVersion(ctx, tx, s.svc.scope, req.DocumentID)
|
err := draftVersion.LoadLatestVersion(ctx, tx, s.svc.scope, req.DocumentID)
|
||||||
if err == nil && draftVersion.Status == coredata.DocumentStatusDraft {
|
if err == nil && draftVersion.Status == coredata.DocumentVersionStatusDraft {
|
||||||
draftVersion.Title = document.Title
|
draftVersion.Title = document.Title
|
||||||
draftVersion.Classification = document.Classification
|
draftVersion.Classification = document.Classification
|
||||||
draftVersion.UpdatedAt = now
|
draftVersion.UpdatedAt = now
|
||||||
@@ -1614,6 +1687,91 @@ func (s *DocumentService) Update(
|
|||||||
return document, nil
|
return document, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *DocumentService) Archive(
|
||||||
|
ctx context.Context,
|
||||||
|
documentID gid.GID,
|
||||||
|
) (*coredata.Document, error) {
|
||||||
|
document := &coredata.Document{}
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
err := s.svc.pg.WithTx(
|
||||||
|
ctx,
|
||||||
|
func(tx pg.Conn) error {
|
||||||
|
if err := document.LoadByID(ctx, tx, s.svc.scope, documentID); err != nil {
|
||||||
|
return fmt.Errorf("cannot load document %q: %w", documentID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if document.ArchivedAt != nil {
|
||||||
|
return &ErrDocumentArchived{}
|
||||||
|
}
|
||||||
|
|
||||||
|
controlDocument := coredata.ControlDocument{}
|
||||||
|
if err := controlDocument.DeleteByDocumentIDs(ctx, tx, s.svc.scope, []gid.GID{documentID}); err != nil {
|
||||||
|
return fmt.Errorf("cannot delete control mappings: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
riskDocument := coredata.RiskDocument{}
|
||||||
|
if err := riskDocument.DeleteByDocumentIDs(ctx, tx, s.svc.scope, []gid.GID{documentID}); err != nil {
|
||||||
|
return fmt.Errorf("cannot delete risk mappings: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.Status = coredata.DocumentStatusArchived
|
||||||
|
document.ArchivedAt = &now
|
||||||
|
document.UpdatedAt = now
|
||||||
|
document.TrustCenterVisibility = coredata.TrustCenterVisibilityNone
|
||||||
|
|
||||||
|
if err := document.Update(ctx, tx, s.svc.scope); err != nil {
|
||||||
|
return fmt.Errorf("cannot archive document: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return document, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DocumentService) Unarchive(
|
||||||
|
ctx context.Context,
|
||||||
|
documentID gid.GID,
|
||||||
|
) (*coredata.Document, error) {
|
||||||
|
document := &coredata.Document{}
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
err := s.svc.pg.WithTx(
|
||||||
|
ctx,
|
||||||
|
func(tx pg.Conn) error {
|
||||||
|
if err := document.LoadByID(ctx, tx, s.svc.scope, documentID); err != nil {
|
||||||
|
return fmt.Errorf("cannot load document %q: %w", documentID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if document.ArchivedAt == nil {
|
||||||
|
return &ErrDocumentNotArchived{}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.Status = coredata.DocumentStatusActive
|
||||||
|
document.ArchivedAt = nil
|
||||||
|
document.UpdatedAt = now
|
||||||
|
|
||||||
|
if err := document.Update(ctx, tx, s.svc.scope); err != nil {
|
||||||
|
return fmt.Errorf("cannot unarchive document: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return document, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *DocumentService) CancelSignatureRequest(
|
func (s *DocumentService) CancelSignatureRequest(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
documentVersionSignatureID gid.GID,
|
documentVersionSignatureID gid.GID,
|
||||||
|
|||||||
@@ -19,12 +19,37 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/iam/policy"
|
"go.probo.inc/probo/pkg/iam/policy"
|
||||||
)
|
)
|
||||||
|
|
||||||
var organizationCondition = policy.Equals("principal.organization_id", "resource.organization_id")
|
var (
|
||||||
|
organizationCondition = policy.Equals("principal.organization_id", "resource.organization_id")
|
||||||
|
documentWriteActiveOnly = policy.Deny(
|
||||||
|
ActionDocumentUpdate,
|
||||||
|
ActionDocumentArchive,
|
||||||
|
ActionDocumentDraftVersionCreate,
|
||||||
|
ActionDocumentChangelogGenerate,
|
||||||
|
ActionDocumentSendSigningNotifications,
|
||||||
|
ActionDocumentVersionUpdate,
|
||||||
|
ActionDocumentVersionPublish,
|
||||||
|
ActionDocumentVersionDeleteDraft,
|
||||||
|
ActionDocumentVersionSignatureRequest,
|
||||||
|
ActionDocumentVersionCancelSignature,
|
||||||
|
).WithSID("document-write-active-only").When(
|
||||||
|
organizationCondition,
|
||||||
|
policy.Equals("resource.document_status", "ARCHIVED"),
|
||||||
|
)
|
||||||
|
documentUnarchiveArchivedOnly = policy.Deny(
|
||||||
|
ActionDocumentUnarchive,
|
||||||
|
).WithSID("document-unarchive-archived-only").When(
|
||||||
|
organizationCondition,
|
||||||
|
policy.Equals("resource.document_status", "ACTIVE"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
// OwnerPolicy defines permissions for organization owners.
|
// OwnerPolicy defines permissions for organization owners.
|
||||||
var OwnerPolicy = policy.NewPolicy(
|
var OwnerPolicy = policy.NewPolicy(
|
||||||
"probo:owner",
|
"probo:owner",
|
||||||
"Probo Owner",
|
"Probo Owner",
|
||||||
|
documentWriteActiveOnly,
|
||||||
|
documentUnarchiveArchivedOnly,
|
||||||
policy.Allow("core:*").WithSID("full-core-access").When(organizationCondition),
|
policy.Allow("core:*").WithSID("full-core-access").When(organizationCondition),
|
||||||
).WithDescription("Full probo access for organization owners")
|
).WithDescription("Full probo access for organization owners")
|
||||||
|
|
||||||
@@ -32,6 +57,8 @@ var OwnerPolicy = policy.NewPolicy(
|
|||||||
var AdminPolicy = policy.NewPolicy(
|
var AdminPolicy = policy.NewPolicy(
|
||||||
"probo:admin",
|
"probo:admin",
|
||||||
"Probo Admin",
|
"Probo Admin",
|
||||||
|
documentWriteActiveOnly,
|
||||||
|
documentUnarchiveArchivedOnly,
|
||||||
policy.Allow("core:*").WithSID("full-core-access").When(organizationCondition),
|
policy.Allow("core:*").WithSID("full-core-access").When(organizationCondition),
|
||||||
).WithDescription("Probo admin access - can manage core entities")
|
).WithDescription("Probo admin access - can manage core entities")
|
||||||
|
|
||||||
|
|||||||
@@ -69,15 +69,26 @@ enum EvidenceState
|
|||||||
@goEnum(value: "go.probo.inc/probo/pkg/coredata.EvidenceStateRequested")
|
@goEnum(value: "go.probo.inc/probo/pkg/coredata.EvidenceStateRequested")
|
||||||
}
|
}
|
||||||
|
|
||||||
enum DocumentStatus
|
enum DocumentVersionStatus
|
||||||
@goModel(model: "go.probo.inc/probo/pkg/coredata.DocumentStatus") {
|
@goModel(model: "go.probo.inc/probo/pkg/coredata.DocumentVersionStatus") {
|
||||||
DRAFT @goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentStatusDraft")
|
DRAFT
|
||||||
|
@goEnum(
|
||||||
|
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionStatusDraft"
|
||||||
|
)
|
||||||
PUBLISHED
|
PUBLISHED
|
||||||
@goEnum(
|
@goEnum(
|
||||||
value: "go.probo.inc/probo/pkg/coredata.DocumentStatusPublished"
|
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionStatusPublished"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum DocumentStatus
|
||||||
|
@goModel(model: "go.probo.inc/probo/pkg/coredata.DocumentStatus") {
|
||||||
|
ACTIVE
|
||||||
|
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentStatusActive")
|
||||||
|
ARCHIVED
|
||||||
|
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentStatusArchived")
|
||||||
|
}
|
||||||
|
|
||||||
enum EvidenceType
|
enum EvidenceType
|
||||||
@goModel(model: "go.probo.inc/probo/pkg/coredata.EvidenceType") {
|
@goModel(model: "go.probo.inc/probo/pkg/coredata.EvidenceType") {
|
||||||
FILE @goEnum(value: "go.probo.inc/probo/pkg/coredata.EvidenceTypeFile")
|
FILE @goEnum(value: "go.probo.inc/probo/pkg/coredata.EvidenceTypeFile")
|
||||||
@@ -1569,7 +1580,7 @@ input ApplicabilityStatementOrder
|
|||||||
}
|
}
|
||||||
|
|
||||||
input DocumentVersionFilter {
|
input DocumentVersionFilter {
|
||||||
status: DocumentStatus
|
status: DocumentVersionStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
# Input Types for Filtering
|
# Input Types for Filtering
|
||||||
@@ -1580,6 +1591,7 @@ input ControlFilter {
|
|||||||
input DocumentFilter {
|
input DocumentFilter {
|
||||||
query: String
|
query: String
|
||||||
documentTypes: [DocumentType!]
|
documentTypes: [DocumentType!]
|
||||||
|
status: [DocumentStatus!]
|
||||||
}
|
}
|
||||||
|
|
||||||
input MeasureFilter {
|
input MeasureFilter {
|
||||||
@@ -2402,6 +2414,9 @@ type Document implements Node {
|
|||||||
filter: ControlFilter
|
filter: ControlFilter
|
||||||
): ControlConnection! @goField(forceResolver: true)
|
): ControlConnection! @goField(forceResolver: true)
|
||||||
|
|
||||||
|
status: DocumentStatus!
|
||||||
|
archivedAt: Datetime
|
||||||
|
|
||||||
createdAt: Datetime!
|
createdAt: Datetime!
|
||||||
updatedAt: Datetime!
|
updatedAt: Datetime!
|
||||||
|
|
||||||
@@ -3657,6 +3672,8 @@ type Mutation {
|
|||||||
# Document mutations
|
# Document mutations
|
||||||
createDocument(input: CreateDocumentInput!): CreateDocumentPayload!
|
createDocument(input: CreateDocumentInput!): CreateDocumentPayload!
|
||||||
updateDocument(input: UpdateDocumentInput!): UpdateDocumentPayload!
|
updateDocument(input: UpdateDocumentInput!): UpdateDocumentPayload!
|
||||||
|
archiveDocument(input: ArchiveDocumentInput!): ArchiveDocumentPayload!
|
||||||
|
unarchiveDocument(input: UnarchiveDocumentInput!): UnarchiveDocumentPayload!
|
||||||
deleteDocument(input: DeleteDocumentInput!): DeleteDocumentPayload!
|
deleteDocument(input: DeleteDocumentInput!): DeleteDocumentPayload!
|
||||||
# Meeting mutations
|
# Meeting mutations
|
||||||
createMeeting(input: CreateMeetingInput!): CreateMeetingPayload!
|
createMeeting(input: CreateMeetingInput!): CreateMeetingPayload!
|
||||||
@@ -3694,6 +3711,12 @@ type Mutation {
|
|||||||
bulkDeleteDocuments(
|
bulkDeleteDocuments(
|
||||||
input: BulkDeleteDocumentsInput!
|
input: BulkDeleteDocumentsInput!
|
||||||
): BulkDeleteDocumentsPayload!
|
): BulkDeleteDocumentsPayload!
|
||||||
|
bulkArchiveDocuments(
|
||||||
|
input: BulkArchiveDocumentsInput!
|
||||||
|
): BulkArchiveDocumentsPayload!
|
||||||
|
bulkUnarchiveDocuments(
|
||||||
|
input: BulkUnarchiveDocumentsInput!
|
||||||
|
): BulkUnarchiveDocumentsPayload!
|
||||||
bulkExportDocuments(
|
bulkExportDocuments(
|
||||||
input: BulkExportDocumentsInput!
|
input: BulkExportDocumentsInput!
|
||||||
): BulkExportDocumentsPayload!
|
): BulkExportDocumentsPayload!
|
||||||
@@ -4367,6 +4390,14 @@ input ExportTransferImpactAssessmentsPDFInput {
|
|||||||
filter: TransferImpactAssessmentFilter
|
filter: TransferImpactAssessmentFilter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input ArchiveDocumentInput {
|
||||||
|
documentId: ID!
|
||||||
|
}
|
||||||
|
|
||||||
|
input UnarchiveDocumentInput {
|
||||||
|
documentId: ID!
|
||||||
|
}
|
||||||
|
|
||||||
input DeleteDocumentInput {
|
input DeleteDocumentInput {
|
||||||
documentId: ID!
|
documentId: ID!
|
||||||
}
|
}
|
||||||
@@ -5086,6 +5117,14 @@ type UpdateDocumentPayload {
|
|||||||
document: Document!
|
document: Document!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ArchiveDocumentPayload {
|
||||||
|
document: Document!
|
||||||
|
}
|
||||||
|
|
||||||
|
type UnarchiveDocumentPayload {
|
||||||
|
document: Document!
|
||||||
|
}
|
||||||
|
|
||||||
type DeleteDocumentPayload {
|
type DeleteDocumentPayload {
|
||||||
deletedDocumentId: ID!
|
deletedDocumentId: ID!
|
||||||
}
|
}
|
||||||
@@ -5197,7 +5236,7 @@ type DeleteMeasurePayload {
|
|||||||
type DocumentVersion implements Node {
|
type DocumentVersion implements Node {
|
||||||
id: ID!
|
id: ID!
|
||||||
document: Document! @goField(forceResolver: true)
|
document: Document! @goField(forceResolver: true)
|
||||||
status: DocumentStatus!
|
status: DocumentVersionStatus!
|
||||||
version: Int!
|
version: Int!
|
||||||
content: String!
|
content: String!
|
||||||
changelog: String!
|
changelog: String!
|
||||||
@@ -5326,6 +5365,22 @@ input BulkDeleteDocumentsInput {
|
|||||||
documentIds: [ID!]!
|
documentIds: [ID!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input BulkArchiveDocumentsInput {
|
||||||
|
documentIds: [ID!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
type BulkArchiveDocumentsPayload {
|
||||||
|
documents: [Document!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
input BulkUnarchiveDocumentsInput {
|
||||||
|
documentIds: [ID!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
type BulkUnarchiveDocumentsPayload {
|
||||||
|
documents: [Document!]!
|
||||||
|
}
|
||||||
|
|
||||||
input BulkExportDocumentsInput {
|
input BulkExportDocumentsInput {
|
||||||
documentIds: [ID!]!
|
documentIds: [ID!]!
|
||||||
withWatermark: Boolean!
|
withWatermark: Boolean!
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ func NewDocument(document *coredata.Document) *Document {
|
|||||||
Classification: document.Classification,
|
Classification: document.Classification,
|
||||||
CurrentPublishedVersion: document.CurrentPublishedVersion,
|
CurrentPublishedVersion: document.CurrentPublishedVersion,
|
||||||
TrustCenterVisibility: document.TrustCenterVisibility,
|
TrustCenterVisibility: document.TrustCenterVisibility,
|
||||||
|
Status: document.Status,
|
||||||
|
ArchivedAt: document.ArchivedAt,
|
||||||
CreatedAt: document.CreatedAt,
|
CreatedAt: document.CreatedAt,
|
||||||
UpdatedAt: document.UpdatedAt,
|
UpdatedAt: document.UpdatedAt,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4286,6 +4286,9 @@ func (r *mutationResolver) UpdateDocument(ctx context.Context, input types.Updat
|
|||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok {
|
||||||
|
return nil, gqlutils.Conflict(ctx, errArchived)
|
||||||
|
}
|
||||||
if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
|
if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
|
||||||
return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors)
|
return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors)
|
||||||
}
|
}
|
||||||
@@ -4298,6 +4301,50 @@ func (r *mutationResolver) UpdateDocument(ctx context.Context, input types.Updat
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ArchiveDocument is the resolver for the archiveDocument field.
|
||||||
|
func (r *mutationResolver) ArchiveDocument(ctx context.Context, input types.ArchiveDocumentInput) (*types.ArchiveDocumentPayload, error) {
|
||||||
|
if err := r.authorize(ctx, input.DocumentID, probo.ActionDocumentArchive); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
prb := r.ProboService(ctx, input.DocumentID.TenantID())
|
||||||
|
|
||||||
|
document, err := prb.Documents.Archive(ctx, input.DocumentID)
|
||||||
|
if err != nil {
|
||||||
|
if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok {
|
||||||
|
return nil, gqlutils.Conflict(ctx, errArchived)
|
||||||
|
}
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot archive document", log.Error(err))
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.ArchiveDocumentPayload{
|
||||||
|
Document: types.NewDocument(document),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnarchiveDocument is the resolver for the unarchiveDocument field.
|
||||||
|
func (r *mutationResolver) UnarchiveDocument(ctx context.Context, input types.UnarchiveDocumentInput) (*types.UnarchiveDocumentPayload, error) {
|
||||||
|
if err := r.authorize(ctx, input.DocumentID, probo.ActionDocumentUnarchive); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
prb := r.ProboService(ctx, input.DocumentID.TenantID())
|
||||||
|
|
||||||
|
document, err := prb.Documents.Unarchive(ctx, input.DocumentID)
|
||||||
|
if err != nil {
|
||||||
|
if errNotArchived, ok := errors.AsType[*probo.ErrDocumentNotArchived](err); ok {
|
||||||
|
return nil, gqlutils.Conflict(ctx, errNotArchived)
|
||||||
|
}
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot unarchive document", log.Error(err))
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.UnarchiveDocumentPayload{
|
||||||
|
Document: types.NewDocument(document),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteDocument is the resolver for the deleteDocument field.
|
// DeleteDocument is the resolver for the deleteDocument field.
|
||||||
func (r *mutationResolver) DeleteDocument(ctx context.Context, input types.DeleteDocumentInput) (*types.DeleteDocumentPayload, error) {
|
func (r *mutationResolver) DeleteDocument(ctx context.Context, input types.DeleteDocumentInput) (*types.DeleteDocumentPayload, error) {
|
||||||
if err := r.authorize(ctx, input.DocumentID, probo.ActionDocumentDelete); err != nil {
|
if err := r.authorize(ctx, input.DocumentID, probo.ActionDocumentDelete); err != nil {
|
||||||
@@ -4607,6 +4654,10 @@ func (r *mutationResolver) PublishDocumentVersion(ctx context.Context, input typ
|
|||||||
return nil, gqlutils.Invalid(ctx, errNoChanges)
|
return nil, gqlutils.Invalid(ctx, errNoChanges)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok {
|
||||||
|
return nil, gqlutils.Conflict(ctx, errArchived)
|
||||||
|
}
|
||||||
|
|
||||||
r.logger.ErrorCtx(ctx, "cannot publish document version", log.Error(err))
|
r.logger.ErrorCtx(ctx, "cannot publish document version", log.Error(err))
|
||||||
return nil, gqlutils.Internal(ctx)
|
return nil, gqlutils.Internal(ctx)
|
||||||
}
|
}
|
||||||
@@ -4687,6 +4738,58 @@ func (r *mutationResolver) BulkDeleteDocuments(ctx context.Context, input types.
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BulkArchiveDocuments is the resolver for the bulkArchiveDocuments field.
|
||||||
|
func (r *mutationResolver) BulkArchiveDocuments(ctx context.Context, input types.BulkArchiveDocumentsInput) (*types.BulkArchiveDocumentsPayload, error) {
|
||||||
|
if len(input.DocumentIds) == 0 {
|
||||||
|
return &types.BulkArchiveDocumentsPayload{
|
||||||
|
Documents: []*types.Document{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, documentID := range input.DocumentIds {
|
||||||
|
if err := r.authorize(ctx, documentID, probo.ActionDocumentArchive); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prb := r.ProboService(ctx, input.DocumentIds[0].TenantID())
|
||||||
|
|
||||||
|
if err := prb.Documents.BulkArchive(ctx, input.DocumentIds); err != nil {
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot bulk archive documents", log.Error(err))
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.BulkArchiveDocumentsPayload{
|
||||||
|
Documents: []*types.Document{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BulkUnarchiveDocuments is the resolver for the bulkUnarchiveDocuments field.
|
||||||
|
func (r *mutationResolver) BulkUnarchiveDocuments(ctx context.Context, input types.BulkUnarchiveDocumentsInput) (*types.BulkUnarchiveDocumentsPayload, error) {
|
||||||
|
if len(input.DocumentIds) == 0 {
|
||||||
|
return &types.BulkUnarchiveDocumentsPayload{
|
||||||
|
Documents: []*types.Document{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, documentID := range input.DocumentIds {
|
||||||
|
if err := r.authorize(ctx, documentID, probo.ActionDocumentUnarchive); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prb := r.ProboService(ctx, input.DocumentIds[0].TenantID())
|
||||||
|
|
||||||
|
if err := prb.Documents.BulkUnarchive(ctx, input.DocumentIds); err != nil {
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot bulk unarchive documents", log.Error(err))
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.BulkUnarchiveDocumentsPayload{
|
||||||
|
Documents: []*types.Document{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// BulkExportDocuments is the resolver for the bulkExportDocuments field.
|
// BulkExportDocuments is the resolver for the bulkExportDocuments field.
|
||||||
func (r *mutationResolver) BulkExportDocuments(ctx context.Context, input types.BulkExportDocumentsInput) (*types.BulkExportDocumentsPayload, error) {
|
func (r *mutationResolver) BulkExportDocuments(ctx context.Context, input types.BulkExportDocumentsInput) (*types.BulkExportDocumentsPayload, error) {
|
||||||
if len(input.DocumentIds) == 0 {
|
if len(input.DocumentIds) == 0 {
|
||||||
@@ -4803,6 +4906,9 @@ func (r *mutationResolver) UpdateDocumentVersion(ctx context.Context, input type
|
|||||||
return nil, gqlutils.Conflict(ctx, errNotDraft)
|
return nil, gqlutils.Conflict(ctx, errNotDraft)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok {
|
||||||
|
return nil, gqlutils.Conflict(ctx, errArchived)
|
||||||
|
}
|
||||||
if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
|
if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
|
||||||
return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors)
|
return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors)
|
||||||
}
|
}
|
||||||
@@ -6479,7 +6585,8 @@ 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).
|
||||||
WithDocumentTypes(filter.DocumentTypes)
|
WithDocumentTypes(filter.DocumentTypes).
|
||||||
|
WithStatus(filter.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
page, err := prb.Documents.ListByOrganizationID(ctx, obj.ID, cursor, documentFilter)
|
page, err := prb.Documents.ListByOrganizationID(ctx, obj.ID, cursor, documentFilter)
|
||||||
|
|||||||
@@ -799,7 +799,7 @@ func (r *Resolver) AddFindingTool(ctx context.Context, req *mcp.CallToolRequest,
|
|||||||
Kind: input.Kind,
|
Kind: input.Kind,
|
||||||
Description: input.Description,
|
Description: input.Description,
|
||||||
Source: input.Source,
|
Source: input.Source,
|
||||||
IdentifiedOn: input.IdentifiedOn,
|
IdentifiedOn: input.IdentifiedOn,
|
||||||
RootCause: input.RootCause,
|
RootCause: input.RootCause,
|
||||||
CorrectiveAction: input.CorrectiveAction,
|
CorrectiveAction: input.CorrectiveAction,
|
||||||
OwnerID: input.OwnerID,
|
OwnerID: input.OwnerID,
|
||||||
@@ -830,7 +830,7 @@ func (r *Resolver) UpdateFindingTool(ctx context.Context, req *mcp.CallToolReque
|
|||||||
ID: input.ID,
|
ID: input.ID,
|
||||||
Description: UnwrapOmittable(input.Description),
|
Description: UnwrapOmittable(input.Description),
|
||||||
Source: UnwrapOmittable(input.Source),
|
Source: UnwrapOmittable(input.Source),
|
||||||
IdentifiedOn: UnwrapOmittable(input.IdentifiedOn),
|
IdentifiedOn: UnwrapOmittable(input.IdentifiedOn),
|
||||||
RootCause: UnwrapOmittable(input.RootCause),
|
RootCause: UnwrapOmittable(input.RootCause),
|
||||||
CorrectiveAction: UnwrapOmittable(input.CorrectiveAction),
|
CorrectiveAction: UnwrapOmittable(input.CorrectiveAction),
|
||||||
OwnerID: input.OwnerID,
|
OwnerID: input.OwnerID,
|
||||||
@@ -3201,3 +3201,43 @@ func (r *Resolver) ListFindingAuditsTool(ctx context.Context, req *mcp.CallToolR
|
|||||||
|
|
||||||
return nil, types.NewListFindingAuditsOutput(auditPage), nil
|
return nil, types.NewListFindingAuditsOutput(auditPage), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Resolver) ArchiveDocumentTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ArchiveDocumentInput) (*mcp.CallToolResult, types.ArchiveDocumentOutput, error) {
|
||||||
|
r.MustAuthorize(ctx, input.ID, probo.ActionDocumentArchive)
|
||||||
|
|
||||||
|
svc := r.ProboService(ctx, input.ID)
|
||||||
|
|
||||||
|
document, err := svc.Documents.Archive(ctx, input.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, types.ArchiveDocumentOutput{}, fmt.Errorf("cannot archive document: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
approverPage, err := svc.Documents.ListApprovers(ctx, input.ID, allApproversCursor())
|
||||||
|
if err != nil {
|
||||||
|
return nil, types.ArchiveDocumentOutput{}, fmt.Errorf("cannot list document approvers: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, types.ArchiveDocumentOutput{
|
||||||
|
Document: types.NewDocument(document, profileIDs(approverPage)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Resolver) UnarchiveDocumentTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UnarchiveDocumentInput) (*mcp.CallToolResult, types.UnarchiveDocumentOutput, error) {
|
||||||
|
r.MustAuthorize(ctx, input.ID, probo.ActionDocumentUnarchive)
|
||||||
|
|
||||||
|
svc := r.ProboService(ctx, input.ID)
|
||||||
|
|
||||||
|
document, err := svc.Documents.Unarchive(ctx, input.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, types.UnarchiveDocumentOutput{}, fmt.Errorf("cannot unarchive document: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
approverPage, err := svc.Documents.ListApprovers(ctx, input.ID, allApproversCursor())
|
||||||
|
if err != nil {
|
||||||
|
return nil, types.UnarchiveDocumentOutput{}, fmt.Errorf("cannot list document approvers: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, types.UnarchiveDocumentOutput{
|
||||||
|
Document: types.NewDocument(document, profileIDs(approverPage)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -5057,11 +5057,18 @@ components:
|
|||||||
- SECRET
|
- SECRET
|
||||||
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.DocumentClassification
|
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.DocumentClassification
|
||||||
|
|
||||||
DocumentStatus:
|
DocumentVersionStatus:
|
||||||
type: string
|
type: string
|
||||||
enum:
|
enum:
|
||||||
- DRAFT
|
- DRAFT
|
||||||
- PUBLISHED
|
- PUBLISHED
|
||||||
|
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.DocumentVersionStatus
|
||||||
|
|
||||||
|
DocumentStatus:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- ACTIVE
|
||||||
|
- 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
|
||||||
|
|
||||||
DocumentVersionSignatureState:
|
DocumentVersionSignatureState:
|
||||||
@@ -5142,6 +5149,7 @@ components:
|
|||||||
- document_type
|
- document_type
|
||||||
- classification
|
- classification
|
||||||
- trust_center_visibility
|
- trust_center_visibility
|
||||||
|
- status
|
||||||
- created_at
|
- created_at
|
||||||
- updated_at
|
- updated_at
|
||||||
properties:
|
properties:
|
||||||
@@ -5173,6 +5181,15 @@ components:
|
|||||||
trust_center_visibility:
|
trust_center_visibility:
|
||||||
$ref: "#/components/schemas/TrustCenterVisibility"
|
$ref: "#/components/schemas/TrustCenterVisibility"
|
||||||
description: Trust center visibility
|
description: Trust center visibility
|
||||||
|
status:
|
||||||
|
$ref: "#/components/schemas/DocumentStatus"
|
||||||
|
description: Document status
|
||||||
|
archived_at:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- "null"
|
||||||
|
format: date-time
|
||||||
|
description: Archive timestamp
|
||||||
created_at:
|
created_at:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
@@ -5228,8 +5245,8 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
description: Changelog
|
description: Changelog
|
||||||
status:
|
status:
|
||||||
$ref: "#/components/schemas/DocumentStatus"
|
$ref: "#/components/schemas/DocumentVersionStatus"
|
||||||
description: Document status
|
description: Document version status
|
||||||
published_at:
|
published_at:
|
||||||
type:
|
type:
|
||||||
- string
|
- string
|
||||||
@@ -5434,6 +5451,40 @@ components:
|
|||||||
document:
|
document:
|
||||||
$ref: "#/components/schemas/Document"
|
$ref: "#/components/schemas/Document"
|
||||||
|
|
||||||
|
ArchiveDocumentInput:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
$ref: "#/components/schemas/GID"
|
||||||
|
description: Document ID
|
||||||
|
|
||||||
|
ArchiveDocumentOutput:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- document
|
||||||
|
properties:
|
||||||
|
document:
|
||||||
|
$ref: "#/components/schemas/Document"
|
||||||
|
|
||||||
|
UnarchiveDocumentInput:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
$ref: "#/components/schemas/GID"
|
||||||
|
description: Document ID
|
||||||
|
|
||||||
|
UnarchiveDocumentOutput:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- document
|
||||||
|
properties:
|
||||||
|
document:
|
||||||
|
$ref: "#/components/schemas/Document"
|
||||||
|
|
||||||
ListDocumentVersionsInput:
|
ListDocumentVersionsInput:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
@@ -7133,6 +7184,22 @@ tools:
|
|||||||
$ref: "#/components/schemas/UpdateDocumentInput"
|
$ref: "#/components/schemas/UpdateDocumentInput"
|
||||||
outputSchema:
|
outputSchema:
|
||||||
$ref: "#/components/schemas/UpdateDocumentOutput"
|
$ref: "#/components/schemas/UpdateDocumentOutput"
|
||||||
|
- name: archiveDocument
|
||||||
|
description: Archive a document to prevent further modifications
|
||||||
|
hints:
|
||||||
|
readonly: false
|
||||||
|
inputSchema:
|
||||||
|
$ref: "#/components/schemas/ArchiveDocumentInput"
|
||||||
|
outputSchema:
|
||||||
|
$ref: "#/components/schemas/ArchiveDocumentOutput"
|
||||||
|
- name: unarchiveDocument
|
||||||
|
description: Unarchive a document to allow modifications again
|
||||||
|
hints:
|
||||||
|
readonly: false
|
||||||
|
inputSchema:
|
||||||
|
$ref: "#/components/schemas/UnarchiveDocumentInput"
|
||||||
|
outputSchema:
|
||||||
|
$ref: "#/components/schemas/UnarchiveDocumentOutput"
|
||||||
- name: listDocumentVersions
|
- name: listDocumentVersions
|
||||||
description: List all versions for a document
|
description: List all versions for a document
|
||||||
hints:
|
hints:
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ func NewDocument(d *coredata.Document, approverIDs []gid.GID) *Document {
|
|||||||
Classification: d.Classification,
|
Classification: d.Classification,
|
||||||
CurrentPublishedVersion: d.CurrentPublishedVersion,
|
CurrentPublishedVersion: d.CurrentPublishedVersion,
|
||||||
TrustCenterVisibility: d.TrustCenterVisibility,
|
TrustCenterVisibility: d.TrustCenterVisibility,
|
||||||
|
Status: d.Status,
|
||||||
|
ArchivedAt: d.ArchivedAt,
|
||||||
CreatedAt: d.CreatedAt,
|
CreatedAt: d.CreatedAt,
|
||||||
UpdatedAt: d.UpdatedAt,
|
UpdatedAt: d.UpdatedAt,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,12 @@ func (r *documentResolver) IsUserAuthorized(ctx context.Context, obj *types.Docu
|
|||||||
|
|
||||||
document, err := trustService.Documents.Get(ctx, trustCenter.OrganizationID, obj.ID)
|
document, err := trustService.Documents.Get(ctx, trustCenter.OrganizationID, obj.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, trust.ErrDocumentNotFound) || errors.Is(err, trust.ErrDocumentNotVisible) || errors.Is(err, coredata.ErrResourceNotFound) {
|
||||||
|
return false, gqlutils.NotFoundf(ctx, "document %q not found", obj.ID)
|
||||||
|
}
|
||||||
|
if _, ok := errors.AsType[*trust.ErrDocumentArchived](err); ok {
|
||||||
|
return false, gqlutils.NotFoundf(ctx, "document %q not found", obj.ID)
|
||||||
|
}
|
||||||
r.logger.ErrorCtx(ctx, "cannot load document", log.Error(err))
|
r.logger.ErrorCtx(ctx, "cannot load document", log.Error(err))
|
||||||
return false, gqlutils.Internal(ctx)
|
return false, gqlutils.Internal(ctx)
|
||||||
}
|
}
|
||||||
@@ -363,6 +369,12 @@ func (r *mutationResolver) ExportDocumentPDF(ctx context.Context, input types.Ex
|
|||||||
|
|
||||||
document, err := trustService.Documents.Get(ctx, trustCenter.OrganizationID, input.DocumentID)
|
document, err := trustService.Documents.Get(ctx, trustCenter.OrganizationID, input.DocumentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, trust.ErrDocumentNotFound) || errors.Is(err, trust.ErrDocumentNotVisible) || errors.Is(err, coredata.ErrResourceNotFound) {
|
||||||
|
return nil, gqlutils.NotFoundf(ctx, "document %q not found", input.DocumentID)
|
||||||
|
}
|
||||||
|
if _, ok := errors.AsType[*trust.ErrDocumentArchived](err); ok {
|
||||||
|
return nil, gqlutils.NotFoundf(ctx, "document %q not found", input.DocumentID)
|
||||||
|
}
|
||||||
r.logger.ErrorCtx(ctx, "cannot load document", log.Error(err))
|
r.logger.ErrorCtx(ctx, "cannot load document", log.Error(err))
|
||||||
return nil, gqlutils.Internal(ctx)
|
return nil, gqlutils.Internal(ctx)
|
||||||
}
|
}
|
||||||
@@ -525,6 +537,12 @@ func (r *mutationResolver) RequestDocumentAccess(ctx context.Context, input type
|
|||||||
|
|
||||||
document, err := trustService.Documents.Get(ctx, trustCenter.OrganizationID, input.DocumentID)
|
document, err := trustService.Documents.Get(ctx, trustCenter.OrganizationID, input.DocumentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, trust.ErrDocumentNotFound) || errors.Is(err, trust.ErrDocumentNotVisible) || errors.Is(err, coredata.ErrResourceNotFound) {
|
||||||
|
return nil, gqlutils.NotFoundf(ctx, "document %q not found", input.DocumentID)
|
||||||
|
}
|
||||||
|
if _, ok := errors.AsType[*trust.ErrDocumentArchived](err); ok {
|
||||||
|
return nil, gqlutils.NotFoundf(ctx, "document %q not found", input.DocumentID)
|
||||||
|
}
|
||||||
r.logger.ErrorCtx(ctx, "cannot load document", log.Error(err))
|
r.logger.ErrorCtx(ctx, "cannot load document", log.Error(err))
|
||||||
return nil, gqlutils.Internal(ctx)
|
return nil, gqlutils.Internal(ctx)
|
||||||
}
|
}
|
||||||
@@ -871,6 +889,9 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error
|
|||||||
if errors.Is(err, trust.ErrDocumentNotFound) || errors.Is(err, trust.ErrDocumentNotVisible) || errors.Is(err, coredata.ErrResourceNotFound) {
|
if errors.Is(err, trust.ErrDocumentNotFound) || errors.Is(err, trust.ErrDocumentNotVisible) || errors.Is(err, coredata.ErrResourceNotFound) {
|
||||||
return nil, gqlutils.NotFoundf(ctx, "node %q not found", id)
|
return nil, gqlutils.NotFoundf(ctx, "node %q not found", id)
|
||||||
}
|
}
|
||||||
|
if _, ok := errors.AsType[*trust.ErrDocumentArchived](err); ok {
|
||||||
|
return nil, gqlutils.NotFoundf(ctx, "node %q not found", id)
|
||||||
|
}
|
||||||
r.logger.ErrorCtx(ctx, "cannot get document", log.Error(err))
|
r.logger.ErrorCtx(ctx, "cannot get document", log.Error(err))
|
||||||
return nil, gqlutils.Internal(ctx)
|
return nil, gqlutils.Internal(ctx)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,8 +34,14 @@ type (
|
|||||||
svc *TenantService
|
svc *TenantService
|
||||||
html2pdfConverter *html2pdf.Converter
|
html2pdfConverter *html2pdf.Converter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ErrDocumentArchived struct{}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func (e ErrDocumentArchived) Error() string {
|
||||||
|
return "cannot access an archived document"
|
||||||
|
}
|
||||||
|
|
||||||
func (s *DocumentService) ListForOrganizationId(
|
func (s *DocumentService) ListForOrganizationId(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
organizationID gid.GID,
|
organizationID gid.GID,
|
||||||
@@ -103,6 +109,10 @@ func (s DocumentService) Get(
|
|||||||
return fmt.Errorf("cannot load document: %w", err)
|
return fmt.Errorf("cannot load document: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if document.ArchivedAt != nil {
|
||||||
|
return &ErrDocumentArchived{}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -138,6 +148,10 @@ func (s *DocumentService) exportPDFData(
|
|||||||
return fmt.Errorf("cannot load document: %w", err)
|
return fmt.Errorf("cannot load document: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if document.ArchivedAt != nil {
|
||||||
|
return &ErrDocumentArchived{}
|
||||||
|
}
|
||||||
|
|
||||||
if document.TrustCenterVisibility == coredata.TrustCenterVisibilityNone {
|
if document.TrustCenterVisibility == coredata.TrustCenterVisibilityNone {
|
||||||
return fmt.Errorf("document not visible on trust center")
|
return fmt.Errorf("document not visible on trust center")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user