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
|
||||
before: $before
|
||||
orderBy: $order
|
||||
filter: { status: [ACTIVE] }
|
||||
) @connection(key: "LinkedDocumentsDialogQuery_documents") {
|
||||
edges {
|
||||
node {
|
||||
|
||||
@@ -61,7 +61,7 @@ const bulkDeleteDocumentsMutation = graphql`
|
||||
$input: BulkDeleteDocumentsInput!
|
||||
) {
|
||||
bulkDeleteDocuments(input: $input) {
|
||||
deletedDocumentIds @deleteRecord
|
||||
deletedDocumentIds
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -12,7 +12,7 @@ const fragment = graphql`
|
||||
compliancePage: trustCenter @required(action: THROW) {
|
||||
...CompliancePageDocumentListItem_compliancePageFragment
|
||||
}
|
||||
documents(first: 100) {
|
||||
documents(first: 100 filter: { status: [ACTIVE] }) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
|
||||
@@ -36,6 +36,7 @@ export const documentLayoutQuery = graphql`
|
||||
... on Document {
|
||||
id
|
||||
title
|
||||
status
|
||||
canPublish: permission(action: "core:document-version:publish")
|
||||
controlInfo: controls(first: 0) {
|
||||
totalCount
|
||||
|
||||
@@ -5,8 +5,10 @@ import {
|
||||
IconBell2,
|
||||
IconPlusLarge,
|
||||
PageHeader,
|
||||
TabItem,
|
||||
Tabs,
|
||||
} from "@probo/ui";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
type PreloadedQuery,
|
||||
usePreloadedQuery,
|
||||
@@ -29,15 +31,6 @@ export const documentsPageQuery = graphql`
|
||||
... on Organization {
|
||||
canCreateDocument: permission(action: "core:document:create")
|
||||
...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"));
|
||||
|
||||
const canSendAnySignatureNotifications = organization.allDocuments.edges.some(
|
||||
({ node: { canSendSigningNotifications } }) => canSendSigningNotifications,
|
||||
const [canSendAnySignatureNotifications, setCanSendAnySignatureNotifications] = useState(false);
|
||||
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 () => {
|
||||
await sendSigningNotifications({
|
||||
variables: {
|
||||
@@ -106,9 +90,9 @@ export default function DocumentsPage(props: {
|
||||
{__("Send signing notifications")}
|
||||
</Button>
|
||||
)}
|
||||
{organization.canCreateDocument && (
|
||||
{organization.canCreateDocument && tab === "ACTIVE" && (
|
||||
<CreateDocumentDialog
|
||||
connection={unfilteredConnectionId}
|
||||
connection={documentListConnectionId}
|
||||
trigger={
|
||||
<Button icon={IconPlusLarge}>{__("New document")}</Button>
|
||||
}
|
||||
@@ -116,9 +100,19 @@ export default function DocumentsPage(props: {
|
||||
)}
|
||||
</div>
|
||||
</PageHeader>
|
||||
<Tabs>
|
||||
<TabItem active={tab === "ACTIVE"} onClick={() => setTab("ACTIVE")}>
|
||||
{__("Active")}
|
||||
</TabItem>
|
||||
<TabItem active={tab === "ARCHIVED"} onClick={() => setTab("ARCHIVED")}>
|
||||
{__("Archived")}
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
<DocumentList
|
||||
fKey={organization}
|
||||
onConnectionIdChange={setDocumentListConnectionId}
|
||||
onCanSendNotificationsChange={setCanSendAnySignatureNotifications}
|
||||
tab={tab}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -39,6 +39,12 @@ const createDocumentMutation = graphql`
|
||||
documentEdge @prependEdge(connections: $connections) {
|
||||
node {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { sprintf } from "@probo/helpers";
|
||||
import { formatError, sprintf } from "@probo/helpers";
|
||||
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 { useFragment } from "react-relay";
|
||||
import { useFragment, useMutation } from "react-relay";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
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_unarchiveMutation } from "#/__generated__/core/DocumentActionsDropdown_unarchiveMutation.graphql";
|
||||
import type { DocumentActionsDropdown_versionFragment$key } from "#/__generated__/core/DocumentActionsDropdown_versionFragment.graphql";
|
||||
import type { DocumentActionsDropdownn_exportVersionMutation } from "#/__generated__/core/DocumentActionsDropdownn_exportVersionMutation.graphql";
|
||||
import { PdfDownloadDialog, type PdfDownloadDialogRef } from "#/components/documents/PdfDownloadDialog";
|
||||
@@ -21,7 +23,10 @@ const documentFragment = graphql`
|
||||
fragment DocumentActionsDropdown_documentFragment on Document {
|
||||
id
|
||||
title
|
||||
status
|
||||
canUpdate: permission(action: "core:document:update")
|
||||
canArchive: permission(action: "core:document:archive")
|
||||
canUnarchive: permission(action: "core:document:unarchive")
|
||||
canDelete: permission(action: "core:document:delete")
|
||||
versions(first: 20) {
|
||||
__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`
|
||||
fragment DocumentActionsDropdown_versionFragment on DocumentVersion {
|
||||
id
|
||||
@@ -65,6 +106,7 @@ export function DocumentActionsDropdownn(props: {
|
||||
const updateDialogRef = useRef<{ open: () => void }>(null);
|
||||
const pdfDownloadDialogRef = useRef<PdfDownloadDialogRef>(null);
|
||||
const confirm = useConfirm();
|
||||
const { toast } = useToast();
|
||||
|
||||
const document = useFragment<DocumentActionsDropdown_documentFragment$key>(documentFragment, documentFragmentRef);
|
||||
const version = useFragment<DocumentActionsDropdown_versionFragment$key>(versionFragment, versionFragmentRef);
|
||||
@@ -72,6 +114,10 @@ export function DocumentActionsDropdownn(props: {
|
||||
const isDraft = version.status === "DRAFT";
|
||||
|
||||
const [deleteDocument, isDeleting] = useDeleteDocumentMutation();
|
||||
const [archiveDocument, isArchiving]
|
||||
= useMutation<DocumentActionsDropdown_archiveMutation>(archiveDocumentMutation);
|
||||
const [unarchiveDocument, isUnarchiving]
|
||||
= useMutation<DocumentActionsDropdown_unarchiveMutation>(unarchiveDocumentMutation);
|
||||
const [deleteDraftDocumentVersion, isDeletingDraft]
|
||||
= useDeleteDraftDocumentVersionMutation();
|
||||
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 connectionId = ConnectionHandler.getConnectionID(
|
||||
organizationId,
|
||||
@@ -214,6 +307,24 @@ export function DocumentActionsDropdownn(props: {
|
||||
>
|
||||
{__("Download PDF")}
|
||||
</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 && (
|
||||
<DropdownItem
|
||||
variant="danger"
|
||||
|
||||
@@ -21,6 +21,8 @@ const documentFragment = graphql`
|
||||
fragment DocumentLayoutDrawer_documentFragment on Document {
|
||||
id
|
||||
documentType
|
||||
status
|
||||
archivedAt
|
||||
canUpdate: permission(action: "core:document:update")
|
||||
approvers(first: 100) {
|
||||
edges {
|
||||
@@ -87,6 +89,7 @@ export function DocumentLayoutDrawer(props: {
|
||||
const version = useFragment<DocumentLayoutDrawer_versionFragment$key>(versionFragment, versionFragmentRef);
|
||||
|
||||
const isDraft = version.status === "DRAFT";
|
||||
const canEdit = document.canUpdate;
|
||||
|
||||
const approvers = document.approvers.edges.map(e => e.node);
|
||||
|
||||
@@ -184,7 +187,7 @@ export function DocumentLayoutDrawer(props: {
|
||||
: (
|
||||
<ReadOnlyPropertyContent
|
||||
onEdit={() => setIsEditingApprover(true)}
|
||||
canEdit={document.canUpdate}
|
||||
canEdit={canEdit}
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{approvers.map(approver => (
|
||||
@@ -220,7 +223,7 @@ export function DocumentLayoutDrawer(props: {
|
||||
: (
|
||||
<ReadOnlyPropertyContent
|
||||
onEdit={() => setIsEditingType(true)}
|
||||
canEdit={document.canUpdate}
|
||||
canEdit={canEdit}
|
||||
>
|
||||
<div className="text-sm text-txt-secondary">
|
||||
{getDocumentTypeLabel(__, document.documentType)}
|
||||
@@ -251,7 +254,7 @@ export function DocumentLayoutDrawer(props: {
|
||||
: (
|
||||
<ReadOnlyPropertyContent
|
||||
onEdit={() => setIsEditingClassification(true)}
|
||||
canEdit={document.canUpdate}
|
||||
canEdit={canEdit}
|
||||
>
|
||||
<div className="text-sm text-txt-secondary">
|
||||
{getDocumentClassificationLabel(
|
||||
@@ -288,6 +291,13 @@ export function DocumentLayoutDrawer(props: {
|
||||
</div>
|
||||
</PropertyRow>
|
||||
)}
|
||||
{document.archivedAt && (
|
||||
<PropertyRow label={__("Archived on")}>
|
||||
<Badge variant="danger" size="md" className="gap-2">
|
||||
{formatDate(document.archivedAt)}
|
||||
</Badge>
|
||||
</PropertyRow>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { documentTypes, getDocumentTypeLabel, sprintf } from "@probo/helpers";
|
||||
import { useList } from "@probo/hooks";
|
||||
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 { type ComponentProps, use, useRef, useState, useTransition } from "react";
|
||||
import { Button, Card, Checkbox, IconArchive, IconArrowDown, IconCheckmark1, IconCrossLargeX, IconSignature, IconTrashCan, Option, Select, Tbody, Th, Thead, Tr, useConfirm } from "@probo/ui";
|
||||
import { type ComponentProps, use, useEffect, useRef, useState, useTransition } from "react";
|
||||
import { usePaginationFragment } from "react-relay";
|
||||
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 { 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 { type Order, SortableTable, SortableTh } from "#/components/SortableTable";
|
||||
import { useBulkDeleteDocumentsMutation, useBulkExportDocumentsMutation } from "#/hooks/graph/DocumentGraph";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
import { CurrentUser } from "#/providers/CurrentUser";
|
||||
|
||||
@@ -30,6 +33,7 @@ const fragment = graphql`
|
||||
after: { type: "CursorKey", defaultValue: null }
|
||||
before: { type: "CursorKey", defaultValue: null }
|
||||
last: { type: "Int", defaultValue: null }
|
||||
status: { type: "[DocumentStatus!]", defaultValue: [ACTIVE] }
|
||||
documentTypes: { type: "[DocumentType!]", defaultValue: null }
|
||||
) {
|
||||
documents(
|
||||
@@ -38,7 +42,7 @@ const fragment = graphql`
|
||||
last: $last
|
||||
before: $before
|
||||
orderBy: $order
|
||||
filter: { documentTypes: $documentTypes }
|
||||
filter: { status: $status documentTypes: $documentTypes }
|
||||
) @connection(key: "DocumentsListQuery_documents" filters: ["orderBy", "filter"]) {
|
||||
__id
|
||||
edges {
|
||||
@@ -49,6 +53,11 @@ const fragment = graphql`
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
fKey: DocumentListFragment$key;
|
||||
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 { email: defaultEmail } = use(CurrentUser);
|
||||
@@ -72,17 +113,48 @@ export function DocumentList(props: {
|
||||
fKey,
|
||||
);
|
||||
|
||||
const documents = pagination.data.documents.edges
|
||||
.map(({ node }) => node);
|
||||
const [documentTypeFilter, setDocumentTypeFilter] = useState<DocumentType | null>(null);
|
||||
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 [bulkDeleteDocuments] = useBulkDeleteDocumentsMutation();
|
||||
const [bulkExportDocuments, isBulkExporting]
|
||||
= useBulkExportDocumentsMutation();
|
||||
const [bulkExportDocuments, isBulkExporting] = 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 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 newType = value === "ALL" ? null : (value as DocumentType);
|
||||
@@ -94,41 +166,36 @@ export function DocumentList(props: {
|
||||
"DocumentsListQuery_documents",
|
||||
{
|
||||
orderBy: { direction: "ASC", field: "TITLE" },
|
||||
filter: { documentTypes: newType ? [newType] : null },
|
||||
filter: { status: [tab], documentTypes: newType ? [newType] : null },
|
||||
},
|
||||
),
|
||||
);
|
||||
startTransition(() => {
|
||||
pagination.refetch(
|
||||
{ documentTypes: newType ? [newType] : null },
|
||||
{ fetchPolicy: "network-only" },
|
||||
{ status: [tab], documentTypes: newType ? [newType] : null },
|
||||
{ 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 documentCount = selection.length;
|
||||
confirm(
|
||||
() =>
|
||||
bulkDeleteDocuments({
|
||||
variables: {
|
||||
input: { documentIds: selection },
|
||||
variables: { input: { documentIds: selection } },
|
||||
updater: (store) => {
|
||||
const conn = store.get(connectionId);
|
||||
if (conn) {
|
||||
selection.forEach(id => ConnectionHandler.deleteNode(conn, id));
|
||||
}
|
||||
},
|
||||
}).then(() => {
|
||||
clear();
|
||||
}),
|
||||
{
|
||||
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 > 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: {
|
||||
withWatermark: boolean;
|
||||
withSignatures: boolean;
|
||||
@@ -145,13 +238,9 @@ export function DocumentList(props: {
|
||||
documentIds: selection,
|
||||
withWatermark: options.withWatermark,
|
||||
withSignatures: options.withSignatures,
|
||||
...(options.withWatermark
|
||||
&& options.watermarkEmail && { watermarkEmail: options.watermarkEmail }),
|
||||
...(options.withWatermark && options.watermarkEmail && { watermarkEmail: options.watermarkEmail }),
|
||||
};
|
||||
|
||||
await bulkExportDocuments({
|
||||
variables: { input },
|
||||
});
|
||||
await bulkExportDocuments({ variables: { input } });
|
||||
clear();
|
||||
};
|
||||
|
||||
@@ -162,12 +251,21 @@ export function DocumentList(props: {
|
||||
"DocumentsListQuery_documents",
|
||||
{
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -188,7 +286,7 @@ export function DocumentList(props: {
|
||||
? (
|
||||
<SortableTable
|
||||
{...pagination}
|
||||
refetch={pagination.refetch as ComponentProps<typeof SortableTable>["refetch"]}
|
||||
refetch={refetchWithFilters}
|
||||
>
|
||||
<Thead>
|
||||
{selection.length === 0
|
||||
@@ -196,10 +294,7 @@ export function DocumentList(props: {
|
||||
<Tr>
|
||||
<Th className="w-18">
|
||||
<Checkbox
|
||||
checked={
|
||||
selection.length === documents.length
|
||||
&& documents.length > 0
|
||||
}
|
||||
checked={selection.length === documents.length && documents.length > 0}
|
||||
onChange={() => reset(documents.map(d => d.id))}
|
||||
/>
|
||||
</Th>
|
||||
@@ -235,58 +330,90 @@ export function DocumentList(props: {
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
{canUpdateAny && (
|
||||
<PublishDocumentsDialog
|
||||
documentIds={selection}
|
||||
onSave={clear}
|
||||
>
|
||||
<Button
|
||||
icon={IconCheckmark1}
|
||||
className="py-0.5 px-2 text-xs h-6 min-h-6"
|
||||
>
|
||||
{__("Publish")}
|
||||
</Button>
|
||||
</PublishDocumentsDialog>
|
||||
)}
|
||||
{canRequestAnySignatures && (
|
||||
<SignatureDocumentsDialog
|
||||
documentIds={selection}
|
||||
onSave={clear}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={IconSignature}
|
||||
className="py-0.5 px-2 text-xs h-6 min-h-6"
|
||||
>
|
||||
{__("Request signature")}
|
||||
</Button>
|
||||
</SignatureDocumentsDialog>
|
||||
)}
|
||||
<BulkExportDialog
|
||||
ref={bulkExportDialogRef}
|
||||
onExport={handleBulkExport}
|
||||
isLoading={isBulkExporting}
|
||||
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>
|
||||
{canDeleteAny && (
|
||||
<Button
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
onClick={handleBulkDelete}
|
||||
className="py-0.5 px-2 text-xs h-6 min-h-6"
|
||||
>
|
||||
{__("Delete")}
|
||||
</Button>
|
||||
)}
|
||||
{tab === "ARCHIVED"
|
||||
? (
|
||||
<>
|
||||
{canUnarchiveAny && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={IconArchive}
|
||||
onClick={handleBulkUnarchive}
|
||||
disabled={isBulkUnarchiving}
|
||||
className="py-0.5 px-2 text-xs h-6 min-h-6"
|
||||
>
|
||||
{__("Unarchive")}
|
||||
</Button>
|
||||
)}
|
||||
{canDeleteAny && (
|
||||
<Button
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
onClick={handleBulkDelete}
|
||||
className="py-0.5 px-2 text-xs h-6 min-h-6"
|
||||
>
|
||||
{__("Delete")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
{canUpdateAny && (
|
||||
<PublishDocumentsDialog documentIds={selection} onSave={clear}>
|
||||
<Button icon={IconCheckmark1} className="py-0.5 px-2 text-xs h-6 min-h-6">
|
||||
{__("Publish")}
|
||||
</Button>
|
||||
</PublishDocumentsDialog>
|
||||
)}
|
||||
{canRequestAnySignatures && (
|
||||
<SignatureDocumentsDialog documentIds={selection} onSave={clear}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={IconSignature}
|
||||
className="py-0.5 px-2 text-xs h-6 min-h-6"
|
||||
>
|
||||
{__("Request signature")}
|
||||
</Button>
|
||||
</SignatureDocumentsDialog>
|
||||
)}
|
||||
<BulkExportDialog
|
||||
ref={bulkExportDialogRef}
|
||||
onExport={handleBulkExport}
|
||||
isLoading={isBulkExporting}
|
||||
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>
|
||||
</Th>
|
||||
@@ -311,11 +438,13 @@ export function DocumentList(props: {
|
||||
<Card padded>
|
||||
<div className="text-center py-12">
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
{__("No documents yet")}
|
||||
{tab === "ARCHIVED" ? __("No archived documents") : __("No documents yet")}
|
||||
</h3>
|
||||
<p className="text-txt-tertiary mb-4">
|
||||
{__("Create your first document to get started.")}
|
||||
</p>
|
||||
{tab !== "ARCHIVED" && (
|
||||
<p className="text-txt-tertiary mb-4">
|
||||
{__("Create your first document to get started.")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -109,7 +109,9 @@ export function DocumentListItem(props: {
|
||||
};
|
||||
|
||||
return (
|
||||
<Tr to={`/organizations/${organizationId}/documents/${document.id}`}>
|
||||
<Tr
|
||||
to={`/organizations/${organizationId}/documents/${document.id}`}
|
||||
>
|
||||
<Td noLink className="w-18">
|
||||
<Checkbox checked={checked} onChange={onCheck} />
|
||||
</Td>
|
||||
|
||||
@@ -113,3 +113,33 @@ WHERE
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
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"`
|
||||
CurrentPublishedVersion *int `db:"current_published_version"`
|
||||
TrustCenterVisibility TrustCenterVisibility `db:"trust_center_visibility"`
|
||||
Status DocumentStatus `db:"status"`
|
||||
ArchivedAt *time.Time `db:"archived_at"`
|
||||
CreatedAt time.Time `db:"created_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.
|
||||
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
|
||||
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) {
|
||||
return nil, ErrResourceNotFound
|
||||
}
|
||||
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(
|
||||
@@ -87,6 +93,8 @@ SELECT
|
||||
classification,
|
||||
current_published_version,
|
||||
trust_center_visibility,
|
||||
status,
|
||||
archived_at,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
@@ -138,6 +146,8 @@ SELECT
|
||||
classification,
|
||||
current_published_version,
|
||||
trust_center_visibility,
|
||||
status,
|
||||
archived_at,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
@@ -190,6 +200,8 @@ SELECT
|
||||
classification,
|
||||
current_published_version,
|
||||
trust_center_visibility,
|
||||
status,
|
||||
archived_at,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
@@ -271,6 +283,8 @@ SELECT
|
||||
classification,
|
||||
current_published_version,
|
||||
trust_center_visibility,
|
||||
status,
|
||||
archived_at,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
@@ -321,6 +335,8 @@ SELECT
|
||||
classification,
|
||||
current_published_version,
|
||||
trust_center_visibility,
|
||||
status,
|
||||
archived_at,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
@@ -384,6 +400,8 @@ SELECT
|
||||
classification,
|
||||
current_published_version,
|
||||
trust_center_visibility,
|
||||
status,
|
||||
archived_at,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
@@ -431,6 +449,8 @@ INSERT INTO
|
||||
classification,
|
||||
current_published_version,
|
||||
trust_center_visibility,
|
||||
status,
|
||||
archived_at,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
@@ -443,6 +463,8 @@ VALUES (
|
||||
@classification,
|
||||
@current_published_version,
|
||||
@trust_center_visibility,
|
||||
@status,
|
||||
@archived_at,
|
||||
@created_at,
|
||||
@updated_at
|
||||
);
|
||||
@@ -457,6 +479,8 @@ VALUES (
|
||||
"classification": p.Classification,
|
||||
"current_published_version": p.CurrentPublishedVersion,
|
||||
"trust_center_visibility": p.TrustCenterVisibility,
|
||||
"status": p.Status,
|
||||
"archived_at": p.ArchivedAt,
|
||||
"created_at": p.CreatedAt,
|
||||
"updated_at": p.UpdatedAt,
|
||||
}
|
||||
@@ -515,6 +539,8 @@ SET
|
||||
document_type = @document_type,
|
||||
classification = @classification,
|
||||
trust_center_visibility = @trust_center_visibility,
|
||||
status = @status,
|
||||
archived_at = @archived_at,
|
||||
updated_at = @updated_at
|
||||
WHERE
|
||||
%s
|
||||
@@ -531,6 +557,8 @@ WHERE
|
||||
"document_type": p.DocumentType,
|
||||
"classification": p.Classification,
|
||||
"trust_center_visibility": p.TrustCenterVisibility,
|
||||
"status": p.Status,
|
||||
"archived_at": p.ArchivedAt,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
@@ -603,6 +631,8 @@ SELECT
|
||||
scoped_documents.classification,
|
||||
scoped_documents.current_published_version,
|
||||
scoped_documents.trust_center_visibility,
|
||||
scoped_documents.status,
|
||||
scoped_documents.archived_at,
|
||||
scoped_documents.created_at,
|
||||
scoped_documents.updated_at
|
||||
FROM scoped_documents
|
||||
@@ -692,6 +722,8 @@ SELECT
|
||||
scoped_documents.classification,
|
||||
scoped_documents.current_published_version,
|
||||
scoped_documents.trust_center_visibility,
|
||||
scoped_documents.status,
|
||||
scoped_documents.archived_at,
|
||||
scoped_documents.created_at,
|
||||
scoped_documents.updated_at
|
||||
FROM scoped_documents
|
||||
@@ -744,6 +776,59 @@ UPDATE documents SET deleted_at = @deleted_at WHERE %s AND id = ANY(@document_id
|
||||
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(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
|
||||
@@ -26,6 +26,7 @@ type (
|
||||
published *bool
|
||||
userEmail *mail.Addr
|
||||
documentTypes []DocumentType
|
||||
status []DocumentStatus
|
||||
}
|
||||
)
|
||||
|
||||
@@ -43,6 +44,7 @@ func NewDocumentTrustCenterFilter() *DocumentFilter {
|
||||
TrustCenterVisibilityPublic,
|
||||
},
|
||||
published: &published,
|
||||
status: []DocumentStatus{DocumentStatusActive},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +63,11 @@ func (f *DocumentFilter) WithDocumentTypes(documentTypes []DocumentType) *Docume
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *DocumentFilter) WithStatus(status []DocumentStatus) *DocumentFilter {
|
||||
f.status = status
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *DocumentFilter) SQLArguments() pgx.NamedArgs {
|
||||
var visibilities []string
|
||||
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{
|
||||
"query": f.query,
|
||||
"trust_center_visibilities": visibilities,
|
||||
"published": f.published,
|
||||
"user_email": f.userEmail,
|
||||
"document_types": documentTypes,
|
||||
"document_status": status,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,5 +147,10 @@ func (f *DocumentFilter) SQLFragment() string {
|
||||
document_type = ANY(@document_types::document_type[])
|
||||
ELSE TRUE
|
||||
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"
|
||||
)
|
||||
|
||||
type (
|
||||
DocumentStatus uint8
|
||||
)
|
||||
type DocumentStatus string
|
||||
|
||||
const (
|
||||
DocumentStatusDraft DocumentStatus = iota
|
||||
DocumentStatusPublished
|
||||
DocumentStatusActive DocumentStatus = "ACTIVE"
|
||||
DocumentStatusArchived DocumentStatus = "ARCHIVED"
|
||||
)
|
||||
|
||||
func (ps DocumentStatus) MarshalText() ([]byte, error) {
|
||||
return []byte(ps.String()), nil
|
||||
func (s DocumentStatus) IsValid() bool {
|
||||
switch s {
|
||||
case DocumentStatusActive, DocumentStatusArchived:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (ps *DocumentStatus) UnmarshalText(data []byte) error {
|
||||
val := string(data)
|
||||
func (s DocumentStatus) String() string { return string(s) }
|
||||
|
||||
switch val {
|
||||
case DocumentStatusDraft.String():
|
||||
*ps = DocumentStatusDraft
|
||||
case DocumentStatusPublished.String():
|
||||
*ps = DocumentStatusPublished
|
||||
default:
|
||||
return fmt.Errorf("invalid DocumentStatus value: %q", val)
|
||||
func (s *DocumentStatus) UnmarshalText(text []byte) error {
|
||||
*s = DocumentStatus(text)
|
||||
if !s.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid DocumentStatus", string(text))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ps DocumentStatus) String() string {
|
||||
var val string
|
||||
|
||||
switch ps {
|
||||
case DocumentStatusDraft:
|
||||
val = "DRAFT"
|
||||
case DocumentStatusPublished:
|
||||
val = "PUBLISHED"
|
||||
}
|
||||
|
||||
return val
|
||||
func (s DocumentStatus) MarshalText() ([]byte, error) {
|
||||
return []byte(s.String()), nil
|
||||
}
|
||||
|
||||
func (ps *DocumentStatus) Scan(value any) error {
|
||||
func (s *DocumentStatus) Scan(value any) error {
|
||||
val, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid scan source for DocumentStatus, expected string got %T", value)
|
||||
}
|
||||
|
||||
return ps.UnmarshalText([]byte(val))
|
||||
return s.UnmarshalText([]byte(val))
|
||||
}
|
||||
|
||||
func (ps DocumentStatus) Value() (driver.Value, error) {
|
||||
return ps.String(), nil
|
||||
func (s DocumentStatus) Value() (driver.Value, error) {
|
||||
return s.String(), nil
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ type (
|
||||
Classification DocumentClassification `db:"classification"`
|
||||
Content string `db:"content"`
|
||||
Changelog string `db:"changelog"`
|
||||
Status DocumentStatus `db:"status"`
|
||||
Status DocumentVersionStatus `db:"status"`
|
||||
PublishedAt *time.Time `db:"published_at"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
@@ -49,17 +49,29 @@ type (
|
||||
|
||||
// AuthorizationAttributes returns the authorization attributes for policy evaluation.
|
||||
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
|
||||
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) {
|
||||
return nil, ErrResourceNotFound
|
||||
}
|
||||
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(
|
||||
@@ -377,7 +389,7 @@ LIMIT 1;
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"document_id": documentID,
|
||||
"status": DocumentStatusPublished,
|
||||
"status": DocumentVersionStatusPublished,
|
||||
}
|
||||
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)
|
||||
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"
|
||||
ActionDocumentDelete = "core:document:delete"
|
||||
ActionDocumentChangelogGenerate = "core:document:generate-changelog"
|
||||
ActionDocumentArchive = "core:document:archive"
|
||||
ActionDocumentUnarchive = "core:document:unarchive"
|
||||
ActionDocumentDraftVersionCreate = "core:document:create-draft-version"
|
||||
ActionDocumentSendSigningNotifications = "core:document:send-signing-notifications"
|
||||
|
||||
|
||||
@@ -49,6 +49,12 @@ type (
|
||||
ErrDocumentVersionNotDraft struct {
|
||||
}
|
||||
|
||||
ErrDocumentArchived struct {
|
||||
}
|
||||
|
||||
ErrDocumentNotArchived struct {
|
||||
}
|
||||
|
||||
ErrDocumentVersionSignatureAlreadySigned struct {
|
||||
}
|
||||
|
||||
@@ -164,6 +170,14 @@ func (e ErrDocumentVersionNotDraft) Error() string {
|
||||
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 {
|
||||
return "document version signature already signed"
|
||||
}
|
||||
@@ -331,7 +345,7 @@ func (s DocumentService) GenerateChangelog(
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -450,15 +464,19 @@ func (s *DocumentService) publishVersionInTx(
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
if documentVersion.Status != coredata.DocumentStatusDraft {
|
||||
if documentVersion.Status != coredata.DocumentVersionStatusDraft {
|
||||
return nil, nil, fmt.Errorf("cannot publish version")
|
||||
}
|
||||
|
||||
@@ -479,7 +497,7 @@ func (s *DocumentService) publishVersionInTx(
|
||||
document.CurrentPublishedVersion = &documentVersion.VersionNumber
|
||||
document.UpdatedAt = now
|
||||
|
||||
documentVersion.Status = coredata.DocumentStatusPublished
|
||||
documentVersion.Status = coredata.DocumentVersionStatusPublished
|
||||
documentVersion.PublishedAt = &now
|
||||
documentVersion.UpdatedAt = now
|
||||
|
||||
@@ -514,6 +532,7 @@ func (s *DocumentService) Create(
|
||||
DocumentType: req.DocumentType,
|
||||
TrustCenterVisibility: coredata.TrustCenterVisibilityNone,
|
||||
Classification: req.Classification,
|
||||
Status: coredata.DocumentStatusActive,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
@@ -528,7 +547,7 @@ func (s *DocumentService) Create(
|
||||
Title: req.Title,
|
||||
VersionNumber: 1,
|
||||
Content: req.Content,
|
||||
Status: coredata.DocumentStatusDraft,
|
||||
Status: coredata.DocumentVersionStatusDraft,
|
||||
Classification: req.Classification,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
@@ -741,7 +760,7 @@ func (s *DocumentService) signDocumentVersionInTx(
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -790,7 +809,11 @@ func (s *DocumentService) UpdateVersion(
|
||||
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{}
|
||||
}
|
||||
|
||||
@@ -871,7 +894,7 @@ func (s *DocumentService) BulkRequestSignatures(
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -948,7 +971,7 @@ func (s *DocumentService) RequestSignature(
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -1046,7 +1069,7 @@ func (s *DocumentService) CreateDraft(
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -1057,7 +1080,7 @@ func (s *DocumentService) CreateDraft(
|
||||
draftVersion.VersionNumber = latestVersion.VersionNumber + 1
|
||||
draftVersion.Classification = document.Classification
|
||||
draftVersion.Content = latestVersion.Content
|
||||
draftVersion.Status = coredata.DocumentStatusDraft
|
||||
draftVersion.Status = coredata.DocumentVersionStatusDraft
|
||||
draftVersion.CreatedAt = now
|
||||
draftVersion.UpdatedAt = now
|
||||
|
||||
@@ -1106,7 +1129,7 @@ func (s *DocumentService) DeleteDraft(
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
ctx context.Context,
|
||||
documentIDs []gid.GID,
|
||||
@@ -1519,6 +1588,10 @@ func (s *DocumentService) Update(
|
||||
return fmt.Errorf("cannot load document %q: %w", req.DocumentID, err)
|
||||
}
|
||||
|
||||
if document.ArchivedAt != nil {
|
||||
return &ErrDocumentArchived{}
|
||||
}
|
||||
|
||||
if req.Title != nil {
|
||||
document.Title = *req.Title
|
||||
}
|
||||
@@ -1574,7 +1647,7 @@ func (s *DocumentService) Update(
|
||||
|
||||
draftVersion := &coredata.DocumentVersion{}
|
||||
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.Classification = document.Classification
|
||||
draftVersion.UpdatedAt = now
|
||||
@@ -1614,6 +1687,91 @@ func (s *DocumentService) Update(
|
||||
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(
|
||||
ctx context.Context,
|
||||
documentVersionSignatureID gid.GID,
|
||||
|
||||
@@ -19,12 +19,37 @@ import (
|
||||
"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.
|
||||
var OwnerPolicy = policy.NewPolicy(
|
||||
"probo:owner",
|
||||
"Probo Owner",
|
||||
documentWriteActiveOnly,
|
||||
documentUnarchiveArchivedOnly,
|
||||
policy.Allow("core:*").WithSID("full-core-access").When(organizationCondition),
|
||||
).WithDescription("Full probo access for organization owners")
|
||||
|
||||
@@ -32,6 +57,8 @@ var OwnerPolicy = policy.NewPolicy(
|
||||
var AdminPolicy = policy.NewPolicy(
|
||||
"probo:admin",
|
||||
"Probo Admin",
|
||||
documentWriteActiveOnly,
|
||||
documentUnarchiveArchivedOnly,
|
||||
policy.Allow("core:*").WithSID("full-core-access").When(organizationCondition),
|
||||
).WithDescription("Probo admin access - can manage core entities")
|
||||
|
||||
|
||||
@@ -69,15 +69,26 @@ enum EvidenceState
|
||||
@goEnum(value: "go.probo.inc/probo/pkg/coredata.EvidenceStateRequested")
|
||||
}
|
||||
|
||||
enum DocumentStatus
|
||||
@goModel(model: "go.probo.inc/probo/pkg/coredata.DocumentStatus") {
|
||||
DRAFT @goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentStatusDraft")
|
||||
enum DocumentVersionStatus
|
||||
@goModel(model: "go.probo.inc/probo/pkg/coredata.DocumentVersionStatus") {
|
||||
DRAFT
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionStatusDraft"
|
||||
)
|
||||
PUBLISHED
|
||||
@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
|
||||
@goModel(model: "go.probo.inc/probo/pkg/coredata.EvidenceType") {
|
||||
FILE @goEnum(value: "go.probo.inc/probo/pkg/coredata.EvidenceTypeFile")
|
||||
@@ -1569,7 +1580,7 @@ input ApplicabilityStatementOrder
|
||||
}
|
||||
|
||||
input DocumentVersionFilter {
|
||||
status: DocumentStatus
|
||||
status: DocumentVersionStatus
|
||||
}
|
||||
|
||||
# Input Types for Filtering
|
||||
@@ -1580,6 +1591,7 @@ input ControlFilter {
|
||||
input DocumentFilter {
|
||||
query: String
|
||||
documentTypes: [DocumentType!]
|
||||
status: [DocumentStatus!]
|
||||
}
|
||||
|
||||
input MeasureFilter {
|
||||
@@ -2402,6 +2414,9 @@ type Document implements Node {
|
||||
filter: ControlFilter
|
||||
): ControlConnection! @goField(forceResolver: true)
|
||||
|
||||
status: DocumentStatus!
|
||||
archivedAt: Datetime
|
||||
|
||||
createdAt: Datetime!
|
||||
updatedAt: Datetime!
|
||||
|
||||
@@ -3657,6 +3672,8 @@ type Mutation {
|
||||
# Document mutations
|
||||
createDocument(input: CreateDocumentInput!): CreateDocumentPayload!
|
||||
updateDocument(input: UpdateDocumentInput!): UpdateDocumentPayload!
|
||||
archiveDocument(input: ArchiveDocumentInput!): ArchiveDocumentPayload!
|
||||
unarchiveDocument(input: UnarchiveDocumentInput!): UnarchiveDocumentPayload!
|
||||
deleteDocument(input: DeleteDocumentInput!): DeleteDocumentPayload!
|
||||
# Meeting mutations
|
||||
createMeeting(input: CreateMeetingInput!): CreateMeetingPayload!
|
||||
@@ -3694,6 +3711,12 @@ type Mutation {
|
||||
bulkDeleteDocuments(
|
||||
input: BulkDeleteDocumentsInput!
|
||||
): BulkDeleteDocumentsPayload!
|
||||
bulkArchiveDocuments(
|
||||
input: BulkArchiveDocumentsInput!
|
||||
): BulkArchiveDocumentsPayload!
|
||||
bulkUnarchiveDocuments(
|
||||
input: BulkUnarchiveDocumentsInput!
|
||||
): BulkUnarchiveDocumentsPayload!
|
||||
bulkExportDocuments(
|
||||
input: BulkExportDocumentsInput!
|
||||
): BulkExportDocumentsPayload!
|
||||
@@ -4367,6 +4390,14 @@ input ExportTransferImpactAssessmentsPDFInput {
|
||||
filter: TransferImpactAssessmentFilter
|
||||
}
|
||||
|
||||
input ArchiveDocumentInput {
|
||||
documentId: ID!
|
||||
}
|
||||
|
||||
input UnarchiveDocumentInput {
|
||||
documentId: ID!
|
||||
}
|
||||
|
||||
input DeleteDocumentInput {
|
||||
documentId: ID!
|
||||
}
|
||||
@@ -5086,6 +5117,14 @@ type UpdateDocumentPayload {
|
||||
document: Document!
|
||||
}
|
||||
|
||||
type ArchiveDocumentPayload {
|
||||
document: Document!
|
||||
}
|
||||
|
||||
type UnarchiveDocumentPayload {
|
||||
document: Document!
|
||||
}
|
||||
|
||||
type DeleteDocumentPayload {
|
||||
deletedDocumentId: ID!
|
||||
}
|
||||
@@ -5197,7 +5236,7 @@ type DeleteMeasurePayload {
|
||||
type DocumentVersion implements Node {
|
||||
id: ID!
|
||||
document: Document! @goField(forceResolver: true)
|
||||
status: DocumentStatus!
|
||||
status: DocumentVersionStatus!
|
||||
version: Int!
|
||||
content: String!
|
||||
changelog: String!
|
||||
@@ -5326,6 +5365,22 @@ input BulkDeleteDocumentsInput {
|
||||
documentIds: [ID!]!
|
||||
}
|
||||
|
||||
input BulkArchiveDocumentsInput {
|
||||
documentIds: [ID!]!
|
||||
}
|
||||
|
||||
type BulkArchiveDocumentsPayload {
|
||||
documents: [Document!]!
|
||||
}
|
||||
|
||||
input BulkUnarchiveDocumentsInput {
|
||||
documentIds: [ID!]!
|
||||
}
|
||||
|
||||
type BulkUnarchiveDocumentsPayload {
|
||||
documents: [Document!]!
|
||||
}
|
||||
|
||||
input BulkExportDocumentsInput {
|
||||
documentIds: [ID!]!
|
||||
withWatermark: Boolean!
|
||||
|
||||
@@ -84,6 +84,8 @@ func NewDocument(document *coredata.Document) *Document {
|
||||
Classification: document.Classification,
|
||||
CurrentPublishedVersion: document.CurrentPublishedVersion,
|
||||
TrustCenterVisibility: document.TrustCenterVisibility,
|
||||
Status: document.Status,
|
||||
ArchivedAt: document.ArchivedAt,
|
||||
CreatedAt: document.CreatedAt,
|
||||
UpdatedAt: document.UpdatedAt,
|
||||
}
|
||||
|
||||
@@ -4286,6 +4286,9 @@ func (r *mutationResolver) UpdateDocument(ctx context.Context, input types.Updat
|
||||
)
|
||||
|
||||
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 {
|
||||
return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors)
|
||||
}
|
||||
@@ -4298,6 +4301,50 @@ func (r *mutationResolver) UpdateDocument(ctx context.Context, input types.Updat
|
||||
}, 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.
|
||||
func (r *mutationResolver) DeleteDocument(ctx context.Context, input types.DeleteDocumentInput) (*types.DeleteDocumentPayload, error) {
|
||||
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)
|
||||
}
|
||||
|
||||
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))
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
@@ -4687,6 +4738,58 @@ func (r *mutationResolver) BulkDeleteDocuments(ctx context.Context, input types.
|
||||
}, 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.
|
||||
func (r *mutationResolver) BulkExportDocuments(ctx context.Context, input types.BulkExportDocumentsInput) (*types.BulkExportDocumentsPayload, error) {
|
||||
if len(input.DocumentIds) == 0 {
|
||||
@@ -4803,6 +4906,9 @@ func (r *mutationResolver) UpdateDocumentVersion(ctx context.Context, input type
|
||||
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 {
|
||||
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)
|
||||
if filter != nil {
|
||||
documentFilter = coredata.NewDocumentFilter(filter.Query).
|
||||
WithDocumentTypes(filter.DocumentTypes)
|
||||
WithDocumentTypes(filter.DocumentTypes).
|
||||
WithStatus(filter.Status)
|
||||
}
|
||||
|
||||
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,
|
||||
Description: input.Description,
|
||||
Source: input.Source,
|
||||
IdentifiedOn: input.IdentifiedOn,
|
||||
IdentifiedOn: input.IdentifiedOn,
|
||||
RootCause: input.RootCause,
|
||||
CorrectiveAction: input.CorrectiveAction,
|
||||
OwnerID: input.OwnerID,
|
||||
@@ -830,7 +830,7 @@ func (r *Resolver) UpdateFindingTool(ctx context.Context, req *mcp.CallToolReque
|
||||
ID: input.ID,
|
||||
Description: UnwrapOmittable(input.Description),
|
||||
Source: UnwrapOmittable(input.Source),
|
||||
IdentifiedOn: UnwrapOmittable(input.IdentifiedOn),
|
||||
IdentifiedOn: UnwrapOmittable(input.IdentifiedOn),
|
||||
RootCause: UnwrapOmittable(input.RootCause),
|
||||
CorrectiveAction: UnwrapOmittable(input.CorrectiveAction),
|
||||
OwnerID: input.OwnerID,
|
||||
@@ -3201,3 +3201,43 @@ func (r *Resolver) ListFindingAuditsTool(ctx context.Context, req *mcp.CallToolR
|
||||
|
||||
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
|
||||
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.DocumentClassification
|
||||
|
||||
DocumentStatus:
|
||||
DocumentVersionStatus:
|
||||
type: string
|
||||
enum:
|
||||
- DRAFT
|
||||
- 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
|
||||
|
||||
DocumentVersionSignatureState:
|
||||
@@ -5142,6 +5149,7 @@ components:
|
||||
- document_type
|
||||
- classification
|
||||
- trust_center_visibility
|
||||
- status
|
||||
- created_at
|
||||
- updated_at
|
||||
properties:
|
||||
@@ -5173,6 +5181,15 @@ components:
|
||||
trust_center_visibility:
|
||||
$ref: "#/components/schemas/TrustCenterVisibility"
|
||||
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:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -5228,8 +5245,8 @@ components:
|
||||
type: string
|
||||
description: Changelog
|
||||
status:
|
||||
$ref: "#/components/schemas/DocumentStatus"
|
||||
description: Document status
|
||||
$ref: "#/components/schemas/DocumentVersionStatus"
|
||||
description: Document version status
|
||||
published_at:
|
||||
type:
|
||||
- string
|
||||
@@ -5434,6 +5451,40 @@ components:
|
||||
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:
|
||||
type: object
|
||||
required:
|
||||
@@ -7133,6 +7184,22 @@ tools:
|
||||
$ref: "#/components/schemas/UpdateDocumentInput"
|
||||
outputSchema:
|
||||
$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
|
||||
description: List all versions for a document
|
||||
hints:
|
||||
|
||||
@@ -30,6 +30,8 @@ func NewDocument(d *coredata.Document, approverIDs []gid.GID) *Document {
|
||||
Classification: d.Classification,
|
||||
CurrentPublishedVersion: d.CurrentPublishedVersion,
|
||||
TrustCenterVisibility: d.TrustCenterVisibility,
|
||||
Status: d.Status,
|
||||
ArchivedAt: d.ArchivedAt,
|
||||
CreatedAt: d.CreatedAt,
|
||||
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)
|
||||
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))
|
||||
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)
|
||||
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))
|
||||
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)
|
||||
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))
|
||||
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) {
|
||||
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))
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
@@ -34,8 +34,14 @@ type (
|
||||
svc *TenantService
|
||||
html2pdfConverter *html2pdf.Converter
|
||||
}
|
||||
|
||||
ErrDocumentArchived struct{}
|
||||
)
|
||||
|
||||
func (e ErrDocumentArchived) Error() string {
|
||||
return "cannot access an archived document"
|
||||
}
|
||||
|
||||
func (s *DocumentService) ListForOrganizationId(
|
||||
ctx context.Context,
|
||||
organizationID gid.GID,
|
||||
@@ -103,6 +109,10 @@ func (s DocumentService) Get(
|
||||
return fmt.Errorf("cannot load document: %w", err)
|
||||
}
|
||||
|
||||
if document.ArchivedAt != nil {
|
||||
return &ErrDocumentArchived{}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
@@ -138,6 +148,10 @@ func (s *DocumentService) exportPDFData(
|
||||
return fmt.Errorf("cannot load document: %w", err)
|
||||
}
|
||||
|
||||
if document.ArchivedAt != nil {
|
||||
return &ErrDocumentArchived{}
|
||||
}
|
||||
|
||||
if document.TrustCenterVisibility == coredata.TrustCenterVisibilityNone {
|
||||
return fmt.Errorf("document not visible on trust center")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user