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:
Sacha Al Himdani
2026-03-19 14:15:16 +01:00
parent 2e12c11c0c
commit 1db8e7133e
29 changed files with 1194 additions and 199 deletions

View File

@@ -52,6 +52,7 @@ const documentsFragment = graphql`
last: $last
before: $before
orderBy: $order
filter: { status: [ACTIVE] }
) @connection(key: "LinkedDocumentsDialogQuery_documents") {
edges {
node {

View File

@@ -61,7 +61,7 @@ const bulkDeleteDocumentsMutation = graphql`
$input: BulkDeleteDocumentsInput!
) {
bulkDeleteDocuments(input: $input) {
deletedDocumentIds @deleteRecord
deletedDocumentIds
}
}
`;

View File

@@ -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

View File

@@ -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

View File

@@ -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>
);

View File

@@ -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
}
}

View File

@@ -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"

View File

@@ -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>
);
}

View File

@@ -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>
)}

View File

@@ -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>

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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
)`
}

View File

@@ -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
}

View File

@@ -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())

View 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
}

View 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;

View File

@@ -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
}

View File

@@ -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"

View File

@@ -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,

View File

@@ -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")

View File

@@ -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!

View File

@@ -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,
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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:

View File

@@ -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,
}

View File

@@ -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)
}

View File

@@ -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")
}