diff --git a/apps/console/src/components/documents/LinkedDocumentsDialog.tsx b/apps/console/src/components/documents/LinkedDocumentsDialog.tsx index bdefd521e..d713bf3ec 100644 --- a/apps/console/src/components/documents/LinkedDocumentsDialog.tsx +++ b/apps/console/src/components/documents/LinkedDocumentsDialog.tsx @@ -52,6 +52,7 @@ const documentsFragment = graphql` last: $last before: $before orderBy: $order + filter: { status: [ACTIVE] } ) @connection(key: "LinkedDocumentsDialogQuery_documents") { edges { node { diff --git a/apps/console/src/hooks/graph/DocumentGraph.ts b/apps/console/src/hooks/graph/DocumentGraph.ts index 47668ae00..70966805d 100644 --- a/apps/console/src/hooks/graph/DocumentGraph.ts +++ b/apps/console/src/hooks/graph/DocumentGraph.ts @@ -61,7 +61,7 @@ const bulkDeleteDocumentsMutation = graphql` $input: BulkDeleteDocumentsInput! ) { bulkDeleteDocuments(input: $input) { - deletedDocumentIds @deleteRecord + deletedDocumentIds } } `; diff --git a/apps/console/src/pages/organizations/compliance-page/documents/_components/CompliancePageDocumentList.tsx b/apps/console/src/pages/organizations/compliance-page/documents/_components/CompliancePageDocumentList.tsx index 8b86def8e..25c98d56a 100644 --- a/apps/console/src/pages/organizations/compliance-page/documents/_components/CompliancePageDocumentList.tsx +++ b/apps/console/src/pages/organizations/compliance-page/documents/_components/CompliancePageDocumentList.tsx @@ -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 diff --git a/apps/console/src/pages/organizations/documents/DocumentLayout.tsx b/apps/console/src/pages/organizations/documents/DocumentLayout.tsx index 3759c6f7e..a84ddfbbc 100644 --- a/apps/console/src/pages/organizations/documents/DocumentLayout.tsx +++ b/apps/console/src/pages/organizations/documents/DocumentLayout.tsx @@ -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 diff --git a/apps/console/src/pages/organizations/documents/DocumentsPage.tsx b/apps/console/src/pages/organizations/documents/DocumentsPage.tsx index fdeca3ff6..c0bfc47bf 100644 --- a/apps/console/src/pages/organizations/documents/DocumentsPage.tsx +++ b/apps/console/src/pages/organizations/documents/DocumentsPage.tsx @@ -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")} )} - {organization.canCreateDocument && ( + {organization.canCreateDocument && tab === "ACTIVE" && ( {__("New document")} } @@ -116,9 +100,19 @@ export default function DocumentsPage(props: { )} + + setTab("ACTIVE")}> + {__("Active")} + + setTab("ARCHIVED")}> + {__("Archived")} + + ); diff --git a/apps/console/src/pages/organizations/documents/_components/CreateDocumentDialog.tsx b/apps/console/src/pages/organizations/documents/_components/CreateDocumentDialog.tsx index 432c5a9fc..b4071fd97 100644 --- a/apps/console/src/pages/organizations/documents/_components/CreateDocumentDialog.tsx +++ b/apps/console/src/pages/organizations/documents/_components/CreateDocumentDialog.tsx @@ -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 } } diff --git a/apps/console/src/pages/organizations/documents/_components/DocumentActionsDropdown.tsx b/apps/console/src/pages/organizations/documents/_components/DocumentActionsDropdown.tsx index 4bcd3b9ae..edcbb23db 100644 --- a/apps/console/src/pages/organizations/documents/_components/DocumentActionsDropdown.tsx +++ b/apps/console/src/pages/organizations/documents/_components/DocumentActionsDropdown.tsx @@ -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(null); const confirm = useConfirm(); + const { toast } = useToast(); const document = useFragment(documentFragment, documentFragmentRef); const version = useFragment(versionFragment, versionFragmentRef); @@ -72,6 +114,10 @@ export function DocumentActionsDropdownn(props: { const isDraft = version.status === "DRAFT"; const [deleteDocument, isDeleting] = useDeleteDocumentMutation(); + const [archiveDocument, isArchiving] + = useMutation(archiveDocumentMutation); + const [unarchiveDocument, isUnarchiving] + = useMutation(unarchiveDocumentMutation); const [deleteDraftDocumentVersion, isDeletingDraft] = useDeleteDraftDocumentVersionMutation(); const [exportDocumentVersion, isExporting] @@ -83,6 +129,53 @@ export function DocumentActionsDropdownn(props: { }, ); + const handleArchive = () => { + confirm( + () => + new Promise((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")} + {document.canArchive && ( + + {__("Archive document")} + + )} + {document.canUnarchive && ( + + {__("Unarchive document")} + + )} {document.canDelete && ( (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: { : ( setIsEditingApprover(true)} - canEdit={document.canUpdate} + canEdit={canEdit} >
{approvers.map(approver => ( @@ -220,7 +223,7 @@ export function DocumentLayoutDrawer(props: { : ( setIsEditingType(true)} - canEdit={document.canUpdate} + canEdit={canEdit} >
{getDocumentTypeLabel(__, document.documentType)} @@ -251,7 +254,7 @@ export function DocumentLayoutDrawer(props: { : ( setIsEditingClassification(true)} - canEdit={document.canUpdate} + canEdit={canEdit} >
{getDocumentClassificationLabel( @@ -288,6 +291,13 @@ export function DocumentLayoutDrawer(props: {
)} + {document.archivedAt && ( + + + {formatDate(document.archivedAt)} + + + )} ); } diff --git a/apps/console/src/pages/organizations/documents/_components/DocumentList.tsx b/apps/console/src/pages/organizations/documents/_components/DocumentList.tsx index cddab515c..02414f669 100644 --- a/apps/console/src/pages/organizations/documents/_components/DocumentList.tsx +++ b/apps/console/src/pages/organizations/documents/_components/DocumentList.tsx @@ -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(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( + bulkArchiveMutation, + { successMessage: __("Documents archived successfully."), errorMessage: __("Failed to archive documents") }, + ); + const [bulkUnarchiveDocuments, isBulkUnarchiving] = useMutationWithToasts( + bulkUnarchiveMutation, + { successMessage: __("Documents unarchived successfully."), errorMessage: __("Failed to unarchive documents") }, + ); const { list: selection, toggle, clear, reset } = useList([]); const confirm = useConfirm(); - const [isPending, startTransition] = useTransition(); - const [documentTypeFilter, setDocumentTypeFilter] = useState(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["refetch"] = ({ order }) => { + pagination.refetch({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + order: order as any, + status: [tab], + documentTypes: documentTypeFilter ? [documentTypeFilter] : null, + }); + }; + return (
@@ -188,7 +286,7 @@ export function DocumentList(props: { ? ( ["refetch"]} + refetch={refetchWithFilters} > {selection.length === 0 @@ -196,10 +294,7 @@ export function DocumentList(props: { 0 - } + checked={selection.length === documents.length && documents.length > 0} onChange={() => reset(documents.map(d => d.id))} /> @@ -235,58 +330,90 @@ export function DocumentList(props: {
- {canUpdateAny && ( - - - - )} - {canRequestAnySignatures && ( - - - - )} - - - - {canDeleteAny && ( - - )} + {tab === "ARCHIVED" + ? ( + <> + {canUnarchiveAny && ( + + )} + {canDeleteAny && ( + + )} + + ) + : ( + <> + {canUpdateAny && ( + + + + )} + {canRequestAnySignatures && ( + + + + )} + + + + {canArchiveAny && ( + + )} + {canDeleteAny && ( + + )} + + )}
@@ -311,11 +438,13 @@ export function DocumentList(props: {

- {__("No documents yet")} + {tab === "ARCHIVED" ? __("No archived documents") : __("No documents yet")}

-

- {__("Create your first document to get started.")} -

+ {tab !== "ARCHIVED" && ( +

+ {__("Create your first document to get started.")} +

+ )}
)} diff --git a/apps/console/src/pages/organizations/documents/_components/DocumentListItem.tsx b/apps/console/src/pages/organizations/documents/_components/DocumentListItem.tsx index 55af986a6..09177f2ed 100644 --- a/apps/console/src/pages/organizations/documents/_components/DocumentListItem.tsx +++ b/apps/console/src/pages/organizations/documents/_components/DocumentListItem.tsx @@ -109,7 +109,9 @@ export function DocumentListItem(props: { }; return ( - + diff --git a/pkg/coredata/control_document.go b/pkg/coredata/control_document.go index d81c222c1..fcd9a4a70 100644 --- a/pkg/coredata/control_document.go +++ b/pkg/coredata/control_document.go @@ -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 +} diff --git a/pkg/coredata/document.go b/pkg/coredata/document.go index 7281cf95e..cbaaca20c 100644 --- a/pkg/coredata/document.go +++ b/pkg/coredata/document.go @@ -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, diff --git a/pkg/coredata/document_filter.go b/pkg/coredata/document_filter.go index b752b6eb1..b6070f91e 100644 --- a/pkg/coredata/document_filter.go +++ b/pkg/coredata/document_filter.go @@ -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 )` } diff --git a/pkg/coredata/document_status.go b/pkg/coredata/document_status.go index 9417875b2..677a9b20b 100644 --- a/pkg/coredata/document_status.go +++ b/pkg/coredata/document_status.go @@ -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 } diff --git a/pkg/coredata/document_version.go b/pkg/coredata/document_version.go index 7a0fafeab..1e436f843 100644 --- a/pkg/coredata/document_version.go +++ b/pkg/coredata/document_version.go @@ -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()) diff --git a/pkg/coredata/document_version_status.go b/pkg/coredata/document_version_status.go new file mode 100644 index 000000000..89cab6b4b --- /dev/null +++ b/pkg/coredata/document_version_status.go @@ -0,0 +1,74 @@ +// Copyright (c) 2025 Probo Inc . +// +// 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 +} diff --git a/pkg/coredata/migrations/20260317T120000Z.sql b/pkg/coredata/migrations/20260317T120000Z.sql new file mode 100644 index 000000000..9e0737c5b --- /dev/null +++ b/pkg/coredata/migrations/20260317T120000Z.sql @@ -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; diff --git a/pkg/coredata/risk_document.go b/pkg/coredata/risk_document.go index 296ec94b7..c4e7d876c 100644 --- a/pkg/coredata/risk_document.go +++ b/pkg/coredata/risk_document.go @@ -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 +} diff --git a/pkg/probo/actions.go b/pkg/probo/actions.go index 96faea13c..ec904ac28 100644 --- a/pkg/probo/actions.go +++ b/pkg/probo/actions.go @@ -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" diff --git a/pkg/probo/document_service.go b/pkg/probo/document_service.go index d42af78c4..c6c1d663b 100644 --- a/pkg/probo/document_service.go +++ b/pkg/probo/document_service.go @@ -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, diff --git a/pkg/probo/policies.go b/pkg/probo/policies.go index 5d01afabf..1752e22a5 100644 --- a/pkg/probo/policies.go +++ b/pkg/probo/policies.go @@ -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") diff --git a/pkg/server/api/console/v1/schema.graphql b/pkg/server/api/console/v1/schema.graphql index 959381aa9..77c5defea 100644 --- a/pkg/server/api/console/v1/schema.graphql +++ b/pkg/server/api/console/v1/schema.graphql @@ -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! diff --git a/pkg/server/api/console/v1/types/document.go b/pkg/server/api/console/v1/types/document.go index 135d104fc..9f7636a13 100644 --- a/pkg/server/api/console/v1/types/document.go +++ b/pkg/server/api/console/v1/types/document.go @@ -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, } diff --git a/pkg/server/api/console/v1/v1_resolver.go b/pkg/server/api/console/v1/v1_resolver.go index 921f0fff4..e6722f09a 100644 --- a/pkg/server/api/console/v1/v1_resolver.go +++ b/pkg/server/api/console/v1/v1_resolver.go @@ -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) diff --git a/pkg/server/api/mcp/v1/schema.resolvers.go b/pkg/server/api/mcp/v1/schema.resolvers.go index e16d7ba6a..f77810779 100644 --- a/pkg/server/api/mcp/v1/schema.resolvers.go +++ b/pkg/server/api/mcp/v1/schema.resolvers.go @@ -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 +} diff --git a/pkg/server/api/mcp/v1/specification.yaml b/pkg/server/api/mcp/v1/specification.yaml index b08bcbfb6..5c9c404c8 100644 --- a/pkg/server/api/mcp/v1/specification.yaml +++ b/pkg/server/api/mcp/v1/specification.yaml @@ -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: diff --git a/pkg/server/api/mcp/v1/types/document.go b/pkg/server/api/mcp/v1/types/document.go index 5b048b1c3..94c4ae6a3 100644 --- a/pkg/server/api/mcp/v1/types/document.go +++ b/pkg/server/api/mcp/v1/types/document.go @@ -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, } diff --git a/pkg/server/api/trust/v1/v1_resolver.go b/pkg/server/api/trust/v1/v1_resolver.go index 2c7fdbf39..fb966b037 100644 --- a/pkg/server/api/trust/v1/v1_resolver.go +++ b/pkg/server/api/trust/v1/v1_resolver.go @@ -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) } diff --git a/pkg/trust/document_service.go b/pkg/trust/document_service.go index 82bf7e4aa..23a1450d9 100644 --- a/pkg/trust/document_service.go +++ b/pkg/trust/document_service.go @@ -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") }