diff --git a/apps/console/src/pages/organizations/documents/DocumentLayout.tsx b/apps/console/src/pages/organizations/documents/DocumentLayout.tsx index a17487483..35726f898 100644 --- a/apps/console/src/pages/organizations/documents/DocumentLayout.tsx +++ b/apps/console/src/pages/organizations/documents/DocumentLayout.tsx @@ -22,7 +22,7 @@ import { graphql } from "relay-runtime"; import type { DocumentLayoutQuery } from "#/__generated__/core/DocumentLayoutQuery.graphql"; import { useOrganizationId } from "#/hooks/useOrganizationId"; -import { DocumentActionsDropdownn } from "./_components/DocumentActionsDropdown"; +import { DocumentActionsDropdown } from "./_components/DocumentActionsDropdown"; import { DocumentLayoutDrawer } from "./_components/DocumentLayoutDrawer"; import { DocumentTitleForm } from "./_components/DocumentTitleForm"; import { DocumentVersionsDropdown } from "./_components/DocumentVersionsDropdown"; @@ -142,7 +142,6 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery )} - diff --git a/apps/console/src/pages/organizations/documents/_components/CreateDocumentDialog.tsx b/apps/console/src/pages/organizations/documents/_components/CreateDocumentDialog.tsx index 30448a66b..79613c9ce 100644 --- a/apps/console/src/pages/organizations/documents/_components/CreateDocumentDialog.tsx +++ b/apps/console/src/pages/organizations/documents/_components/CreateDocumentDialog.tsx @@ -12,6 +12,7 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. +import { formatError } from "@probo/helpers"; import { useTranslate } from "@probo/i18n"; import { Badge, @@ -24,8 +25,10 @@ import { Label, PropertyRow, useDialogRef, + useToast, } from "@probo/ui"; import { type ReactNode } from "react"; +import { useMutation } from "react-relay"; import { graphql } from "relay-runtime"; import { z } from "zod"; @@ -33,11 +36,11 @@ import type { CreateDocumentDialogMutation } from "#/__generated__/core/CreateDo import { ControlledField } from "#/components/form/ControlledField"; import { DocumentClassificationOptions } from "#/components/form/DocumentClassificationOptions"; import { DocumentTypeOptions } from "#/components/form/DocumentTypeOptions"; +import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField"; import { useFormWithSchema } from "#/hooks/useFormWithSchema"; -import { useMutationWithToasts } from "#/hooks/useMutationWithToasts"; import { useOrganizationId } from "#/hooks/useOrganizationId"; -type Props = { +type CreateDocumentDialogProps = { trigger?: ReactNode; connection: string; }; @@ -68,14 +71,16 @@ const documentSchema = z.object({ title: z.string().min(1, "Title is required"), documentType: z.enum(["OTHER", "GOVERNANCE", "POLICY", "PROCEDURE", "PLAN", "REGISTER", "RECORD", "REPORT", "TEMPLATE"]), classification: z.enum(["PUBLIC", "INTERNAL", "CONFIDENTIAL", "SECRET"]), + defaultApproverIds: z.array(z.string()), }); /** * Dialog to create or update a document */ -export function CreateDocumentDialog({ trigger, connection }: Props) { +export function CreateDocumentDialog({ trigger, connection }: CreateDocumentDialogProps) { const { __ } = useTranslate(); const organizationId = useOrganizationId(); + const { toast } = useToast(); const { control, handleSubmit, register, formState, reset } = useFormWithSchema( documentSchema, @@ -83,15 +88,16 @@ export function CreateDocumentDialog({ trigger, connection }: Props) { defaultValues: { documentType: "POLICY", classification: "INTERNAL", + defaultApproverIds: [], }, }, ); const errors = formState.errors ?? {}; const [createDocument, isLoading] - = useMutationWithToasts(createDocumentMutation); + = useMutation(createDocumentMutation); - const onSubmit = async (data: z.infer) => { - await createDocument({ + const onSubmit = (data: z.infer) => { + createDocument({ variables: { input: { ...data, @@ -99,12 +105,18 @@ export function CreateDocumentDialog({ trigger, connection }: Props) { }, connections: [connection], }, - successMessage: __("Document created successfully."), - errorMessage: __("Failed to create document"), - onSuccess: () => { + onCompleted(_, errors) { + if (errors?.length) { + toast({ title: __("Error"), description: formatError(__("Failed to create document"), errors), variant: "error" }); + return; + } + toast({ title: __("Success"), description: __("Document created successfully."), variant: "success" }); dialogRef.current?.close(); reset(); }, + onError(error) { + toast({ title: __("Error"), description: error.message, variant: "error" }); + }, }); }; @@ -165,6 +177,15 @@ export function CreateDocumentDialog({ trigger, connection }: Props) { + + + + diff --git a/apps/console/src/pages/organizations/documents/_components/DocumentActionsDropdown.tsx b/apps/console/src/pages/organizations/documents/_components/DocumentActionsDropdown.tsx index 032f35c8d..307977b90 100644 --- a/apps/console/src/pages/organizations/documents/_components/DocumentActionsDropdown.tsx +++ b/apps/console/src/pages/organizations/documents/_components/DocumentActionsDropdown.tsx @@ -23,12 +23,11 @@ import { ConnectionHandler, graphql } from "relay-runtime"; import type { DocumentActionsDropdown_archiveMutation } from "#/__generated__/core/DocumentActionsDropdown_archiveMutation.graphql"; import type { DocumentActionsDropdown_createDraftMutation } from "#/__generated__/core/DocumentActionsDropdown_createDraftMutation.graphql"; import type { DocumentActionsDropdown_documentFragment$key } from "#/__generated__/core/DocumentActionsDropdown_documentFragment.graphql"; +import type { DocumentActionsDropdown_exportVersionMutation } from "#/__generated__/core/DocumentActionsDropdown_exportVersionMutation.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"; import { DocumentsConnectionKey, useDeleteDocumentMutation, useDeleteDraftDocumentVersionMutation } from "#/hooks/graph/DocumentGraph"; -import { useMutationWithToasts } from "#/hooks/useMutationWithToasts"; import { useOrganizationId } from "#/hooks/useOrganizationId"; import { CurrentUser } from "#/providers/CurrentUser"; @@ -129,7 +128,7 @@ const versionFragment = graphql` `; const exportDocumentVersionMutation = graphql` - mutation DocumentActionsDropdownn_exportVersionMutation( + mutation DocumentActionsDropdown_exportVersionMutation( $input: ExportDocumentVersionPDFInput! ) { exportDocumentVersionPDF(input: $input) { @@ -138,7 +137,7 @@ const exportDocumentVersionMutation = graphql` } `; -export function DocumentActionsDropdownn(props: { +export function DocumentActionsDropdown(props: { documentFragmentRef: DocumentActionsDropdown_documentFragment$key; versionFragmentRef: DocumentActionsDropdown_versionFragment$key; onRefetch: () => void; @@ -158,7 +157,7 @@ export function DocumentActionsDropdownn(props: { const version = useFragment(versionFragment, versionFragmentRef); const lastVersion = document.versions.edges[0].node; - const hasDraft = lastVersion.status === "DRAFT"; + const isLastVersionPublished = lastVersion.status === "PUBLISHED"; const isDraft = version.status === "DRAFT"; const [createDraftDocumentVersion, isCreatingDraft] @@ -171,13 +170,7 @@ export function DocumentActionsDropdownn(props: { const [deleteDraftDocumentVersion, isDeletingDraft] = useDeleteDraftDocumentVersionMutation(); const [exportDocumentVersion, isExporting] - = useMutationWithToasts( - exportDocumentVersionMutation, - { - successMessage: __("PDF download started."), - errorMessage: __("Failed to generate PDF"), - }, - ); + = useMutation(exportDocumentVersionMutation); const handleCreateDraft = () => { const connectionId = ConnectionHandler.getConnectionID(document.id, "DocumentversionsDropdownMenu_versions"); @@ -205,7 +198,6 @@ export function DocumentActionsDropdownn(props: { void navigate(`/organizations/${organizationId}/documents/${document.id}/versions/${newVersionId}`); }, onError(error) { - console.log(error); toast({ title: __("Error"), description: error.message, variant: "error" }); }, }); @@ -320,7 +312,7 @@ export function DocumentActionsDropdownn(props: { ); }; - const handleExportDocumentVersion = async (options: { + const handleExportDocumentVersion = (options: { withWatermark: boolean; withSignatures: boolean; watermarkEmail?: string; @@ -333,10 +325,15 @@ export function DocumentActionsDropdownn(props: { && options.watermarkEmail && { watermarkEmail: options.watermarkEmail }), }; - await exportDocumentVersion({ + exportDocumentVersion({ variables: { input }, onCompleted: (data, errors) => { if (errors?.length) { + toast({ + title: __("Error"), + description: errors[0]?.message || __("Failed to generate PDF"), + variant: "error", + }); return; } @@ -349,6 +346,9 @@ export function DocumentActionsDropdownn(props: { window.document.body.removeChild(link); } }, + onError(error) { + toast({ title: __("Error"), description: error.message, variant: "error" }); + }, }); }; @@ -356,12 +356,12 @@ export function DocumentActionsDropdownn(props: { <> void handleExportDocumentVersion(options)} + onDownload={handleExportDocumentVersion} isLoading={isExporting} defaultEmail={defaultEmail} /> - {document.canUpdate && !hasDraft && ( + {document.canUpdate && isLastVersionPublished && ( {__("Download PDF")} - {document.canArchive && ( + {document.canArchive && document.status === "ACTIVE" && ( )} - {document.canUnarchive && ( + {document.canUnarchive && document.status === "ARCHIVED" && ( (documentFragment, documentFragmentRef); @@ -120,12 +149,28 @@ export function DocumentLayoutDrawer(props: { }, ); + const { + control: approversControl, + handleSubmit: handleApproversSubmit, + reset: resetApprovers, + } = useFormWithSchema( + approversSchema, + { + defaultValues: { + approverIds: document.defaultApprovers.map(a => a.id), + }, + }, + ); + const [updateDocumentType, isUpdatingDocumentType] = useMutation(updateDocumentTypeMutation); const [updateClassification, isUpdatingClassification] = useMutation(updateClassificationMutation); + const [updateApprovers, isUpdatingApprovers] + = useMutation(updateApproversMutation); + const handleUpdateDocumentType = (data: { documentType: (typeof documentTypes)[number]; }) => { @@ -182,11 +227,74 @@ export function DocumentLayoutDrawer(props: { }); }; + const handleUpdateApprovers = (data: { approverIds: string[] }) => { + updateApprovers({ + variables: { + input: { + id: document.id, + defaultApproverIds: data.approverIds, + }, + }, + onCompleted: () => { + setIsEditingApprovers(false); + toast({ + title: __("Success"), + description: __("Approvers updated successfully"), + variant: "success", + }); + }, + onError: () => { + toast({ + title: __("Error"), + description: __("Failed to update approvers"), + variant: "error", + }); + }, + }); + }; + return (
{__("Properties")}
+ + {isEditingApprovers + ? ( + void handleApproversSubmit(handleUpdateApprovers)()} + onCancel={() => { + setIsEditingApprovers(false); + resetApprovers({ approverIds: document.defaultApprovers.map(a => a.id) }); + }} + disabled={isUpdatingApprovers} + > + ({ + id: a.id, + fullName: a.fullName, + emailAddress: a.emailAddress, + }))} + placeholder={__("Add approvers...")} + /> + + ) + : ( + setIsEditingApprovers(true)} + canEdit={canEdit} + > +
+ {document.defaultApprovers.length > 0 + ? document.defaultApprovers.map(a => a.fullName).join(", ") + : __("None")} +
+
+ )} +
{isEditingType ? ( @@ -251,11 +359,11 @@ export function DocumentLayoutDrawer(props: { - {isDraft ? __("Draft") : __("Published")} + {version.status === "PUBLISHED" ? __("Published") : version.status === "PENDING_APPROVAL" ? __("Pending approval") : __("Draft")} diff --git a/apps/console/src/pages/organizations/documents/_components/DocumentList.tsx b/apps/console/src/pages/organizations/documents/_components/DocumentList.tsx index b4c16e9f4..0d8866d64 100644 --- a/apps/console/src/pages/organizations/documents/_components/DocumentList.tsx +++ b/apps/console/src/pages/organizations/documents/_components/DocumentList.tsx @@ -365,15 +365,14 @@ export function DocumentList(props: { {__("Classification")} {__("Approvers")} - {__("Last update")} - {__("Approvals")} + {__("Last update")} {__("Signatures")} {hasAnyAction && } ) : ( - +
{sprintf(__("%s documents selected"), selection.length)} diff --git a/apps/console/src/pages/organizations/documents/_components/DocumentListItem.tsx b/apps/console/src/pages/organizations/documents/_components/DocumentListItem.tsx index cde680979..273cf60c4 100644 --- a/apps/console/src/pages/organizations/documents/_components/DocumentListItem.tsx +++ b/apps/console/src/pages/organizations/documents/_components/DocumentListItem.tsx @@ -28,6 +28,10 @@ const fragment = graphql` title updatedAt canDelete: permission(action: "core:document:delete") + defaultApprovers { + id + fullName + } recentVersions: versions(first: 2 orderBy: { field: CREATED_AT direction: DESC }) { edges { node { @@ -41,15 +45,8 @@ const fragment = graphql` edges { node { status - decisions(first: 20) { + decisions(first: 0) { totalCount - edges { - node { - approver { - fullName - } - } - } } approvedDecisions: decisions(first: 0 filter: { states: [APPROVED] }) { totalCount @@ -96,29 +93,30 @@ export function DocumentListItem(props: { } = props; const organizationId = useOrganizationId(); + const { __ } = useTranslate(); + const [deleteDocument] = useMutation(deleteDocumentMutation); + const confirm = useConfirm(); const document = useFragment( fragment, fragmentRef, ); - const lastVersion = document.recentVersions.edges[0].node; - const approverQuorum = lastVersion.approvalQuorums?.edges?.[0]?.node - ?? document.recentVersions.edges[1]?.node.approvalQuorums?.edges?.[0]?.node; - const { __ } = useTranslate(); + const lastVersionEdge = document.recentVersions.edges[0]; + if (!lastVersionEdge) return null; + const lastVersion = lastVersionEdge.node; const statusVariant = { DRAFT: "neutral", + PENDING_APPROVAL: "warning", PUBLISHED: "success", } as const; const statusLabel = { DRAFT: __("Draft"), + PENDING_APPROVAL: __("Pending approval"), PUBLISHED: __("Published"), } as const; - const [deleteDocument] = useMutation(deleteDocumentMutation); - const confirm = useConfirm(); - const handleDelete = () => { confirm( () => @@ -172,23 +170,19 @@ export function DocumentListItem(props: { {(() => { - const decisions = approverQuorum?.decisions; - if (!decisions?.edges.length) return "—"; - const names = decisions.edges.map(e => e.node.approver.fullName).join(", "); - return decisions.totalCount > 20 ? `${names}...` : names; - })()} - - {formatDate(document.updatedAt)} - - {(() => { - const lastQuorum = lastVersion.approvalQuorums?.edges?.[0]?.node; - return lastQuorum - ? lastQuorum.status === "REJECTED" - ? __("Rejected") - : `${lastQuorum.approvedDecisions.totalCount}/${lastQuorum.decisions.totalCount}` - : "—"; + if (lastVersion.status === "PENDING_APPROVAL") { + const quorum = lastVersion.approvalQuorums?.edges?.[0]?.node; + if (quorum) { + if (quorum.status === "REJECTED") return __("Rejected"); + return `${quorum.approvedDecisions.totalCount}/${quorum.decisions.totalCount}`; + } + return "—"; + } + if (!document.defaultApprovers.length) return "—"; + return document.defaultApprovers.map(a => a.fullName).join(", "); })()} + {formatDate(document.updatedAt)} {lastVersion.signedSignatures.totalCount} / diff --git a/apps/console/src/pages/organizations/documents/_components/DocumentVersionsDropdownItem.tsx b/apps/console/src/pages/organizations/documents/_components/DocumentVersionsDropdownItem.tsx index 438a01eda..8a5dfa389 100644 --- a/apps/console/src/pages/organizations/documents/_components/DocumentVersionsDropdownItem.tsx +++ b/apps/console/src/pages/organizations/documents/_components/DocumentVersionsDropdownItem.tsx @@ -78,6 +78,11 @@ export function DocumentVersionsDropdownItem(props: { {__("Draft")} )} + {version.status === "PENDING_APPROVAL" && ( + + {__("Pending approval")} + + )}
{dateTimeFormat(version.publishedAt ?? version.updatedAt)} diff --git a/apps/console/src/pages/organizations/documents/_components/PublishDialog.tsx b/apps/console/src/pages/organizations/documents/_components/PublishDialog.tsx index 2bc2505ae..0f62827e8 100644 --- a/apps/console/src/pages/organizations/documents/_components/PublishDialog.tsx +++ b/apps/console/src/pages/organizations/documents/_components/PublishDialog.tsx @@ -21,12 +21,11 @@ import { DialogFooter, IconSend, IconUpload, - IconWarning, Textarea, useDialogRef, useToast, } from "@probo/ui"; -import { type Ref, useImperativeHandle, useRef } from "react"; +import { type Ref, useImperativeHandle, useMemo, useRef } from "react"; import { useFragment, useMutation } from "react-relay"; import { graphql } from "relay-runtime"; import { z } from "zod"; @@ -43,36 +42,17 @@ export type PublishDialogRef = { open: () => void; }; -type Props = { +type PublishDialogProps = { ref: Ref; documentId: string; documentFragmentRef: PublishDialog_documentFragment$key; - hasPendingApproval: boolean; onSuccess: () => void; }; const documentFragment = graphql` fragment PublishDialog_documentFragment on Document { - lastPublishedVersion: versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }, filter: { statuses: [PUBLISHED] }) { - edges { - node { - approvalQuorums(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) { - edges { - node { - decisions(first: 100) { - edges { - node { - approver { - id - } - } - } - } - } - } - } - } - } + defaultApprovers { + id } } `; @@ -147,24 +127,20 @@ export function PublishDialog({ ref, documentId, documentFragmentRef, - hasPendingApproval, onSuccess, -}: Props) { +}: PublishDialogProps) { const document = useFragment(documentFragment, documentFragmentRef); const { __ } = useTranslate(); const { toast } = useToast(); const organizationId = useOrganizationId(); const dialogRef = useDialogRef(); - const previousApproverIds = document.lastPublishedVersion.edges[0] - ?.node.approvalQuorums.edges[0] - ?.node.decisions?.edges.map(e => e.node.approver.id) - ?? []; - - const schema = z.object({ + const publishSchema = useMemo(() => z.object({ changelog: z.string().min(1, __("Changelog is required")), approverIds: z.array(z.string()), - }); + }), [__]); + + const defaultApproverIds = document.defaultApprovers.map(a => a.id); const { control, @@ -173,7 +149,7 @@ export function PublishDialog({ reset, watch, formState: { errors }, - } = useFormWithSchema(schema, { + } = useFormWithSchema(publishSchema, { defaultValues: { changelog: "", approverIds: [], @@ -184,7 +160,7 @@ export function PublishDialog({ open: () => { reset({ changelog: "", - approverIds: previousApproverIds, + approverIds: defaultApproverIds, }); dialogRef.current?.open(); }, @@ -199,6 +175,7 @@ export function PublishDialog({ const isBusy = isPublishingMajor || isPublishingMinor || isRequesting; const approverIds = watch("approverIds"); + const hasApprovers = approverIds.length > 0; const actionRef = useRef<"publish" | "publish-minor" | "request-approval">("publish"); const onPublishCompleted = (_: unknown, errors: ReadonlyArray<{ message: string }> | null) => { @@ -223,7 +200,7 @@ export function PublishDialog({ toast({ title: __("Error"), description: error.message, variant: "error" }); }; - const handlePublishMajor = (data: z.infer) => { + const handlePublishMajor = (data: z.infer) => { publishMajor({ variables: { input: { documentId, changelog: data.changelog } }, onCompleted: onPublishCompleted, @@ -231,7 +208,7 @@ export function PublishDialog({ }); }; - const handlePublishMinor = (data: z.infer) => { + const handlePublishMinor = (data: z.infer) => { publishMinor({ variables: { input: { documentId, changelog: data.changelog } }, onCompleted: onPublishCompleted, @@ -239,7 +216,7 @@ export function PublishDialog({ }); }; - const onRequestApproval = (data: z.infer) => { + const onRequestApproval = (data: z.infer) => { requestApproval({ variables: { input: { @@ -276,12 +253,16 @@ export function PublishDialog({
void handleSubmit((data) => { - if (actionRef.current === "publish") { - handlePublishMajor(data); - } else if (actionRef.current === "publish-minor") { + const action = actionRef.current; + actionRef.current = "publish"; + if (action === "publish-minor") { handlePublishMinor(data); - } else { + } else if (action === "request-approval") { onRequestApproval(data); + } else if (data.approverIds.length > 0) { + onRequestApproval(data); + } else { + handlePublishMajor(data); } })(e)} > @@ -303,63 +284,64 @@ export function PublishDialog({

{errors.changelog.message}

)}
- {hasPendingApproval - ? ( -
- -

- {__("An approval review is currently in progress. Publishing now will bypass the pending approval.")} -

-
- ) - : ( -
-
- {__("Request approval before publishing")} -
-

- {__("Select approvers to review this document. The document will be published once all approvers have approved it. You can also publish directly without requiring approval.")} -

- -
- )} +
+

+ {__("Approvers will receive an email and the document will be published as a major version once all have approved. Remove all approvers to publish directly as major.")} +

+ +
- - - {!hasPendingApproval && ( - - )} + {hasApprovers + ? ( + <> + + + + ) + : ( + <> + + + + )} diff --git a/apps/console/src/pages/organizations/documents/_components/PublishDocumentsDialog.tsx b/apps/console/src/pages/organizations/documents/_components/PublishDocumentsDialog.tsx index eb0f7e5b9..9809649ae 100644 --- a/apps/console/src/pages/organizations/documents/_components/PublishDocumentsDialog.tsx +++ b/apps/console/src/pages/organizations/documents/_components/PublishDocumentsDialog.tsx @@ -156,9 +156,10 @@ export function PublishDocumentsDialog({
-

- {__("This will publish the selected documents directly without requiring approval.")} -

+
+

{__("Publishing as major will request approval for documents that have default approvers configured. Approvers will receive an email notification.")}

+

{__("Documents already published and pending approval will be skipped.")}

+
); @@ -245,6 +244,20 @@ function ViewerDecision(props: { const isPending = decision.state === "PENDING"; const isApproved = decision.state === "APPROVED"; const isRejected = decision.state === "REJECTED"; + const isVoided = decision.state === "VOIDED"; + + if (isVoided) { + return ( + <> +
+ {__("Your approval is no longer required for this version.")} +
+ + + ); + } if (!decision.canApprove && !decision.canReject) { return ( @@ -484,10 +497,7 @@ function DocumentApproveContent({ }, [selectedVersion?.id, exportPDF, toast, __]); return ( -
+

diff --git a/apps/console/src/pages/organizations/employee/EmployeeApprovalsPage.tsx b/apps/console/src/pages/organizations/employee/EmployeeApprovalsPage.tsx index 22c5c8d3d..d1ce9dfc0 100644 --- a/apps/console/src/pages/organizations/employee/EmployeeApprovalsPage.tsx +++ b/apps/console/src/pages/organizations/employee/EmployeeApprovalsPage.tsx @@ -18,7 +18,6 @@ import { Card, Tbody, Th, Thead, Tr } from "@probo/ui"; import { graphql, type PreloadedQuery, usePreloadedQuery } from "react-relay"; import type { EmployeeApprovalsPageQuery } from "#/__generated__/core/EmployeeApprovalsPageQuery.graphql"; -import { useOrganizationId } from "#/hooks/useOrganizationId"; import { ApprovableDocumentRow } from "./_components/ApprovableDocumentRow"; @@ -46,7 +45,6 @@ export function EmployeeApprovalsPage(props: { }) { const { queryRef } = props; const { __ } = useTranslate(); - const organizationId = useOrganizationId(); const { viewer: { approvableDocuments }, @@ -79,7 +77,6 @@ export function EmployeeApprovalsPage(props: { ))} diff --git a/apps/console/src/pages/organizations/employee/_components/ApprovableDocumentRow.tsx b/apps/console/src/pages/organizations/employee/_components/ApprovableDocumentRow.tsx index f0012a025..418d53dfa 100644 --- a/apps/console/src/pages/organizations/employee/_components/ApprovableDocumentRow.tsx +++ b/apps/console/src/pages/organizations/employee/_components/ApprovableDocumentRow.tsx @@ -22,6 +22,7 @@ import { Badge, Td, Tr } from "@probo/ui"; import { graphql, useFragment } from "react-relay"; import type { ApprovableDocumentRowFragment$key } from "#/__generated__/core/ApprovableDocumentRowFragment.graphql"; +import { useOrganizationId } from "#/hooks/useOrganizationId"; const fragment = graphql` fragment ApprovableDocumentRowFragment on EmployeeDocument { @@ -42,26 +43,32 @@ const fragment = graphql` export function ApprovableDocumentRow({ fKey, - organizationId, }: { fKey: ApprovableDocumentRowFragment$key; - organizationId: string; }) { - const document = useFragment(fragment, fKey); - const lastVersion = document.lastVersion.edges[0].node; + const organizationId = useOrganizationId(); const { __ } = useTranslate(); + const document = useFragment(fragment, fKey); + + const lastVersionEdge = document.lastVersion.edges[0]; + if (!lastVersionEdge) return null; + const lastVersion = lastVersionEdge.node; const stateVariant = document.approvalState === "APPROVED" ? "success" : document.approvalState === "REJECTED" ? "danger" - : "warning"; + : document.approvalState === "VOIDED" + ? "neutral" + : "warning"; const stateLabel = document.approvalState === "APPROVED" ? __("Approved") : document.approvalState === "REJECTED" ? __("Rejected") - : __("Pending"); + : document.approvalState === "VOIDED" + ? __("No longer required") + : __("Pending"); return ( diff --git a/e2e/console/document_version_test.go b/e2e/console/document_version_test.go index 61203ff2f..c1f4bf4b2 100644 --- a/e2e/console/document_version_test.go +++ b/e2e/console/document_version_test.go @@ -353,15 +353,49 @@ func TestDocumentVersion_RequestSignature(t *testing.T) { assert.Equal(t, signerProfileID, result.RequestSignature.DocumentVersionSignatureEdge.Node.SignedBy.ID) } +func createTestDocumentWithApprovers(t *testing.T, owner *testutil.Client, approverIDs []string) (docID string) { + t.Helper() + + var result struct { + CreateDocument struct { + DocumentEdge struct { + Node struct { + ID string `json:"id"` + } `json:"node"` + } `json:"documentEdge"` + } `json:"createDocument"` + } + + err := owner.Execute(` + mutation($input: CreateDocumentInput!) { + createDocument(input: $input) { + documentEdge { + node { id } + } + } + } + `, map[string]any{ + "input": map[string]any{ + "organizationId": owner.GetOrganizationID().String(), + "title": "Test Document With Approvers", + "content": testutil.ProseMirrorTextDoc("Initial content"), + "documentType": "POLICY", + "classification": "INTERNAL", + "defaultApproverIds": approverIDs, + }, + }, &result) + require.NoError(t, err) + + return result.CreateDocument.DocumentEdge.Node.ID +} + func TestDocumentVersion_BulkPublish(t *testing.T) { t.Parallel() owner := testutil.NewClient(t, testutil.RoleOwner) - // Create multiple documents + // Create multiple draft documents (no default approvers — should publish directly) docID1, _ := createTestDocument(t, owner) docID2, _ := createTestDocument(t, owner) - approveTestDocument(t, owner, docID1) - approveTestDocument(t, owner, docID2) query := ` mutation BulkPublishMajorDocumentVersions($input: BulkPublishDocumentVersionsInput!) { @@ -397,6 +431,170 @@ func TestDocumentVersion_BulkPublish(t *testing.T) { } } +func TestDocumentVersion_BulkPublishRequestsApproval(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + approverID := getOwnerProfileID(t, owner) + + // Create a document with default approvers + docID := createTestDocumentWithApprovers(t, owner, []string{approverID}) + + query := ` + mutation BulkPublishMajorDocumentVersions($input: BulkPublishDocumentVersionsInput!) { + bulkPublishMajorDocumentVersions(input: $input) { + documentVersions { + id + status + major + minor + } + } + } + ` + + var result struct { + BulkPublishMajorDocumentVersions struct { + DocumentVersions []struct { + ID string `json:"id"` + Status string `json:"status"` + Major int `json:"major"` + Minor int `json:"minor"` + } `json:"documentVersions"` + } `json:"bulkPublishMajorDocumentVersions"` + } + + err := owner.Execute(query, map[string]any{ + "input": map[string]any{ + "documentIds": []string{docID}, + "changelog": "Needs approval", + }, + }, &result) + require.NoError(t, err) + + require.Len(t, result.BulkPublishMajorDocumentVersions.DocumentVersions, 1) + dv := result.BulkPublishMajorDocumentVersions.DocumentVersions[0] + assert.Equal(t, "PENDING_APPROVAL", dv.Status) + assert.Equal(t, 1, dv.Major) + assert.Equal(t, 0, dv.Minor) +} + +func TestDocumentVersion_BulkPublishSkipsPendingApproval(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + approverID := getOwnerProfileID(t, owner) + + // Create a document with default approvers and bulk publish it (puts it in PENDING_APPROVAL) + docID := createTestDocumentWithApprovers(t, owner, []string{approverID}) + + _, err := owner.Do(` + mutation($input: BulkPublishDocumentVersionsInput!) { + bulkPublishMajorDocumentVersions(input: $input) { + documentVersions { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "documentIds": []string{docID}, + "changelog": "First approval request", + }, + }) + require.NoError(t, err) + + // Bulk publish again — should skip the pending document and return empty + var result struct { + BulkPublishMajorDocumentVersions struct { + DocumentVersions []struct { + ID string `json:"id"` + } `json:"documentVersions"` + } `json:"bulkPublishMajorDocumentVersions"` + } + + err = owner.Execute(` + mutation($input: BulkPublishDocumentVersionsInput!) { + bulkPublishMajorDocumentVersions(input: $input) { + documentVersions { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "documentIds": []string{docID}, + "changelog": "Second attempt", + }, + }, &result) + require.NoError(t, err) + + assert.Empty(t, result.BulkPublishMajorDocumentVersions.DocumentVersions) +} + +func TestDocumentVersion_BulkPublishMinorSkipsPendingApproval(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + // Create and publish a document first (need a published version for minor publish) + docID, _ := createTestDocument(t, owner) + approveTestDocument(t, owner, docID) + + // Create a draft so we can publish minor + _, err := owner.Do(` + mutation($input: CreateDraftDocumentVersionInput!) { + createDraftDocumentVersion(input: $input) { + documentVersionEdge { + node { id } + } + } + } + `, map[string]any{ + "input": map[string]any{ + "documentID": docID, + }, + }) + require.NoError(t, err) + + // Request approval to put it in PENDING_APPROVAL + approverID := getOwnerProfileID(t, owner) + _, err = owner.Do(` + mutation($input: RequestDocumentVersionApprovalInput!) { + requestDocumentVersionApproval(input: $input) { + approvalQuorum { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "documentId": docID, + "approverIds": []string{approverID}, + "changelog": "Approval request", + }, + }) + require.NoError(t, err) + + // Bulk publish minor — should skip the pending document + var result struct { + BulkPublishMinorDocumentVersions struct { + DocumentVersions []struct { + ID string `json:"id"` + } `json:"documentVersions"` + } `json:"bulkPublishMinorDocumentVersions"` + } + + err = owner.Execute(` + mutation($input: BulkPublishDocumentVersionsInput!) { + bulkPublishMinorDocumentVersions(input: $input) { + documentVersions { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "documentIds": []string{docID}, + "changelog": "Minor publish attempt", + }, + }, &result) + require.NoError(t, err) + + assert.Empty(t, result.BulkPublishMinorDocumentVersions.DocumentVersions) +} + func TestDocumentVersion_BulkRequestSignatures(t *testing.T) { t.Parallel() owner := testutil.NewClient(t, testutil.RoleOwner) @@ -480,3 +678,468 @@ func TestDocumentVersion_BulkDelete(t *testing.T) { assert.Contains(t, result.BulkDeleteDocuments.DeletedDocumentIds, docID1) assert.Contains(t, result.BulkDeleteDocuments.DeletedDocumentIds, docID2) } + +func TestDocumentVersion_VoidApproval(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + docID, _ := createTestDocument(t, owner) + approverID := getOwnerProfileID(t, owner) + + // Request approval + _, err := owner.Do(` + mutation($input: RequestDocumentVersionApprovalInput!) { + requestDocumentVersionApproval(input: $input) { + approvalQuorum { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "documentId": docID, + "approverIds": []string{approverID}, + "changelog": "Test changelog", + }, + }) + require.NoError(t, err) + + // Get version ID and verify version bumped to 1.0 + var versionResult struct { + Node struct { + Versions struct { + Edges []struct { + Node struct { + ID string `json:"id"` + Status string `json:"status"` + Major int `json:"major"` + Minor int `json:"minor"` + } `json:"node"` + } `json:"edges"` + } `json:"versions"` + } `json:"node"` + } + + err = owner.Execute(` + query($id: ID!) { + node(id: $id) { + ... on Document { + versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) { + edges { node { id status major minor } } + } + } + } + } + `, map[string]any{"id": docID}, &versionResult) + require.NoError(t, err) + require.NotEmpty(t, versionResult.Node.Versions.Edges) + assert.Equal(t, "PENDING_APPROVAL", versionResult.Node.Versions.Edges[0].Node.Status) + assert.Equal(t, 1, versionResult.Node.Versions.Edges[0].Node.Major) + assert.Equal(t, 0, versionResult.Node.Versions.Edges[0].Node.Minor) + + versionID := versionResult.Node.Versions.Edges[0].Node.ID + + // Void approval — version should revert to 0.1 + var voidResult struct { + VoidDocumentVersionApproval struct { + ApprovalQuorum struct { + ID string `json:"id"` + Status string `json:"status"` + } `json:"approvalQuorum"` + DocumentVersion struct { + ID string `json:"id"` + Status string `json:"status"` + Major int `json:"major"` + Minor int `json:"minor"` + } `json:"documentVersion"` + } `json:"voidDocumentVersionApproval"` + } + + err = owner.Execute(` + mutation($input: VoidDocumentVersionApprovalInput!) { + voidDocumentVersionApproval(input: $input) { + approvalQuorum { id status } + documentVersion { id status major minor } + } + } + `, map[string]any{ + "input": map[string]any{ + "documentVersionId": versionID, + }, + }, &voidResult) + require.NoError(t, err) + + assert.Equal(t, "VOIDED", voidResult.VoidDocumentVersionApproval.ApprovalQuorum.Status) + assert.Equal(t, "DRAFT", voidResult.VoidDocumentVersionApproval.DocumentVersion.Status) + assert.Equal(t, 0, voidResult.VoidDocumentVersionApproval.DocumentVersion.Major) + assert.Equal(t, 1, voidResult.VoidDocumentVersionApproval.DocumentVersion.Minor) + + // Verify decisions are VOIDED after voiding + var quorumResult struct { + Node struct { + Versions struct { + Edges []struct { + Node struct { + ApprovalQuorums struct { + Edges []struct { + Node struct { + Decisions struct { + Edges []struct { + Node struct { + State string `json:"state"` + } `json:"node"` + } `json:"edges"` + } `json:"decisions"` + } `json:"node"` + } `json:"edges"` + } `json:"approvalQuorums"` + } `json:"node"` + } `json:"edges"` + } `json:"versions"` + } `json:"node"` + } + + err = owner.Execute(` + query($id: ID!) { + node(id: $id) { + ... on Document { + versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) { + edges { + node { + approvalQuorums(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) { + edges { + node { + decisions(first: 100) { + edges { node { state } } + } + } + } + } + } + } + } + } + } + } + `, map[string]any{"id": docID}, &quorumResult) + require.NoError(t, err) + require.NotEmpty(t, quorumResult.Node.Versions.Edges) + require.NotEmpty(t, quorumResult.Node.Versions.Edges[0].Node.ApprovalQuorums.Edges) + decisions := quorumResult.Node.Versions.Edges[0].Node.ApprovalQuorums.Edges[0].Node.Decisions.Edges + require.NotEmpty(t, decisions) + for _, d := range decisions { + assert.Equal(t, "VOIDED", d.Node.State, "decisions should be VOIDED after voiding") + } +} + +func TestDocumentVersion_RejectApproval(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + docID, _ := createTestDocument(t, owner) + approverID := getOwnerProfileID(t, owner) + + // Request approval — version should bump to 1.0 + _, err := owner.Do(` + mutation($input: RequestDocumentVersionApprovalInput!) { + requestDocumentVersionApproval(input: $input) { + approvalQuorum { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "documentId": docID, + "approverIds": []string{approverID}, + "changelog": "Test changelog", + }, + }) + require.NoError(t, err) + + // Get version ID + var versionResult struct { + Node struct { + Versions struct { + Edges []struct { + Node struct { + ID string `json:"id"` + Status string `json:"status"` + Major int `json:"major"` + Minor int `json:"minor"` + } `json:"node"` + } `json:"edges"` + } `json:"versions"` + } `json:"node"` + } + + err = owner.Execute(` + query($id: ID!) { + node(id: $id) { + ... on Document { + versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) { + edges { node { id status major minor } } + } + } + } + } + `, map[string]any{"id": docID}, &versionResult) + require.NoError(t, err) + require.NotEmpty(t, versionResult.Node.Versions.Edges) + assert.Equal(t, "PENDING_APPROVAL", versionResult.Node.Versions.Edges[0].Node.Status) + assert.Equal(t, 1, versionResult.Node.Versions.Edges[0].Node.Major) + assert.Equal(t, 0, versionResult.Node.Versions.Edges[0].Node.Minor) + + versionID := versionResult.Node.Versions.Edges[0].Node.ID + + // Reject approval + _, err = owner.Do(` + mutation($input: RejectDocumentVersionInput!) { + rejectDocumentVersion(input: $input) { + approvalDecision { id state } + } + } + `, map[string]any{ + "input": map[string]any{ + "documentVersionId": versionID, + "comment": "Needs rework", + }, + }) + require.NoError(t, err) + + // Verify version reverted to 0.1 DRAFT + err = owner.Execute(` + query($id: ID!) { + node(id: $id) { + ... on Document { + versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) { + edges { node { id status major minor } } + } + } + } + } + `, map[string]any{"id": docID}, &versionResult) + require.NoError(t, err) + require.NotEmpty(t, versionResult.Node.Versions.Edges) + assert.Equal(t, "DRAFT", versionResult.Node.Versions.Edges[0].Node.Status) + assert.Equal(t, 0, versionResult.Node.Versions.Edges[0].Node.Major) + assert.Equal(t, 1, versionResult.Node.Versions.Edges[0].Node.Minor) + + // Verify decisions are VOIDED after reject + var quorumResult struct { + Node struct { + Versions struct { + Edges []struct { + Node struct { + ApprovalQuorums struct { + Edges []struct { + Node struct { + Decisions struct { + Edges []struct { + Node struct { + State string `json:"state"` + } `json:"node"` + } `json:"edges"` + } `json:"decisions"` + } `json:"node"` + } `json:"edges"` + } `json:"approvalQuorums"` + } `json:"node"` + } `json:"edges"` + } `json:"versions"` + } `json:"node"` + } + + err = owner.Execute(` + query($id: ID!) { + node(id: $id) { + ... on Document { + versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) { + edges { + node { + approvalQuorums(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) { + edges { + node { + decisions(first: 100) { + edges { node { state } } + } + } + } + } + } + } + } + } + } + } + `, map[string]any{"id": docID}, &quorumResult) + require.NoError(t, err) + require.NotEmpty(t, quorumResult.Node.Versions.Edges) + require.NotEmpty(t, quorumResult.Node.Versions.Edges[0].Node.ApprovalQuorums.Edges) + decisions := quorumResult.Node.Versions.Edges[0].Node.ApprovalQuorums.Edges[0].Node.Decisions.Edges + require.Len(t, decisions, 1) + assert.Equal(t, "REJECTED", decisions[0].Node.State, "rejecting approver's decision should be REJECTED") +} + +func TestDocumentVersion_PublishBlockedWhenPendingApproval(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + docID, _ := createTestDocument(t, owner) + approverID := getOwnerProfileID(t, owner) + + // Request approval (puts version in PENDING_APPROVAL) + _, err := owner.Do(` + mutation($input: RequestDocumentVersionApprovalInput!) { + requestDocumentVersionApproval(input: $input) { + approvalQuorum { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "documentId": docID, + "approverIds": []string{approverID}, + "changelog": "Test changelog", + }, + }) + require.NoError(t, err) + + t.Run("publish major blocked", func(t *testing.T) { + t.Parallel() + + _, err := owner.Do(` + mutation($input: PublishMajorDocumentVersionInput!) { + publishMajorDocumentVersion(input: $input) { + documentVersion { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "documentId": docID, + "changelog": "Major release", + }, + }) + require.Error(t, err) + }) + + t.Run("publish minor blocked", func(t *testing.T) { + t.Parallel() + + _, err := owner.Do(` + mutation($input: PublishMinorDocumentVersionInput!) { + publishMinorDocumentVersion(input: $input) { + documentVersion { id } + } + } + `, map[string]any{ + "input": map[string]any{ + "documentId": docID, + "changelog": "Minor release", + }, + }) + require.Error(t, err) + }) +} + +func TestDocument_DefaultApprovers(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + approverID := getOwnerProfileID(t, owner) + + t.Run("create document with default approvers", func(t *testing.T) { + t.Parallel() + + var result struct { + CreateDocument struct { + DocumentEdge struct { + Node struct { + ID string `json:"id"` + DefaultApprovers []struct { + ID string `json:"id"` + } `json:"defaultApprovers"` + } `json:"node"` + } `json:"documentEdge"` + } `json:"createDocument"` + } + + err := owner.Execute(` + mutation($input: CreateDocumentInput!) { + createDocument(input: $input) { + documentEdge { + node { + id + defaultApprovers { id } + } + } + } + } + `, map[string]any{ + "input": map[string]any{ + "organizationId": owner.GetOrganizationID().String(), + "title": "Doc With Approvers", + "content": testutil.ProseMirrorTextDoc("Content"), + "documentType": "POLICY", + "classification": "INTERNAL", + "defaultApproverIds": []string{approverID}, + }, + }, &result) + require.NoError(t, err) + + assert.Len(t, result.CreateDocument.DocumentEdge.Node.DefaultApprovers, 1) + assert.Equal(t, approverID, result.CreateDocument.DocumentEdge.Node.DefaultApprovers[0].ID) + }) + + t.Run("update document with default approvers", func(t *testing.T) { + t.Parallel() + + docID, _ := createTestDocument(t, owner) + + var result struct { + UpdateDocument struct { + Document struct { + ID string `json:"id"` + DefaultApprovers []struct { + ID string `json:"id"` + } `json:"defaultApprovers"` + } `json:"document"` + } `json:"updateDocument"` + } + + err := owner.Execute(` + mutation($input: UpdateDocumentInput!) { + updateDocument(input: $input) { + document { + id + defaultApprovers { id } + } + } + } + `, map[string]any{ + "input": map[string]any{ + "id": docID, + "defaultApproverIds": []string{approverID}, + }, + }, &result) + require.NoError(t, err) + + assert.Len(t, result.UpdateDocument.Document.DefaultApprovers, 1) + assert.Equal(t, approverID, result.UpdateDocument.Document.DefaultApprovers[0].ID) + + // Clear approvers + err = owner.Execute(` + mutation($input: UpdateDocumentInput!) { + updateDocument(input: $input) { + document { + id + defaultApprovers { id } + } + } + } + `, map[string]any{ + "input": map[string]any{ + "id": docID, + "defaultApproverIds": []string{}, + }, + }, &result) + require.NoError(t, err) + + assert.Empty(t, result.UpdateDocument.Document.DefaultApprovers) + }) +} diff --git a/packages/ui/src/Molecules/Badge/DocumentVersionBadge.tsx b/packages/ui/src/Molecules/Badge/DocumentVersionBadge.tsx index 719c73a3a..1e6203ad7 100644 --- a/packages/ui/src/Molecules/Badge/DocumentVersionBadge.tsx +++ b/packages/ui/src/Molecules/Badge/DocumentVersionBadge.tsx @@ -17,14 +17,23 @@ import { useTranslate } from "@probo/i18n"; import { Badge } from "../../Atoms/Badge/Badge"; type Props = { - state: "DRAFT" | "PUBLISHED"; + state: "DRAFT" | "PENDING_APPROVAL" | "PUBLISHED"; }; export function DocumentVersionBadge(props: Props) { const { __ } = useTranslate(); - return ( - - {props.state === "DRAFT" ? __("Draft") : __("Published")} - - ); + + const variant = { + DRAFT: "neutral", + PENDING_APPROVAL: "warning", + PUBLISHED: "success", + } as const; + + const label = { + DRAFT: __("Draft"), + PENDING_APPROVAL: __("Pending approval"), + PUBLISHED: __("Published"), + } as const; + + return {label[props.state]}; } diff --git a/pkg/coredata/document.go b/pkg/coredata/document.go index c4c111577..292890720 100644 --- a/pkg/coredata/document.go +++ b/pkg/coredata/document.go @@ -66,45 +66,14 @@ 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.Querier) (map[string]string, error) { q := ` -WITH document AS ( - SELECT id, organization_id, status - FROM documents - WHERE id = $1 - LIMIT 1 -), -latest_version AS ( - SELECT dv.id, dv.document_id, dv.status AS version_status - FROM document_versions dv - INNER JOIN document ON dv.document_id = document.id - ORDER BY dv.created_at DESC - LIMIT 1 -), -last_quorum AS ( - SELECT - lv.document_id, - q.status::text AS status - FROM document_version_approval_quorums q - INNER JOIN latest_version lv ON lv.id = q.version_id - ORDER BY q.created_at DESC - LIMIT 1 -) -SELECT - document.organization_id, - document.status, - COALESCE(lv.version_status::text, ''), - COALESCE(lq.status, '') -FROM document -LEFT JOIN latest_version lv ON lv.document_id = document.id -LEFT JOIN last_quorum lq ON lq.document_id = document.id; +SELECT organization_id +FROM documents +WHERE id = $1 +LIMIT 1; ` - var ( - organizationID gid.GID - documentStatus DocumentStatus - latestVersionStatus string - lastQuorumStatus string - ) - if err := conn.QueryRow(ctx, q, d.ID).Scan(&organizationID, &documentStatus, &latestVersionStatus, &lastQuorumStatus); err != nil { + var organizationID gid.GID + if err := conn.QueryRow(ctx, q, d.ID).Scan(&organizationID); err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrResourceNotFound } @@ -112,10 +81,7 @@ LEFT JOIN last_quorum lq ON lq.document_id = document.id; } return map[string]string{ - "organization_id": organizationID.String(), - "document_status": documentStatus.String(), - "version_status": latestVersionStatus, - "last_quorum_status": lastQuorumStatus, + "organization_id": organizationID.String(), }, nil } @@ -1117,7 +1083,7 @@ LIMIT 1 state, err := pgx.CollectOneRow(rows, pgx.RowTo[DocumentVersionApprovalDecisionState]) if err != nil { if errors.Is(err, pgx.ErrNoRows) { - return DocumentVersionApprovalDecisionStatePending, nil + return "", nil } return "", fmt.Errorf("cannot collect approval state: %w", err) } diff --git a/pkg/coredata/document_default_approver.go b/pkg/coredata/document_default_approver.go new file mode 100644 index 000000000..1a7d8d54d --- /dev/null +++ b/pkg/coredata/document_default_approver.go @@ -0,0 +1,143 @@ +// Copyright (c) 2025-2026 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 ( + "context" + "fmt" + "maps" + "time" + + "github.com/jackc/pgx/v5" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/gid" +) + +type ( + DocumentDefaultApprover struct { + DocumentID gid.GID `db:"document_id"` + ApproverProfileID gid.GID `db:"approver_profile_id"` + OrganizationID gid.GID `db:"organization_id"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + } + + DocumentDefaultApprovers []*DocumentDefaultApprover +) + +// LoadByDocumentID loads all default approvers for a document. +func (das *DocumentDefaultApprovers) LoadByDocumentID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + documentID gid.GID, +) error { + q := ` +SELECT + document_id, + approver_profile_id, + organization_id, + created_at, + updated_at +FROM document_default_approvers +WHERE + %s + AND document_id = @document_id +ORDER BY created_at ASC; +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"document_id": documentID} + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query document default approvers: %w", err) + } + + result, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[DocumentDefaultApprover]) + if err != nil { + return fmt.Errorf("cannot collect document default approvers: %w", err) + } + + *das = result + return nil +} + +// MergeByDocumentID merges the given approver profile IDs for a document, +// inserting new ones, keeping existing ones, and deleting removed ones. +func (das *DocumentDefaultApprovers) MergeByDocumentID( + ctx context.Context, + conn pg.Tx, + scope Scoper, + documentID gid.GID, + organizationID gid.GID, + approverProfileIDs []gid.GID, +) error { + q := ` +MERGE INTO document_default_approvers AS target +USING ( + SELECT unnest(@approver_profile_ids::text[]) AS approver_profile_id +) AS source +ON + %s + AND target.document_id = @document_id + AND target.approver_profile_id = source.approver_profile_id +WHEN NOT MATCHED THEN + INSERT (document_id, approver_profile_id, tenant_id, organization_id, created_at, updated_at) + VALUES (@document_id, source.approver_profile_id, @tenant_id, @organization_id, @now, @now) +WHEN NOT MATCHED BY SOURCE + AND %s + AND target.document_id = @document_id THEN + DELETE; +` + + q = fmt.Sprintf(q, scope.SQLFragment(), scope.SQLFragment()) + + now := time.Now() + + ids := make([]string, len(approverProfileIDs)) + for i, id := range approverProfileIDs { + ids[i] = id.String() + } + + args := pgx.StrictNamedArgs{ + "document_id": documentID, + "approver_profile_ids": ids, + "tenant_id": scope.GetTenantID(), + "organization_id": organizationID, + "now": now, + } + maps.Copy(args, scope.SQLArguments()) + + if _, err := conn.Exec(ctx, q, args); err != nil { + return fmt.Errorf("cannot merge document default approvers: %w", err) + } + + result := make(DocumentDefaultApprovers, 0, len(approverProfileIDs)) + for _, profileID := range approverProfileIDs { + result = append(result, &DocumentDefaultApprover{ + DocumentID: documentID, + ApproverProfileID: profileID, + OrganizationID: organizationID, + CreatedAt: now, + UpdatedAt: now, + }) + } + + *das = result + return nil +} diff --git a/pkg/coredata/document_version.go b/pkg/coredata/document_version.go index 4d896d9e2..f2fff6992 100644 --- a/pkg/coredata/document_version.go +++ b/pkg/coredata/document_version.go @@ -52,44 +52,15 @@ type ( // AuthorizationAttributes returns the authorization attributes for policy evaluation. func (dv *DocumentVersion) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { q := ` -WITH document_version AS ( - SELECT id, document_id, organization_id, status AS version_status - FROM document_versions - WHERE id = $1 - LIMIT 1 -), -document AS ( - SELECT d.id, d.status - FROM documents d - INNER JOIN document_version ON d.id = document_version.document_id -), -last_quorum AS ( - SELECT - q.version_id, - q.status::text AS status - FROM document_version_approval_quorums q - INNER JOIN document_version ON q.version_id = document_version.id - ORDER BY q.created_at DESC - LIMIT 1 -) -SELECT - document_version.organization_id, - document.status, - document_version.version_status, - COALESCE(lq.status, '') -FROM document_version -INNER JOIN document ON document.id = document_version.document_id -LEFT JOIN last_quorum lq ON lq.version_id = document_version.id; +SELECT organization_id +FROM document_versions +WHERE id = $1 +LIMIT 1; ` - var ( - organizationID gid.GID - documentStatus DocumentStatus - documentVersionStatus DocumentVersionStatus - lastQuorumStatus string - ) + var organizationID gid.GID - if err := conn.QueryRow(ctx, q, dv.ID).Scan(&organizationID, &documentStatus, &documentVersionStatus, &lastQuorumStatus); err != nil { + if err := conn.QueryRow(ctx, q, dv.ID).Scan(&organizationID); err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrResourceNotFound } @@ -97,10 +68,7 @@ LEFT JOIN last_quorum lq ON lq.version_id = document_version.id; } return map[string]string{ - "organization_id": organizationID.String(), - "document_status": documentStatus.String(), - "version_status": documentVersionStatus.String(), - "last_quorum_status": lastQuorumStatus, + "organization_id": organizationID.String(), }, nil } @@ -213,6 +181,9 @@ LIMIT 1; documentVersion, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[DocumentVersion]) if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } return fmt.Errorf("cannot collect document version: %w", err) } @@ -343,6 +314,9 @@ LIMIT 1; documentVersion, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[DocumentVersion]) if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } return fmt.Errorf("cannot collect document version: %w", err) } @@ -395,6 +369,9 @@ LIMIT 1; documentVersion, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[DocumentVersion]) if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } return fmt.Errorf("cannot collect document version: %w", err) } @@ -449,6 +426,9 @@ LIMIT 1; documentVersion, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[DocumentVersion]) if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } return fmt.Errorf("cannot collect document version: %w", err) } diff --git a/pkg/coredata/document_version_approval_decision.go b/pkg/coredata/document_version_approval_decision.go index 908113b86..96674bdd4 100644 --- a/pkg/coredata/document_version_approval_decision.go +++ b/pkg/coredata/document_version_approval_decision.go @@ -424,6 +424,40 @@ WHERE return nil } +func (d *DocumentVersionApprovalDecisions) VoidPendingByQuorumID( + ctx context.Context, + conn pg.Tx, + scope Scoper, + quorumID gid.GID, + now time.Time, +) error { + q := ` +UPDATE document_version_approval_decisions +SET + state = 'VOIDED', + updated_at = @updated_at +WHERE + %s + AND quorum_id = @quorum_id + AND state = 'PENDING' +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "quorum_id": quorumID, + "updated_at": now, + } + maps.Copy(args, scope.SQLArguments()) + + _, err := conn.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot void pending approval decisions: %w", err) + } + + return nil +} + func (d *DocumentVersionApprovalDecisions) CountByQuorumID( ctx context.Context, conn pg.Querier, diff --git a/pkg/coredata/document_version_approval_decision_state.go b/pkg/coredata/document_version_approval_decision_state.go index 2c3d14400..7a35475be 100644 --- a/pkg/coredata/document_version_approval_decision_state.go +++ b/pkg/coredata/document_version_approval_decision_state.go @@ -29,6 +29,7 @@ const ( DocumentVersionApprovalDecisionStatePending DocumentVersionApprovalDecisionState = "PENDING" DocumentVersionApprovalDecisionStateApproved DocumentVersionApprovalDecisionState = "APPROVED" DocumentVersionApprovalDecisionStateRejected DocumentVersionApprovalDecisionState = "REJECTED" + DocumentVersionApprovalDecisionStateVoided DocumentVersionApprovalDecisionState = "VOIDED" ) func (s DocumentVersionApprovalDecisionState) MarshalText() ([]byte, error) { @@ -45,6 +46,8 @@ func (s *DocumentVersionApprovalDecisionState) UnmarshalText(data []byte) error *s = DocumentVersionApprovalDecisionStateApproved case DocumentVersionApprovalDecisionStateRejected.String(): *s = DocumentVersionApprovalDecisionStateRejected + case DocumentVersionApprovalDecisionStateVoided.String(): + *s = DocumentVersionApprovalDecisionStateVoided default: return fmt.Errorf("invalid DocumentVersionApprovalDecisionState value: %q", val) } @@ -62,6 +65,8 @@ func (s DocumentVersionApprovalDecisionState) String() string { val = "APPROVED" case DocumentVersionApprovalDecisionStateRejected: val = "REJECTED" + case DocumentVersionApprovalDecisionStateVoided: + val = "VOIDED" default: panic(fmt.Errorf("invalid DocumentVersionApprovalDecisionState value: %q", string(s))) } diff --git a/pkg/coredata/document_version_approval_quorum_status.go b/pkg/coredata/document_version_approval_quorum_status.go index f63546420..6309953fb 100644 --- a/pkg/coredata/document_version_approval_quorum_status.go +++ b/pkg/coredata/document_version_approval_quorum_status.go @@ -25,6 +25,7 @@ const ( DocumentVersionApprovalQuorumStatusPending DocumentVersionApprovalQuorumStatus = "PENDING" DocumentVersionApprovalQuorumStatusApproved DocumentVersionApprovalQuorumStatus = "APPROVED" DocumentVersionApprovalQuorumStatusRejected DocumentVersionApprovalQuorumStatus = "REJECTED" + DocumentVersionApprovalQuorumStatusVoided DocumentVersionApprovalQuorumStatus = "VOIDED" ) func (s DocumentVersionApprovalQuorumStatus) MarshalText() ([]byte, error) { @@ -41,6 +42,8 @@ func (s *DocumentVersionApprovalQuorumStatus) UnmarshalText(data []byte) error { *s = DocumentVersionApprovalQuorumStatusApproved case DocumentVersionApprovalQuorumStatusRejected.String(): *s = DocumentVersionApprovalQuorumStatusRejected + case DocumentVersionApprovalQuorumStatusVoided.String(): + *s = DocumentVersionApprovalQuorumStatusVoided default: return fmt.Errorf("invalid DocumentVersionApprovalQuorumStatus value: %q", val) } @@ -58,6 +61,8 @@ func (s DocumentVersionApprovalQuorumStatus) String() string { val = "APPROVED" case DocumentVersionApprovalQuorumStatusRejected: val = "REJECTED" + case DocumentVersionApprovalQuorumStatusVoided: + val = "VOIDED" default: panic(fmt.Errorf("invalid DocumentVersionApprovalQuorumStatus value: %q", string(s))) } diff --git a/pkg/coredata/document_version_status.go b/pkg/coredata/document_version_status.go index 88e811df8..9c71517d1 100644 --- a/pkg/coredata/document_version_status.go +++ b/pkg/coredata/document_version_status.go @@ -20,12 +20,13 @@ import ( ) type ( - DocumentVersionStatus uint8 + DocumentVersionStatus string ) const ( - DocumentVersionStatusDraft DocumentVersionStatus = iota - DocumentVersionStatusPublished + DocumentVersionStatusDraft DocumentVersionStatus = "DRAFT" + DocumentVersionStatusPendingApproval DocumentVersionStatus = "PENDING_APPROVAL" + DocumentVersionStatusPublished DocumentVersionStatus = "PUBLISHED" ) func (ps DocumentVersionStatus) MarshalText() ([]byte, error) { @@ -38,6 +39,8 @@ func (ps *DocumentVersionStatus) UnmarshalText(data []byte) error { switch val { case DocumentVersionStatusDraft.String(): *ps = DocumentVersionStatusDraft + case DocumentVersionStatusPendingApproval.String(): + *ps = DocumentVersionStatusPendingApproval case DocumentVersionStatusPublished.String(): *ps = DocumentVersionStatusPublished default: @@ -48,16 +51,16 @@ func (ps *DocumentVersionStatus) UnmarshalText(data []byte) error { } func (ps DocumentVersionStatus) String() string { - var val string - switch ps { case DocumentVersionStatusDraft: - val = "DRAFT" + return "DRAFT" + case DocumentVersionStatusPendingApproval: + return "PENDING_APPROVAL" case DocumentVersionStatusPublished: - val = "PUBLISHED" + return "PUBLISHED" + default: + panic(fmt.Errorf("invalid DocumentVersionStatus value: %q", string(ps))) } - - return val } func (ps *DocumentVersionStatus) Scan(value any) error { diff --git a/pkg/coredata/migrations/20260408T120000Z.sql b/pkg/coredata/migrations/20260408T120000Z.sql new file mode 100644 index 000000000..ccb6ad6f8 --- /dev/null +++ b/pkg/coredata/migrations/20260408T120000Z.sql @@ -0,0 +1,17 @@ +-- Copyright (c) 2025-2026 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. + +ALTER TYPE document_version_status ADD VALUE 'PENDING_APPROVAL' BEFORE 'PUBLISHED'; +ALTER TYPE document_version_approval_quorum_status ADD VALUE 'VOIDED'; +ALTER TYPE document_version_approval_decision_state ADD VALUE 'VOIDED'; diff --git a/pkg/coredata/migrations/20260408T120004Z.sql b/pkg/coredata/migrations/20260408T120004Z.sql new file mode 100644 index 000000000..97fd2ca45 --- /dev/null +++ b/pkg/coredata/migrations/20260408T120004Z.sql @@ -0,0 +1,35 @@ +-- Copyright (c) 2025-2026 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. + +-- Backfill: set every draft version that has a pending approval quorum to PENDING_APPROVAL +UPDATE document_versions dv +SET status = 'PENDING_APPROVAL' +WHERE dv.status = 'DRAFT' + AND EXISTS ( + SELECT 1 + FROM document_version_approval_quorums q + WHERE q.version_id = dv.id + AND q.status = 'PENDING' + ); + +-- Backfill: void pending decisions in rejected or voided quorums +UPDATE document_version_approval_decisions d +SET state = 'VOIDED' +WHERE d.state = 'PENDING' + AND EXISTS ( + SELECT 1 + FROM document_version_approval_quorums q + WHERE q.id = d.quorum_id + AND q.status IN ('REJECTED', 'VOIDED') + ); diff --git a/pkg/coredata/migrations/20260408T130000Z.sql b/pkg/coredata/migrations/20260408T130000Z.sql new file mode 100644 index 000000000..641b8e26a --- /dev/null +++ b/pkg/coredata/migrations/20260408T130000Z.sql @@ -0,0 +1,47 @@ +-- Copyright (c) 2025-2026 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. + +CREATE TABLE document_default_approvers ( + document_id text NOT NULL, + approver_profile_id text NOT NULL, + tenant_id text NOT NULL, + organization_id text NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + PRIMARY KEY (document_id, approver_profile_id), + FOREIGN KEY (document_id) REFERENCES documents(id) ON UPDATE CASCADE ON DELETE CASCADE, + FOREIGN KEY (approver_profile_id) REFERENCES iam_membership_profiles(id) ON UPDATE CASCADE ON DELETE CASCADE +); + +-- Backfill default approvers from the last quorum of the last version of each document. +INSERT INTO document_default_approvers (document_id, approver_profile_id, tenant_id, organization_id, created_at, updated_at) +SELECT DISTINCT + dv.document_id, + d.approver_id, + d.tenant_id, + d.organization_id, + d.created_at, + d.created_at +FROM document_version_approval_decisions d +JOIN document_version_approval_quorums q ON q.id = d.quorum_id +JOIN document_versions dv ON dv.id = q.version_id +WHERE q.id = ( + SELECT q2.id + FROM document_version_approval_quorums q2 + JOIN document_versions dv2 ON dv2.id = q2.version_id + WHERE dv2.document_id = dv.document_id + ORDER BY dv2.created_at DESC, q2.created_at DESC + LIMIT 1 +) +ON CONFLICT (document_id, approver_profile_id) DO NOTHING; diff --git a/pkg/probo/actions.go b/pkg/probo/actions.go index 9b9c357f0..5d9ff5f64 100644 --- a/pkg/probo/actions.go +++ b/pkg/probo/actions.go @@ -197,11 +197,10 @@ const ( ActionDocumentVersionUpdate = "core:document-version:update" ActionDocumentVersionDeleteDraft = "core:document-version:delete-draft" ActionDocumentVersionRequestApproval = "core:document-version:request-approval" + ActionDocumentVersionVoidApproval = "core:document-version:void-approval" ActionDocumentVersionApprove = "core:document-version:approve" ActionDocumentVersionReject = "core:document-version:reject" ActionDocumentVersionApprovalList = "core:document-version:approval-list" - ActionDocumentVersionAddApprover = "core:document-version:add-approver" - ActionDocumentVersionRemoveApprover = "core:document-version:remove-approver" ActionDocumentVersionPublish = "core:document-version:publish" ActionDocumentVersionExport = "core:document-version:export" diff --git a/pkg/probo/document_approval_service.go b/pkg/probo/document_approval_service.go index a5558da33..721aaca0b 100644 --- a/pkg/probo/document_approval_service.go +++ b/pkg/probo/document_approval_service.go @@ -19,9 +19,8 @@ import ( "context" "errors" "fmt" - "time" - "net/url" + "time" "go.gearno.de/crypto/uuid" "go.gearno.de/kit/pg" @@ -84,10 +83,10 @@ func (req *RequestApprovalRequest) Validate() error { v := validator.New() v.Check(req.DocumentID, "document_id", validator.Required(), validator.GID(coredata.DocumentEntityType)) - v.Check(req.ApproverIDs, "approver_ids", validator.Required()) - v.Check(len(req.ApproverIDs), "approver_ids", validator.Max(100)) - v.CheckEach(req.ApproverIDs, "approver_ids", func(_ int, item any) { - v.Check(item, "approver_ids", validator.GID(coredata.MembershipProfileEntityType)) + v.Check(len(req.ApproverIDs), "approver_ids", validator.Min(1), validator.Max(100)) + v.Check(req.ApproverIDs, "approver_ids", validator.NoDuplicates()) + v.CheckEach(req.ApproverIDs, "approver_ids", func(index int, item any) { + v.Check(item, fmt.Sprintf("approver_ids[%d]", index), validator.GID(coredata.MembershipProfileEntityType)) }) v.Check(req.Changelog, "changelog", validator.Required(), validator.SafeText(5000)) @@ -121,53 +120,19 @@ func (s *DocumentApprovalService) RequestApproval( return fmt.Errorf("cannot load latest version: %w", err) } - if documentVersion.Status == coredata.DocumentVersionStatusPublished { - return fmt.Errorf("cannot request approval for a published document") + if documentVersion.Status != coredata.DocumentVersionStatusDraft { + return &ErrDocumentVersionNotDraft{} } - if err := s.rejectPendingQuorum(ctx, tx, documentVersion.ID); err != nil { - return fmt.Errorf("cannot reject pending quorum: %w", err) + q, err := s.requestApprovalInTx(ctx, tx, document, documentVersion, req.ApproverIDs, req.Changelog) + if err != nil { + return err } + quorum = q - organization := &coredata.Organization{} - if err := organization.LoadByID(ctx, tx, s.svc.scope, document.OrganizationID); err != nil { - return fmt.Errorf("cannot load organization: %w", err) - } - - approverProfiles := &coredata.MembershipProfiles{} - if err := approverProfiles.LoadByIDs(ctx, tx, s.svc.scope, req.ApproverIDs); err != nil { - return fmt.Errorf("cannot load approver profiles: %w", err) - } - - now := time.Now() - - if req.Changelog != nil { - documentVersion.Changelog = *req.Changelog - documentVersion.UpdatedAt = now - if err := documentVersion.Update(ctx, tx, s.svc.scope); err != nil { - return fmt.Errorf("cannot update document version changelog: %w", err) - } - } - - quorum = &coredata.DocumentVersionApprovalQuorum{ - ID: gid.New(s.svc.scope.GetTenantID(), coredata.DocumentVersionApprovalQuorumEntityType), - OrganizationID: document.OrganizationID, - VersionID: documentVersion.ID, - Status: coredata.DocumentVersionApprovalQuorumStatusPending, - CreatedAt: now, - UpdatedAt: now, - } - - if err := quorum.Insert(ctx, tx, s.svc.scope); err != nil { - return fmt.Errorf("cannot insert approval quorum: %w", err) - } - - if err := s.createDecisions(ctx, tx, quorum, document.OrganizationID, req.ApproverIDs, now); err != nil { - return fmt.Errorf("cannot create approval decisions: %w", err) - } - - if err := s.sendApprovalEmails(ctx, tx, *approverProfiles, document, organization, documentVersion.ID); err != nil { - return fmt.Errorf("cannot send approval emails: %w", err) + defaultApprovers := &coredata.DocumentDefaultApprovers{} + if err := defaultApprovers.MergeByDocumentID(ctx, tx, s.svc.scope, req.DocumentID, document.OrganizationID, req.ApproverIDs); err != nil { + return fmt.Errorf("cannot update default approvers: %w", err) } return nil @@ -181,6 +146,138 @@ func (s *DocumentApprovalService) RequestApproval( return quorum, nil } +func (s *DocumentApprovalService) requestApprovalInTx( + ctx context.Context, + tx pg.Tx, + document *coredata.Document, + documentVersion *coredata.DocumentVersion, + approverIDs []gid.GID, + changelog *string, +) (*coredata.DocumentVersionApprovalQuorum, error) { + organization := &coredata.Organization{} + if err := organization.LoadByID(ctx, tx, s.svc.scope, document.OrganizationID); err != nil { + return nil, fmt.Errorf("cannot load organization: %w", err) + } + + approverProfiles := &coredata.MembershipProfiles{} + if err := approverProfiles.LoadByIDs(ctx, tx, s.svc.scope, approverIDs); err != nil { + return nil, fmt.Errorf("cannot load approver profiles: %w", err) + } + + now := time.Now() + + documentVersion.Status = coredata.DocumentVersionStatusPendingApproval + if changelog != nil { + documentVersion.Changelog = *changelog + } + + if document.CurrentPublishedMajor != nil { + documentVersion.Major = *document.CurrentPublishedMajor + 1 + } else { + documentVersion.Major = 1 + } + documentVersion.Minor = 0 + + documentVersion.UpdatedAt = now + if err := documentVersion.Update(ctx, tx, s.svc.scope); err != nil { + return nil, fmt.Errorf("cannot update document version: %w", err) + } + + quorum := &coredata.DocumentVersionApprovalQuorum{ + ID: gid.New(s.svc.scope.GetTenantID(), coredata.DocumentVersionApprovalQuorumEntityType), + OrganizationID: document.OrganizationID, + VersionID: documentVersion.ID, + Status: coredata.DocumentVersionApprovalQuorumStatusPending, + CreatedAt: now, + UpdatedAt: now, + } + + if err := quorum.Insert(ctx, tx, s.svc.scope); err != nil { + return nil, fmt.Errorf("cannot insert approval quorum: %w", err) + } + + if err := s.createDecisions(ctx, tx, quorum, document.OrganizationID, approverIDs, now); err != nil { + return nil, fmt.Errorf("cannot create approval decisions: %w", err) + } + + if err := s.sendApprovalEmails(ctx, tx, *approverProfiles, document, organization, documentVersion.ID); err != nil { + return nil, fmt.Errorf("cannot send approval emails: %w", err) + } + + return quorum, nil +} + +func (s *DocumentApprovalService) BulkPublishMajorVersions( + ctx context.Context, + req BulkPublishVersionsRequest, +) ([]*coredata.DocumentVersion, []*coredata.Document, error) { + var publishedVersions []*coredata.DocumentVersion + var updatedDocuments []*coredata.Document + + err := s.svc.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + for _, documentID := range req.DocumentIDs { + dv := &coredata.DocumentVersion{} + if err := dv.LoadLatestVersion(ctx, tx, s.svc.scope, documentID); err != nil { + return fmt.Errorf("cannot load latest version for %q: %w", documentID, err) + } + + // Skip documents already pending approval. + if dv.Status == coredata.DocumentVersionStatusPendingApproval { + continue + } + + document := &coredata.Document{} + 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{} + } + + defaultApprovers := &coredata.DocumentDefaultApprovers{} + if err := defaultApprovers.LoadByDocumentID(ctx, tx, s.svc.scope, documentID); err != nil { + return fmt.Errorf("cannot load default approvers for %q: %w", documentID, err) + } + + if dv.Status != coredata.DocumentVersionStatusDraft { + continue + } + + if len(*defaultApprovers) > 0 { + approverIDs := make([]gid.GID, len(*defaultApprovers)) + for i, a := range *defaultApprovers { + approverIDs[i] = a.ApproverProfileID + } + + if _, err := s.requestApprovalInTx(ctx, tx, document, dv, approverIDs, &req.Changelog); err != nil { + return fmt.Errorf("cannot request approval for %q: %w", documentID, err) + } + } else { + var err error + document, dv, err = s.svc.Documents.publishMajorVersionInTx(ctx, tx, documentID, &req.Changelog, true) + if err != nil { + return fmt.Errorf("cannot publish document %q: %w", documentID, err) + } + } + + publishedVersions = append(publishedVersions, dv) + updatedDocuments = append(updatedDocuments, document) + } + + return nil + }, + ) + + if err != nil { + return nil, nil, err + } + + return publishedVersions, updatedDocuments, nil +} + func (s *DocumentApprovalService) Approve( ctx context.Context, req ApproveDocumentVersionRequest, @@ -205,6 +302,10 @@ func (s *DocumentApprovalService) Approve( return fmt.Errorf("cannot load document: %w", err) } + if document.ArchivedAt != nil { + return &ErrDocumentArchived{} + } + var profile *coredata.MembershipProfile var err error quorum, profile, err = s.loadQuorumAndProfile(ctx, conn, req.DocumentVersionID, req.IdentityID, documentVersion.OrganizationID) @@ -269,9 +370,20 @@ func (s *DocumentApprovalService) Approve( approverID := decision.ApproverID + quorumID := quorum.ID + err = s.svc.pg.WithTx( ctx, func(ctx context.Context, tx pg.Tx) error { + quorum = &coredata.DocumentVersionApprovalQuorum{} + if err := quorum.LoadByID(ctx, tx, s.svc.scope, quorumID); err != nil { + return fmt.Errorf("cannot load quorum: %w", err) + } + + if quorum.Status != coredata.DocumentVersionApprovalQuorumStatusPending { + return &ErrDocumentVersionNotPendingApproval{} + } + decision = &coredata.DocumentVersionApprovalDecision{} if err := decision.LoadByQuorumIDAndApproverID(ctx, tx, s.svc.scope, quorum.ID, approverID); err != nil { return fmt.Errorf("cannot load approval decision: %w", err) @@ -343,6 +455,15 @@ func (s *DocumentApprovalService) Reject( return fmt.Errorf("cannot load document version: %w", err) } + document := &coredata.Document{} + if err := document.LoadByID(ctx, tx, s.svc.scope, documentVersion.DocumentID); err != nil { + return fmt.Errorf("cannot load document: %w", err) + } + + if document.ArchivedAt != nil { + return &ErrDocumentArchived{} + } + quorum, profile, err := s.loadQuorumAndProfile(ctx, tx, req.DocumentVersionID, req.IdentityID, documentVersion.OrganizationID) if err != nil { return fmt.Errorf("cannot load quorum and profile: %w", err) @@ -375,6 +496,25 @@ func (s *DocumentApprovalService) Reject( return fmt.Errorf("cannot update approval quorum: %w", err) } + decisions := &coredata.DocumentVersionApprovalDecisions{} + if err := decisions.VoidPendingByQuorumID(ctx, tx, s.svc.scope, quorum.ID, now); err != nil { + return fmt.Errorf("cannot void pending decisions: %w", err) + } + + documentVersion.Status = coredata.DocumentVersionStatusDraft + if document.CurrentPublishedMajor != nil { + documentVersion.Major = *document.CurrentPublishedMajor + documentVersion.Minor = *document.CurrentPublishedMinor + 1 + } else { + documentVersion.Major = 0 + documentVersion.Minor = 1 + } + documentVersion.UpdatedAt = now + + if err := documentVersion.Update(ctx, tx, s.svc.scope); err != nil { + return fmt.Errorf("cannot update document version status: %w", err) + } + return nil }, ) @@ -386,70 +526,71 @@ func (s *DocumentApprovalService) Reject( return decision, nil } -func (s *DocumentApprovalService) AddApprover( +func (s *DocumentApprovalService) VoidApproval( ctx context.Context, documentVersionID gid.GID, - approverID gid.GID, -) (*coredata.DocumentVersionApprovalDecision, error) { - var decision *coredata.DocumentVersionApprovalDecision +) (*coredata.DocumentVersionApprovalQuorum, *coredata.DocumentVersion, error) { + var ( + quorum *coredata.DocumentVersionApprovalQuorum + documentVersion *coredata.DocumentVersion + ) err := s.svc.pg.WithTx( ctx, func(ctx context.Context, tx pg.Tx) error { - documentVersion := &coredata.DocumentVersion{} + documentVersion = &coredata.DocumentVersion{} if err := documentVersion.LoadByID(ctx, tx, s.svc.scope, documentVersionID); err != nil { return fmt.Errorf("cannot load document version: %w", err) } - quorum := &coredata.DocumentVersionApprovalQuorum{} - if err := quorum.LoadLastByDocumentVersionID(ctx, tx, s.svc.scope, documentVersionID); err != nil { + document := &coredata.Document{} + if err := document.LoadByID(ctx, tx, s.svc.scope, documentVersion.DocumentID); err != nil { + return fmt.Errorf("cannot load document: %w", err) + } + + if document.ArchivedAt != nil { + return &ErrDocumentArchived{} + } + + if documentVersion.Status != coredata.DocumentVersionStatusPendingApproval { return &ErrDocumentVersionNotPendingApproval{} } + quorum = &coredata.DocumentVersionApprovalQuorum{} + if err := quorum.LoadLastByDocumentVersionID(ctx, tx, s.svc.scope, documentVersionID); err != nil { + return fmt.Errorf("cannot load approval quorum: %w", err) + } + if quorum.Status != coredata.DocumentVersionApprovalQuorumStatusPending { return &ErrDocumentVersionNotPendingApproval{} } now := time.Now() - decision = &coredata.DocumentVersionApprovalDecision{ - ID: gid.New(s.svc.scope.GetTenantID(), coredata.DocumentVersionApprovalDecisionEntityType), - OrganizationID: documentVersion.OrganizationID, - QuorumID: quorum.ID, - ApproverID: approverID, - State: coredata.DocumentVersionApprovalDecisionStatePending, - CreatedAt: now, - UpdatedAt: now, + quorum.Status = coredata.DocumentVersionApprovalQuorumStatusVoided + quorum.UpdatedAt = now + + if err := quorum.Update(ctx, tx, s.svc.scope); err != nil { + return fmt.Errorf("cannot update approval quorum: %w", err) } - if err := decision.Insert(ctx, tx, s.svc.scope); err != nil { - return fmt.Errorf("cannot insert approval decision: %w", err) + decisions := &coredata.DocumentVersionApprovalDecisions{} + if err := decisions.VoidPendingByQuorumID(ctx, tx, s.svc.scope, quorum.ID, now); err != nil { + return fmt.Errorf("cannot void pending decisions: %w", err) } - document := &coredata.Document{} - if err := document.LoadByID(ctx, tx, s.svc.scope, documentVersion.DocumentID); err != nil { - return fmt.Errorf("cannot load document: %w", err) + documentVersion.Status = coredata.DocumentVersionStatusDraft + if document.CurrentPublishedMajor != nil { + documentVersion.Major = *document.CurrentPublishedMajor + documentVersion.Minor = *document.CurrentPublishedMinor + 1 + } else { + documentVersion.Major = 0 + documentVersion.Minor = 1 } + documentVersion.UpdatedAt = now - organization := &coredata.Organization{} - if err := organization.LoadByID(ctx, tx, s.svc.scope, document.OrganizationID); err != nil { - return fmt.Errorf("cannot load organization: %w", err) - } - - profile := &coredata.MembershipProfile{} - if err := profile.LoadByID(ctx, tx, s.svc.scope, approverID); err != nil { - return fmt.Errorf("cannot load approver profile: %w", err) - } - - if err := s.sendApprovalEmails( - ctx, - tx, - coredata.MembershipProfiles{profile}, - document, - organization, - documentVersionID, - ); err != nil { - return fmt.Errorf("cannot send approval email: %w", err) + if err := documentVersion.Update(ctx, tx, s.svc.scope); err != nil { + return fmt.Errorf("cannot update document version status: %w", err) } return nil @@ -457,66 +598,10 @@ func (s *DocumentApprovalService) AddApprover( ) if err != nil { - return nil, err + return nil, nil, err } - return decision, nil -} - -func (s *DocumentApprovalService) RemoveApprover( - ctx context.Context, - approvalDecisionID gid.GID, -) (gid.GID, error) { - var documentVersionID gid.GID - - err := s.svc.pg.WithTx( - ctx, - func(ctx context.Context, tx pg.Tx) error { - decision := &coredata.DocumentVersionApprovalDecision{} - if err := decision.LoadByID(ctx, tx, s.svc.scope, approvalDecisionID); err != nil { - return fmt.Errorf("cannot load approval decision: %w", err) - } - - quorum := &coredata.DocumentVersionApprovalQuorum{} - if err := quorum.LoadByID(ctx, tx, s.svc.scope, decision.QuorumID); err != nil { - return fmt.Errorf("cannot load approval quorum: %w", err) - } - - if quorum.Status != coredata.DocumentVersionApprovalQuorumStatusPending { - return &ErrDocumentVersionNotPendingApproval{} - } - - documentVersionID = quorum.VersionID - - if err := decision.Delete(ctx, tx, s.svc.scope); err != nil { - return fmt.Errorf("cannot delete approval decision: %w", err) - } - - remaining, err := s.countDecisions(ctx, tx, quorum.ID) - if err != nil { - return fmt.Errorf("cannot count remaining decisions: %w", err) - } - - if remaining == 0 { - if err := quorum.Delete(ctx, tx, s.svc.scope); err != nil { - return fmt.Errorf("cannot delete approval quorum: %w", err) - } - return nil - } - - if err := s.maybeApproveQuorum(ctx, tx, quorum.ID); err != nil { - return fmt.Errorf("cannot check quorum approval: %w", err) - } - - return nil - }, - ) - - if err != nil { - return gid.GID{}, err - } - - return documentVersionID, nil + return quorum, documentVersion, nil } func (s *DocumentApprovalService) GetQuorum( @@ -726,34 +811,6 @@ func (s *DocumentApprovalService) loadQuorumAndProfile( return quorum, profile, nil } -func (s *DocumentApprovalService) rejectPendingQuorum( - ctx context.Context, - tx pg.Tx, - documentVersionID gid.GID, -) error { - existingQuorum := &coredata.DocumentVersionApprovalQuorum{} - if err := existingQuorum.LoadLastByDocumentVersionID(ctx, tx, s.svc.scope, documentVersionID); err != nil { - if errors.Is(err, coredata.ErrResourceNotFound) { - return nil - } - return fmt.Errorf("cannot load last quorum: %w", err) - } - - if existingQuorum.Status != coredata.DocumentVersionApprovalQuorumStatusPending { - return nil - } - - now := time.Now() - existingQuorum.Status = coredata.DocumentVersionApprovalQuorumStatusRejected - existingQuorum.UpdatedAt = now - - if err := existingQuorum.Update(ctx, tx, s.svc.scope); err != nil { - return fmt.Errorf("cannot reject existing quorum: %w", err) - } - - return nil -} - func (s *DocumentApprovalService) createDecisions( ctx context.Context, tx pg.Tx, @@ -918,16 +975,18 @@ func (s *DocumentApprovalService) maybeApproveQuorum( return fmt.Errorf("cannot count total decisions: %w", err) } - if totalCount > 0 { - decisions := &coredata.DocumentVersionApprovalDecisions{} - approvedCount, err := decisions.CountApprovedByQuorumID(ctx, tx, s.svc.scope, quorumID) - if err != nil { - return fmt.Errorf("cannot count approved decisions: %w", err) - } + if totalCount == 0 { + return nil + } - if approvedCount != totalCount { - return nil - } + decisions := &coredata.DocumentVersionApprovalDecisions{} + approvedCount, err := decisions.CountApprovedByQuorumID(ctx, tx, s.svc.scope, quorumID) + if err != nil { + return fmt.Errorf("cannot count approved decisions: %w", err) + } + + if approvedCount != totalCount { + return nil } quorum := &coredata.DocumentVersionApprovalQuorum{} @@ -960,13 +1019,17 @@ func (s *DocumentApprovalService) publishVersion( return fmt.Errorf("cannot load document version: %w", err) } - _, _, err := s.svc.Documents.publishMajorVersionInTx( - ctx, - tx, - version.DocumentID, - nil, - false, - ) + document := &coredata.Document{} + if err := document.LoadByID(ctx, tx, s.svc.scope, version.DocumentID); err != nil { + return fmt.Errorf("cannot load document: %w", err) + } - return err + document.CurrentPublishedMajor = &version.Major + document.CurrentPublishedMinor = &version.Minor + + if err := s.svc.Documents.finalizePublish(ctx, tx, document, version, nil); err != nil { + return fmt.Errorf("cannot finalize publish: %w", err) + } + + return nil } diff --git a/pkg/probo/document_service.go b/pkg/probo/document_service.go index 624ce95b2..8a83d1ae4 100644 --- a/pkg/probo/document_service.go +++ b/pkg/probo/document_service.go @@ -62,6 +62,12 @@ type ( ErrDocumentVersionNotDraft struct { } + ErrDocumentVersionNotPublished struct { + } + + ErrDocumentVersionPendingApproval struct { + } + ErrDocumentArchived struct { } @@ -78,12 +84,14 @@ type ( Classification coredata.DocumentClassification DocumentType coredata.DocumentType TrustCenterVisibility *coredata.TrustCenterVisibility + DefaultApproverIDs []gid.GID } UpdateDocumentRequest struct { DocumentID gid.GID Title *string TrustCenterVisibility *coredata.TrustCenterVisibility + DefaultApproverIDs *[]gid.GID } UpdateDocumentVersionRequest struct { @@ -129,6 +137,11 @@ func (cdr *CreateDocumentRequest) Validate() error { v.Check(cdr.Classification, "classification", validator.Required(), validator.OneOfSlice(coredata.DocumentClassifications())) v.Check(cdr.DocumentType, "document_type", validator.Required(), validator.OneOfSlice(coredata.DocumentTypes())) v.Check(cdr.TrustCenterVisibility, "trust_center_visibility", validator.OneOfSlice(coredata.TrustCenterVisibilities())) + v.Check(len(cdr.DefaultApproverIDs), "default_approver_ids", validator.Max(100)) + v.Check(cdr.DefaultApproverIDs, "default_approver_ids", validator.NoDuplicates()) + v.CheckEach(cdr.DefaultApproverIDs, "default_approver_ids", func(_ int, item any) { + v.Check(item, "default_approver_ids", validator.GID(coredata.MembershipProfileEntityType)) + }) return v.Error() } @@ -139,6 +152,13 @@ func (udr *UpdateDocumentRequest) Validate() error { v.Check(udr.DocumentID, "document_id", validator.Required(), validator.GID(coredata.DocumentEntityType)) v.Check(udr.Title, "title", validator.SafeTextNoNewLine(TitleMaxLength)) v.Check(udr.TrustCenterVisibility, "trust_center_visibility", validator.OneOfSlice(coredata.TrustCenterVisibilities())) + if udr.DefaultApproverIDs != nil { + v.Check(len(*udr.DefaultApproverIDs), "default_approver_ids", validator.Max(100)) + v.Check(*udr.DefaultApproverIDs, "default_approver_ids", validator.NoDuplicates()) + v.CheckEach(*udr.DefaultApproverIDs, "default_approver_ids", func(_ int, item any) { + v.Check(item, "default_approver_ids", validator.GID(coredata.MembershipProfileEntityType)) + }) + } return v.Error() } @@ -179,7 +199,15 @@ func (e ErrSignatureNotCancellable) Error() string { } func (e ErrDocumentVersionNotDraft) Error() string { - return "cannot update a published document version" + return "document version is not a draft" +} + +func (e ErrDocumentVersionNotPublished) Error() string { + return "document version is not published" +} + +func (e ErrDocumentVersionPendingApproval) Error() string { + return "cannot publish a document version that is pending approval" } func (e ErrDocumentArchived) Error() string { @@ -214,6 +242,48 @@ func (s *DocumentService) Get( return document, nil } +func (s *DocumentService) GetDefaultApprovers( + ctx context.Context, + documentID gid.GID, +) (coredata.MembershipProfiles, error) { + var approvers coredata.DocumentDefaultApprovers + + err := s.svc.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + return approvers.LoadByDocumentID(ctx, conn, s.svc.scope, documentID) + }, + ) + + if err != nil { + return nil, fmt.Errorf("cannot load default approvers: %w", err) + } + + if len(approvers) == 0 { + return nil, nil + } + + profileIDs := make([]gid.GID, len(approvers)) + for i, a := range approvers { + profileIDs[i] = a.ApproverProfileID + } + + var profiles coredata.MembershipProfiles + + err = s.svc.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + return profiles.LoadByIDs(ctx, conn, s.svc.scope, profileIDs) + }, + ) + + if err != nil { + return nil, fmt.Errorf("cannot load approver profiles: %w", err) + } + + return profiles, nil +} + func (s *DocumentService) GetByIDs( ctx context.Context, documentIDs ...gid.GID, @@ -343,6 +413,10 @@ func (s DocumentService) GenerateChangelog( return fmt.Errorf("cannot load document: %w", err) } + if document.ArchivedAt != nil { + return &ErrDocumentArchived{} + } + if document.CurrentPublishedMajor == nil { initialVersionChangelog := "Initial version" changelog = &initialVersionChangelog @@ -375,37 +449,6 @@ func (s DocumentService) GenerateChangelog( return changelog, nil } -func (s *DocumentService) BulkPublishMajorVersions( - ctx context.Context, - req BulkPublishVersionsRequest, -) ([]*coredata.DocumentVersion, []*coredata.Document, error) { - var publishedVersions []*coredata.DocumentVersion - var updatedDocuments []*coredata.Document - - err := s.svc.pg.WithTx( - ctx, - func(ctx context.Context, tx pg.Tx) error { - for _, documentID := range req.DocumentIDs { - document, version, err := s.publishMajorVersionInTx(ctx, tx, documentID, &req.Changelog, true) - if err != nil { - return fmt.Errorf("cannot publish document %q: %w", documentID, err) - } - - publishedVersions = append(publishedVersions, version) - updatedDocuments = append(updatedDocuments, document) - } - - return nil - }, - ) - - if err != nil { - return nil, nil, err - } - - return publishedVersions, updatedDocuments, nil -} - func (s *DocumentService) BulkPublishMinorVersions( ctx context.Context, req BulkPublishVersionsRequest, @@ -417,6 +460,16 @@ func (s *DocumentService) BulkPublishMinorVersions( ctx, func(ctx context.Context, tx pg.Tx) error { for _, documentID := range req.DocumentIDs { + dv := &coredata.DocumentVersion{} + if err := dv.LoadLatestVersion(ctx, tx, s.svc.scope, documentID); err != nil { + return fmt.Errorf("cannot load latest version for %q: %w", documentID, err) + } + + // Skip documents already pending approval. + if dv.Status == coredata.DocumentVersionStatusPendingApproval { + continue + } + document, version, err := s.publishMinorVersionInTx(ctx, tx, documentID, &req.Changelog, true) if err != nil { return fmt.Errorf("cannot publish document %q: %w", documentID, err) @@ -449,6 +502,15 @@ func (s *DocumentService) PublishMajorVersion( err := s.svc.pg.WithTx( ctx, func(ctx context.Context, tx pg.Tx) error { + dv := &coredata.DocumentVersion{} + if err := dv.LoadLatestVersion(ctx, tx, s.svc.scope, documentID); err != nil { + return fmt.Errorf("cannot load latest version: %w", err) + } + + if dv.Status == coredata.DocumentVersionStatusPendingApproval { + return &ErrDocumentVersionPendingApproval{} + } + var err error document, documentVersion, err = s.publishMajorVersionInTx(ctx, tx, documentID, changelog, false) @@ -479,6 +541,15 @@ func (s *DocumentService) PublishMinorVersion( err := s.svc.pg.WithTx( ctx, func(ctx context.Context, tx pg.Tx) error { + dv := &coredata.DocumentVersion{} + if err := dv.LoadLatestVersion(ctx, tx, s.svc.scope, documentID); err != nil { + return fmt.Errorf("cannot load latest version: %w", err) + } + + if dv.Status == coredata.DocumentVersionStatusPendingApproval { + return &ErrDocumentVersionPendingApproval{} + } + var err error document, documentVersion, err = s.publishMinorVersionInTx(ctx, tx, documentID, changelog, false) @@ -566,6 +637,13 @@ func (s *DocumentService) Create( return fmt.Errorf("cannot create document version: %w", err) } + if len(req.DefaultApproverIDs) > 0 { + approvers := &coredata.DocumentDefaultApprovers{} + if err := approvers.MergeByDocumentID(ctx, conn, s.svc.scope, documentID, organization.ID, req.DefaultApproverIDs); err != nil { + return fmt.Errorf("cannot set default approvers: %w", err) + } + } + return nil }, ) @@ -916,19 +994,30 @@ func (s *DocumentService) RequestSignature( ctx context.Context, req RequestSignatureRequest, ) (*coredata.DocumentVersionSignature, error) { - documentVersion, err := s.GetVersion(ctx, req.DocumentVersionID) - if err != nil { - return nil, fmt.Errorf("cannot get document version: %w", err) - } - - if documentVersion.Status != coredata.DocumentVersionStatusPublished { - return nil, fmt.Errorf("cannot request signature for unpublished version") - } - var signature *coredata.DocumentVersionSignature - err = s.svc.pg.WithTx( + + err := s.svc.pg.WithTx( ctx, func(ctx context.Context, tx pg.Tx) error { + documentVersion := &coredata.DocumentVersion{} + if err := documentVersion.LoadByID(ctx, tx, s.svc.scope, req.DocumentVersionID); err != nil { + return fmt.Errorf("cannot load document version: %w", err) + } + + document := &coredata.Document{} + if err := document.LoadByID(ctx, tx, s.svc.scope, documentVersion.DocumentID); err != nil { + return fmt.Errorf("cannot load document: %w", err) + } + + if document.ArchivedAt != nil { + return &ErrDocumentArchived{} + } + + if documentVersion.Status != coredata.DocumentVersionStatusPublished { + return fmt.Errorf("cannot request signature for unpublished version") + } + + var err error signature, err = s.createSignatureRequestInTx(ctx, tx, req.DocumentVersionID, req.Signatory, false) if err != nil { return fmt.Errorf("cannot create signature request: %w", err) @@ -1015,12 +1104,16 @@ func (s *DocumentService) CreateDraft( return fmt.Errorf("cannot load document: %w", err) } + if document.ArchivedAt != nil { + return &ErrDocumentArchived{} + } + if err := latestVersion.LoadLatestVersion(ctx, conn, s.svc.scope, documentID); err != nil { return fmt.Errorf("cannot load latest version: %w", err) } if latestVersion.Status != coredata.DocumentVersionStatusPublished { - return fmt.Errorf("cannot create draft from unpublished version") + return &ErrDocumentVersionNotPublished{} } draftVersion.ID = draftVersionID @@ -1064,6 +1157,15 @@ func (s *DocumentService) DeleteDraft( return fmt.Errorf("cannot load document version: %w", err) } + document := &coredata.Document{} + if err := document.LoadByID(ctx, conn, s.svc.scope, documentVersion.DocumentID); err != nil { + return fmt.Errorf("cannot load document: %w", err) + } + + if document.ArchivedAt != nil { + return &ErrDocumentArchived{} + } + if documentVersion.Status != coredata.DocumentVersionStatusDraft { return fmt.Errorf("cannot delete published document version") } @@ -1639,6 +1741,13 @@ func (s *DocumentService) Update( } } + if req.DefaultApproverIDs != nil { + defaultApprovers := &coredata.DocumentDefaultApprovers{} + if err := defaultApprovers.MergeByDocumentID(ctx, tx, s.svc.scope, req.DocumentID, document.OrganizationID, *req.DefaultApproverIDs); err != nil { + return fmt.Errorf("cannot update default approvers: %w", err) + } + } + return nil }, ) @@ -1753,6 +1862,20 @@ func (s *DocumentService) CancelSignatureRequest( return fmt.Errorf("cannot load document version signature: %w", err) } + documentVersion := &coredata.DocumentVersion{} + if err := documentVersion.LoadByID(ctx, tx, s.svc.scope, documentVersionSignature.DocumentVersionID); err != nil { + return fmt.Errorf("cannot load document version: %w", err) + } + + document := &coredata.Document{} + if err := document.LoadByID(ctx, tx, s.svc.scope, documentVersion.DocumentID); err != nil { + return fmt.Errorf("cannot load document: %w", err) + } + + if document.ArchivedAt != nil { + return &ErrDocumentArchived{} + } + if documentVersionSignature.State != coredata.DocumentVersionSignatureStateRequested { return ErrSignatureNotCancellable{ currentState: documentVersionSignature.State, @@ -2269,7 +2392,7 @@ func (s *DocumentService) loadDraftForPublish( return document, documentVersion, nil } - if documentVersion.Status != coredata.DocumentVersionStatusDraft { + if documentVersion.Status != coredata.DocumentVersionStatusDraft && documentVersion.Status != coredata.DocumentVersionStatusPendingApproval { return nil, nil, &ErrDocumentVersionNotDraft{} } diff --git a/pkg/probo/policies.go b/pkg/probo/policies.go index 8874ed9df..6d07d1ec0 100644 --- a/pkg/probo/policies.go +++ b/pkg/probo/policies.go @@ -20,71 +20,13 @@ import ( ) var ( - organizationCondition = policy.Equals("principal.organization_id", "resource.organization_id") - documentWriteActiveOnly = policy.Deny( - ActionDocumentUpdate, - ActionDocumentArchive, - ActionDocumentDraftVersionCreate, - ActionDocumentChangelogGenerate, - ActionDocumentSendSigningNotifications, - ActionDocumentVersionUpdate, - ActionDocumentVersionPublish, - ActionDocumentVersionRequestApproval, - ActionDocumentVersionApprove, - ActionDocumentVersionReject, - ActionDocumentVersionAddApprover, - ActionDocumentVersionRemoveApprover, - 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"), - ) - - // Deny requesting approval when a pending quorum exists - documentRequestApprovalNoPendingQuorum = policy.Deny( - ActionDocumentVersionRequestApproval, - ).WithSID("document-request-approval-no-pending-quorum").When( - organizationCondition, - policy.Equals("resource.last_quorum_status", "PENDING"), - ) - - // Deny requesting approval when the version is already published - documentRequestApprovalNotPublished = policy.Deny( - ActionDocumentVersionRequestApproval, - ).WithSID("document-request-approval-not-published").When( - organizationCondition, - policy.Equals("resource.version_status", "PUBLISHED"), - ) - - // Deny adding/removing approvers when there is no pending quorum - documentApproverRequiresPendingQuorum = policy.Deny( - ActionDocumentVersionAddApprover, - ActionDocumentVersionRemoveApprover, - ).WithSID("document-approver-requires-pending-quorum").When( - organizationCondition, - policy.NotEquals("resource.last_quorum_status", "PENDING"), - ) + organizationCondition = policy.Equals("principal.organization_id", "resource.organization_id") ) // OwnerPolicy defines permissions for organization owners. var OwnerPolicy = policy.NewPolicy( "probo:owner", "Probo Owner", - documentWriteActiveOnly, - documentUnarchiveArchivedOnly, - - documentRequestApprovalNoPendingQuorum, - documentRequestApprovalNotPublished, - - documentApproverRequiresPendingQuorum, policy.Allow("core:*").WithSID("full-core-access").When(organizationCondition), ).WithDescription("Full probo access for organization owners") @@ -92,13 +34,6 @@ var OwnerPolicy = policy.NewPolicy( var AdminPolicy = policy.NewPolicy( "probo:admin", "Probo Admin", - documentWriteActiveOnly, - documentUnarchiveArchivedOnly, - - documentRequestApprovalNoPendingQuorum, - documentRequestApprovalNotPublished, - - documentApproverRequiresPendingQuorum, policy.Allow("core:*").WithSID("full-core-access").When(organizationCondition), ).WithDescription("Probo admin access - can manage core entities") @@ -106,7 +41,6 @@ var AdminPolicy = policy.NewPolicy( var ViewerPolicy = policy.NewPolicy( "probo:viewer", "Probo Viewer", - documentWriteActiveOnly, policy.Allow( ActionOrganizationGet, ActionOrganizationGetLogoUrl, @@ -244,7 +178,6 @@ var AuditorPolicy = policy.NewPolicy( var EmployeePolicy = policy.NewPolicy( "probo:employee", "Probo Employee", - documentWriteActiveOnly, policy.Allow( ActionOrganizationGet, ActionOrganizationGetLogoUrl, @@ -263,7 +196,6 @@ var EmployeePolicy = policy.NewPolicy( ActionDocumentVersionApprovalList, ActionDocumentVersionApprove, ActionDocumentVersionReject, - ActionEmployeeDocumentVersionExportPDF, ).WithSID("document-version-approval").When(organizationCondition), ).WithDescription("Employee access - can sign documents, approve documents, and view internal content") diff --git a/pkg/server/api/console/v1/schema.graphql b/pkg/server/api/console/v1/schema.graphql index 77d2f7e2c..6a8836f4c 100644 --- a/pkg/server/api/console/v1/schema.graphql +++ b/pkg/server/api/console/v1/schema.graphql @@ -104,6 +104,10 @@ enum DocumentVersionStatus @goEnum( value: "go.probo.inc/probo/pkg/coredata.DocumentVersionStatusDraft" ) + PENDING_APPROVAL + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.DocumentVersionStatusPendingApproval" + ) PUBLISHED @goEnum( value: "go.probo.inc/probo/pkg/coredata.DocumentVersionStatusPublished" @@ -2583,6 +2587,8 @@ type Document implements Node { filter: ControlFilter ): ControlConnection! @goField(forceResolver: true) + defaultApprovers: [Profile!]! @goField(forceResolver: true) + status: DocumentStatus! archivedAt: Datetime @@ -3934,6 +3940,9 @@ type Mutation { requestDocumentVersionApproval( input: RequestDocumentVersionApprovalInput! ): RequestDocumentVersionApprovalPayload! + voidDocumentVersionApproval( + input: VoidDocumentVersionApprovalInput! + ): VoidDocumentVersionApprovalPayload! bulkDeleteDocuments( input: BulkDeleteDocumentsInput! ): BulkDeleteDocumentsPayload! @@ -3969,12 +3978,6 @@ type Mutation { input: CancelSignatureRequestInput! ): CancelSignatureRequestPayload! signDocument(input: SignDocumentInput!): SignDocumentPayload! - addDocumentVersionApprover( - input: AddDocumentVersionApproverInput! - ): AddDocumentVersionApproverPayload! - removeDocumentVersionApprover( - input: RemoveDocumentVersionApproverInput! - ): RemoveDocumentVersionApproverPayload! approveDocumentVersion( input: ApproveDocumentVersionInput! ): ApproveDocumentVersionPayload! @@ -4668,6 +4671,7 @@ input CreateDocumentInput { documentType: DocumentType! classification: DocumentClassification! trustCenterVisibility: TrustCenterVisibility + defaultApproverIds: [ID!] } input UpdateDocumentInput { @@ -4675,6 +4679,7 @@ input UpdateDocumentInput { title: String content: String trustCenterVisibility: TrustCenterVisibility + defaultApproverIds: [ID!] } input ExportDocumentVersionPDFInput { @@ -5686,6 +5691,10 @@ enum DocumentVersionApprovalDecisionState @goEnum( value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalDecisionStateRejected" ) + VOIDED + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalDecisionStateVoided" + ) } enum DocumentVersionApprovalDecisionOrderField @@ -5714,6 +5723,10 @@ enum DocumentVersionApprovalQuorumStatus @goEnum( value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalQuorumStatusRejected" ) + VOIDED + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalQuorumStatusVoided" + ) } enum DocumentVersionApprovalQuorumOrderField @@ -5816,23 +5829,6 @@ type RejectDocumentVersionPayload { approvalDecision: DocumentVersionApprovalDecision! } -input AddDocumentVersionApproverInput { - documentVersionId: ID! - approverId: ID! -} - -type AddDocumentVersionApproverPayload { - approvalDecisionEdge: DocumentVersionApprovalDecisionEdge! -} - -input RemoveDocumentVersionApproverInput { - approvalDecisionId: ID! -} - -type RemoveDocumentVersionApproverPayload { - deletedApprovalDecisionId: ID! - documentVersion: DocumentVersion! -} input RequestSignatureInput { documentVersionId: ID! @@ -5897,6 +5893,15 @@ type RequestDocumentVersionApprovalPayload { approvalQuorum: DocumentVersionApprovalQuorum! } +input VoidDocumentVersionApprovalInput { + documentVersionId: ID! +} + +type VoidDocumentVersionApprovalPayload { + approvalQuorum: DocumentVersionApprovalQuorum! + documentVersion: DocumentVersion! +} + input PublishMajorDocumentVersionInput { documentId: ID! changelog: String diff --git a/pkg/server/api/console/v1/v1_resolver.go b/pkg/server/api/console/v1/v1_resolver.go index 33b611e2a..d5e034567 100644 --- a/pkg/server/api/console/v1/v1_resolver.go +++ b/pkg/server/api/console/v1/v1_resolver.go @@ -1577,6 +1577,28 @@ func (r *documentResolver) Controls(ctx context.Context, obj *types.Document, fi return types.NewControlConnection(page, r, obj.ID, controlFilter), nil } +// DefaultApprovers is the resolver for the defaultApprovers field. +func (r *documentResolver) DefaultApprovers(ctx context.Context, obj *types.Document) ([]*types.Profile, error) { + if err := r.authorize(ctx, obj.ID, probo.ActionDocumentGet); err != nil { + return nil, err + } + + prb := r.ProboService(ctx, obj.ID.TenantID()) + + profiles, err := prb.Documents.GetDefaultApprovers(ctx, obj.ID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot get default approvers", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + result := make([]*types.Profile, len(profiles)) + for i, p := range profiles { + result[i] = types.NewProfile(p) + } + + return result, nil +} + // Permission is the resolver for the permission field. func (r *documentResolver) Permission(ctx context.Context, obj *types.Document, action string) (bool, error) { return r.Resolver.Permission(ctx, obj, action) @@ -5247,6 +5269,7 @@ func (r *mutationResolver) CreateDocument(ctx context.Context, input types.Creat Classification: input.Classification, DocumentType: input.DocumentType, TrustCenterVisibility: input.TrustCenterVisibility, + DefaultApproverIDs: input.DefaultApproverIds, }, ) if err != nil { @@ -5275,12 +5298,18 @@ func (r *mutationResolver) UpdateDocument(ctx context.Context, input types.Updat prb := r.ProboService(ctx, input.ID.TenantID()) + var defaultApproverIDs *[]gid.GID + if input.DefaultApproverIds != nil { + defaultApproverIDs = &input.DefaultApproverIds + } + document, err := prb.Documents.Update( ctx, probo.UpdateDocumentRequest{ DocumentID: input.ID, Title: input.Title, TrustCenterVisibility: input.TrustCenterVisibility, + DefaultApproverIDs: defaultApproverIDs, }, ) @@ -5659,6 +5688,10 @@ func (r *mutationResolver) PublishMajorDocumentVersion(ctx context.Context, inpu return nil, gqlutils.Invalid(ctx, errNotDraft) } + if errPending, ok := errors.AsType[*probo.ErrDocumentVersionPendingApproval](err); ok { + return nil, gqlutils.Conflict(ctx, errPending) + } + r.logger.ErrorCtx(ctx, "cannot publish major document version", log.Error(err)) return nil, gqlutils.Internal(ctx) } @@ -5692,6 +5725,10 @@ func (r *mutationResolver) PublishMinorDocumentVersion(ctx context.Context, inpu return nil, gqlutils.Invalid(ctx, errNotDraft) } + if errPending, ok := errors.AsType[*probo.ErrDocumentVersionPendingApproval](err); ok { + return nil, gqlutils.Conflict(ctx, errPending) + } + r.logger.ErrorCtx(ctx, "cannot publish minor document version", log.Error(err)) return nil, gqlutils.Internal(ctx) } @@ -5719,7 +5756,7 @@ func (r *mutationResolver) BulkPublishMajorDocumentVersions(ctx context.Context, prb := r.ProboService(ctx, input.DocumentIds[0].TenantID()) - versions, documents, err := prb.Documents.BulkPublishMajorVersions(ctx, probo.BulkPublishVersionsRequest{ + versions, documents, err := prb.DocumentApprovals.BulkPublishMajorVersions(ctx, probo.BulkPublishVersionsRequest{ DocumentIDs: input.DocumentIds, Changelog: input.Changelog, }) @@ -5820,6 +5857,10 @@ func (r *mutationResolver) RequestDocumentVersionApproval(ctx context.Context, i return nil, gqlutils.Conflict(ctx, errArchived) } + if errNotDraft, ok := errors.AsType[*probo.ErrDocumentVersionNotDraft](err); ok { + return nil, gqlutils.Conflict(ctx, errNotDraft) + } + if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok { return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors) } @@ -5833,6 +5874,34 @@ func (r *mutationResolver) RequestDocumentVersionApproval(ctx context.Context, i }, nil } +// VoidDocumentVersionApproval is the resolver for the voidDocumentVersionApproval field. +func (r *mutationResolver) VoidDocumentVersionApproval(ctx context.Context, input types.VoidDocumentVersionApprovalInput) (*types.VoidDocumentVersionApprovalPayload, error) { + if err := r.authorize(ctx, input.DocumentVersionID, probo.ActionDocumentVersionVoidApproval); err != nil { + return nil, err + } + + prb := r.ProboService(ctx, input.DocumentVersionID.TenantID()) + + quorum, documentVersion, err := prb.DocumentApprovals.VoidApproval(ctx, input.DocumentVersionID) + if err != nil { + if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok { + return nil, gqlutils.Conflict(ctx, errArchived) + } + + if errNotPending, ok := errors.AsType[*probo.ErrDocumentVersionNotPendingApproval](err); ok { + return nil, gqlutils.Conflict(ctx, errNotPending) + } + + r.logger.ErrorCtx(ctx, "cannot void document version approval", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return &types.VoidDocumentVersionApprovalPayload{ + ApprovalQuorum: types.NewDocumentVersionApprovalQuorum(quorum), + DocumentVersion: types.NewDocumentVersion(documentVersion), + }, nil +} + // BulkDeleteDocuments is the resolver for the bulkDeleteDocuments field. func (r *mutationResolver) BulkDeleteDocuments(ctx context.Context, input types.BulkDeleteDocumentsInput) (*types.BulkDeleteDocumentsPayload, error) { if len(input.DocumentIds) == 0 { @@ -5957,6 +6026,10 @@ func (r *mutationResolver) GenerateDocumentChangelog(ctx context.Context, input changelog, err := prb.Documents.GenerateChangelog(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 generate document changelog", log.Error(err)) return nil, gqlutils.Internal(ctx) } @@ -5976,6 +6049,14 @@ func (r *mutationResolver) CreateDraftDocumentVersion(ctx context.Context, input documentVersion, err := prb.Documents.CreateDraft(ctx, input.DocumentID) if err != nil { + if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok { + return nil, gqlutils.Conflict(ctx, errArchived) + } + + if errNotPublished, ok := errors.AsType[*probo.ErrDocumentVersionNotPublished](err); ok { + return nil, gqlutils.Conflict(ctx, errNotPublished) + } + r.logger.ErrorCtx(ctx, "cannot create draft document version", log.Error(err)) return nil, gqlutils.Internal(ctx) } @@ -5995,6 +6076,10 @@ func (r *mutationResolver) DeleteDraftDocumentVersion(ctx context.Context, input err := prb.Documents.DeleteDraft(ctx, input.DocumentVersionID) if err != nil { + if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok { + return nil, gqlutils.Conflict(ctx, errArchived) + } + r.logger.ErrorCtx(ctx, "cannot delete draft document version", log.Error(err)) return nil, gqlutils.Internal(ctx) } @@ -6061,6 +6146,10 @@ func (r *mutationResolver) RequestSignature(ctx context.Context, input types.Req }, ) if err != nil { + if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok { + return nil, gqlutils.Conflict(ctx, errArchived) + } + r.logger.ErrorCtx(ctx, "cannot request signature", log.Error(err)) return nil, gqlutils.Internal(ctx) } @@ -6132,6 +6221,10 @@ func (r *mutationResolver) CancelSignatureRequest(ctx context.Context, input typ err := prb.Documents.CancelSignatureRequest(ctx, input.DocumentVersionSignatureID) if err != nil { + if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok { + return nil, gqlutils.Conflict(ctx, errArchived) + } + r.logger.ErrorCtx(ctx, "cannot cancel signature request", log.Error(err)) return nil, gqlutils.Internal(ctx) } @@ -6165,53 +6258,6 @@ func (r *mutationResolver) SignDocument(ctx context.Context, input types.SignDoc }, nil } -// AddDocumentVersionApprover is the resolver for the addDocumentVersionApprover field. -func (r *mutationResolver) AddDocumentVersionApprover(ctx context.Context, input types.AddDocumentVersionApproverInput) (*types.AddDocumentVersionApproverPayload, error) { - if err := r.authorize(ctx, input.DocumentVersionID, probo.ActionDocumentVersionAddApprover); err != nil { - return nil, err - } - - prb := r.ProboService(ctx, input.DocumentVersionID.TenantID()) - - decision, err := prb.DocumentApprovals.AddApprover(ctx, input.DocumentVersionID, input.ApproverID) - if err != nil { - if errNotPending, ok := errors.AsType[*probo.ErrDocumentVersionNotPendingApproval](err); ok { - return nil, gqlutils.Invalid(ctx, errNotPending) - } - - r.logger.ErrorCtx(ctx, "cannot add document version approver", log.Error(err)) - return nil, gqlutils.Internal(ctx) - } - - return &types.AddDocumentVersionApproverPayload{ - ApprovalDecisionEdge: types.NewDocumentVersionApprovalDecisionEdge(decision, coredata.DocumentVersionApprovalDecisionOrderFieldCreatedAt), - }, nil -} - -// RemoveDocumentVersionApprover is the resolver for the removeDocumentVersionApprover field. -func (r *mutationResolver) RemoveDocumentVersionApprover(ctx context.Context, input types.RemoveDocumentVersionApproverInput) (*types.RemoveDocumentVersionApproverPayload, error) { - if err := r.authorize(ctx, input.ApprovalDecisionID, probo.ActionDocumentVersionRemoveApprover); err != nil { - return nil, err - } - - prb := r.ProboService(ctx, input.ApprovalDecisionID.TenantID()) - - documentVersionID, err := prb.DocumentApprovals.RemoveApprover(ctx, input.ApprovalDecisionID) - if err != nil { - if errAlreadyMade, ok := errors.AsType[*probo.ErrApprovalDecisionAlreadyMade](err); ok { - return nil, gqlutils.Conflict(ctx, errAlreadyMade) - } - - r.logger.ErrorCtx(ctx, "cannot remove document version approver", log.Error(err)) - return nil, gqlutils.Internal(ctx) - } - - return &types.RemoveDocumentVersionApproverPayload{ - DeletedApprovalDecisionID: input.ApprovalDecisionID, - DocumentVersion: &types.DocumentVersion{ID: documentVersionID}, - }, nil -} - // ApproveDocumentVersion is the resolver for the approveDocumentVersion field. func (r *mutationResolver) ApproveDocumentVersion(ctx context.Context, input types.ApproveDocumentVersionInput) (*types.ApproveDocumentVersionPayload, error) { if err := r.authorize(ctx, input.DocumentVersionID, probo.ActionDocumentVersionApprove); err != nil { @@ -6238,6 +6284,10 @@ func (r *mutationResolver) ApproveDocumentVersion(ctx context.Context, input typ SignerUA: httpReq.UserAgent(), }) if err != nil { + if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok { + return nil, gqlutils.Conflict(ctx, errArchived) + } + if errNotPending, ok := errors.AsType[*probo.ErrDocumentVersionNotPendingApproval](err); ok { return nil, gqlutils.Invalid(ctx, errNotPending) } @@ -6275,6 +6325,10 @@ func (r *mutationResolver) RejectDocumentVersion(ctx context.Context, input type Comment: input.Comment, }) if err != nil { + if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok { + return nil, gqlutils.Conflict(ctx, errArchived) + } + if errNotPending, ok := errors.AsType[*probo.ErrDocumentVersionNotPendingApproval](err); ok { return nil, gqlutils.Invalid(ctx, errNotPending) } diff --git a/pkg/server/api/mcp/v1/schema.resolvers.go b/pkg/server/api/mcp/v1/schema.resolvers.go index 6553b8cda..c4bd298fb 100644 --- a/pkg/server/api/mcp/v1/schema.resolvers.go +++ b/pkg/server/api/mcp/v1/schema.resolvers.go @@ -2095,6 +2095,7 @@ func (r *Resolver) AddDocumentTool(ctx context.Context, req *mcp.CallToolRequest Classification: input.Classification, DocumentType: input.DocumentType, TrustCenterVisibility: trustCenterVisibility, + DefaultApproverIDs: input.DefaultApproverIds, }, ) if err != nil { @@ -2109,12 +2110,18 @@ func (r *Resolver) UpdateDocumentTool(ctx context.Context, req *mcp.CallToolRequ svc := r.ProboService(ctx, input.ID) + var defaultApproverIDs *[]gid.GID + if input.DefaultApproverIds != nil { + defaultApproverIDs = &input.DefaultApproverIds + } + document, err := svc.Documents.Update( ctx, probo.UpdateDocumentRequest{ DocumentID: input.ID, Title: input.Title, TrustCenterVisibility: input.TrustCenterVisibility, + DefaultApproverIDs: defaultApproverIDs, }, ) if err != nil { @@ -3928,3 +3935,18 @@ func (r *Resolver) ListMeasureDocumentsTool(ctx context.Context, req *mcp.CallTo return nil, types.NewListMeasureDocumentsOutput(docPage), nil } + +func (r *Resolver) VoidDocumentVersionApprovalTool(ctx context.Context, req *mcp.CallToolRequest, input *types.VoidDocumentVersionApprovalInput) (*mcp.CallToolResult, types.VoidDocumentVersionApprovalOutput, error) { + r.MustAuthorize(ctx, input.DocumentVersionID, probo.ActionDocumentVersionVoidApproval) + + svc := r.ProboService(ctx, input.DocumentVersionID) + + _, documentVersion, err := svc.DocumentApprovals.VoidApproval(ctx, input.DocumentVersionID) + if err != nil { + panic(fmt.Errorf("cannot void document version approval: %w", err)) + } + + return nil, types.VoidDocumentVersionApprovalOutput{ + DocumentVersion: types.NewDocumentVersion(documentVersion), + }, nil +} diff --git a/pkg/server/api/mcp/v1/specification.yaml b/pkg/server/api/mcp/v1/specification.yaml index a44d31013..6883d439a 100644 --- a/pkg/server/api/mcp/v1/specification.yaml +++ b/pkg/server/api/mcp/v1/specification.yaml @@ -5159,6 +5159,7 @@ components: type: string enum: - DRAFT + - PENDING_APPROVAL - PUBLISHED go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.DocumentVersionStatus @@ -5495,6 +5496,11 @@ components: trust_center_visibility: $ref: "#/components/schemas/TrustCenterVisibility" description: Trust center visibility + default_approver_ids: + type: array + items: + $ref: "#/components/schemas/GID" + description: Default approver profile IDs AddDocumentOutput: type: object @@ -5522,6 +5528,11 @@ components: trust_center_visibility: $ref: "#/components/schemas/TrustCenterVisibility" description: Trust center visibility + default_approver_ids: + type: array + items: + $ref: "#/components/schemas/GID" + description: Default approver profile IDs UpdateDocumentOutput: type: object @@ -5860,6 +5871,23 @@ components: $ref: "#/components/schemas/GID" description: Deleted document version signature ID + VoidDocumentVersionApprovalInput: + type: object + required: + - document_version_id + properties: + document_version_id: + $ref: "#/components/schemas/GID" + description: Document version ID + + VoidDocumentVersionApprovalOutput: + type: object + required: + - document_version + properties: + document_version: + $ref: "#/components/schemas/DocumentVersion" + MeetingOrderField: type: string enum: @@ -8474,6 +8502,15 @@ tools: $ref: "#/components/schemas/CancelSignatureRequestInput" outputSchema: $ref: "#/components/schemas/CancelSignatureRequestOutput" + - name: voidDocumentVersionApproval + description: Void a pending document version approval request + hints: + readonly: false + destructive: true + inputSchema: + $ref: "#/components/schemas/VoidDocumentVersionApprovalInput" + outputSchema: + $ref: "#/components/schemas/VoidDocumentVersionApprovalOutput" - name: listMeetings description: List all meetings for the organization hints: diff --git a/pkg/validator/validator_common.go b/pkg/validator/validator_common.go index c3b8d999a..d66346201 100644 --- a/pkg/validator/validator_common.go +++ b/pkg/validator/validator_common.go @@ -47,6 +47,36 @@ func Required() ValidatorFunc { } } +// NoDuplicates validates that a slice contains no duplicate elements. +func NoDuplicates() ValidatorFunc { + return func(value any) *ValidationError { + actualValue, isNil := dereferenceValue(value) + if isNil { + return nil + } + + rv := reflect.ValueOf(actualValue) + if rv.Kind() != reflect.Slice { + return newValidationError(ErrorCodeInvalidFormat, "value must be a slice") + } + + if !rv.Type().Elem().Comparable() { + return newValidationError(ErrorCodeInvalidFormat, "slice elements must be comparable") + } + + seen := make(map[any]struct{}, rv.Len()) + for i := range rv.Len() { + elem := rv.Index(i).Interface() + if _, ok := seen[elem]; ok { + return newValidationError(ErrorCodeInvalidFormat, "must not contain duplicates") + } + seen[elem] = struct{}{} + } + + return nil + } +} + // NotEmpty validates that a field is not empty. // Similar to Required, but can be used independently. func NotEmpty() ValidatorFunc { diff --git a/pkg/validator/validator_common_test.go b/pkg/validator/validator_common_test.go index e319d7bee..6a75c5f6d 100644 --- a/pkg/validator/validator_common_test.go +++ b/pkg/validator/validator_common_test.go @@ -264,3 +264,65 @@ func TestRequired(t *testing.T) { } }) } + +func TestNoDuplicates(t *testing.T) { + t.Run("nil slice", func(t *testing.T) { + var slice []string + err := NoDuplicates()(slice) + if err != nil { + t.Errorf("expected no error for nil slice, got: %v", err) + } + }) + + t.Run("empty slice", func(t *testing.T) { + slice := []string{} + err := NoDuplicates()(slice) + if err != nil { + t.Errorf("expected no error for empty slice, got: %v", err) + } + }) + + t.Run("unique strings", func(t *testing.T) { + slice := []string{"a", "b", "c"} + err := NoDuplicates()(slice) + if err != nil { + t.Errorf("expected no error, got: %v", err) + } + }) + + t.Run("duplicate strings", func(t *testing.T) { + slice := []string{"a", "b", "a"} + err := NoDuplicates()(slice) + if err == nil { + t.Fatal("expected validation error for duplicates") + } else if err.Code != ErrorCodeInvalidFormat { + t.Errorf("expected error code %s, got %s", ErrorCodeInvalidFormat, err.Code) + } + }) + + t.Run("unique ints", func(t *testing.T) { + slice := []int{1, 2, 3} + err := NoDuplicates()(slice) + if err != nil { + t.Errorf("expected no error, got: %v", err) + } + }) + + t.Run("duplicate ints", func(t *testing.T) { + slice := []int{1, 2, 1} + err := NoDuplicates()(slice) + if err == nil { + t.Fatal("expected validation error for duplicates") + } + }) + + t.Run("non-comparable elements", func(t *testing.T) { + slice := []map[string]string{{"a": "b"}} + err := NoDuplicates()(slice) + if err == nil { + t.Fatal("expected validation error for non-comparable elements") + } else if err.Code != ErrorCodeInvalidFormat { + t.Errorf("expected error code %s, got %s", ErrorCodeInvalidFormat, err.Code) + } + }) +}