Redesign document approval flow

Replace the per-approver add/remove model with a quorum-based approval
system. Documents now have default approvers that are pre-populated when
requesting approval, and the publish dialog lets users adjust the list
before submitting.

Key changes:

- Add PENDING_APPROVAL document version status with dedicated transitions
- Introduce approval quorums with request/approve/reject/void lifecycle
- Add default approvers per document (stored in document_default_approvers)
  with MERGE-based upsert for efficient sync
- Add NoDuplicates validator for slice fields
- Split ALTER TYPE ADD VALUE migrations into separate files (required by
  PostgreSQL when run inside transactions)
- Use VOIDED consistently for both quorum status and decision state enums
- Expose void/approve/reject through GraphQL and MCP, with e2e tests
- Add approval management UI: publish dialog with approver selection,
  approval list with void support, and external approve/reject page

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-04-09 11:17:51 +02:00
parent 17f579b8a2
commit ab5f42ad74
38 changed files with 2125 additions and 922 deletions

View File

@@ -22,7 +22,7 @@ import { graphql } from "relay-runtime";
import type { DocumentLayoutQuery } from "#/__generated__/core/DocumentLayoutQuery.graphql"; import type { DocumentLayoutQuery } from "#/__generated__/core/DocumentLayoutQuery.graphql";
import { useOrganizationId } from "#/hooks/useOrganizationId"; import { useOrganizationId } from "#/hooks/useOrganizationId";
import { DocumentActionsDropdownn } from "./_components/DocumentActionsDropdown"; import { DocumentActionsDropdown } from "./_components/DocumentActionsDropdown";
import { DocumentLayoutDrawer } from "./_components/DocumentLayoutDrawer"; import { DocumentLayoutDrawer } from "./_components/DocumentLayoutDrawer";
import { DocumentTitleForm } from "./_components/DocumentTitleForm"; import { DocumentTitleForm } from "./_components/DocumentTitleForm";
import { DocumentVersionsDropdown } from "./_components/DocumentVersionsDropdown"; import { DocumentVersionsDropdown } from "./_components/DocumentVersionsDropdown";
@@ -142,7 +142,6 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
const isPublished = currentVersion.status === "PUBLISHED"; const isPublished = currentVersion.status === "PUBLISHED";
const lastQuorum = currentVersion.approvalQuorums?.edges?.[0]?.node ?? null; const lastQuorum = currentVersion.approvalQuorums?.edges?.[0]?.node ?? null;
const hasApprovals = lastQuorum != null; const hasApprovals = lastQuorum != null;
const hasPendingApproval = lastQuorum?.status === "PENDING";
const urlPrefix = versionId const urlPrefix = versionId
? `/organizations/${organizationId}/documents/${document.id}/versions/${versionId}` ? `/organizations/${organizationId}/documents/${document.id}/versions/${versionId}`
@@ -174,7 +173,7 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
</Button> </Button>
)} )}
<DocumentVersionsDropdown /> <DocumentVersionsDropdown />
<DocumentActionsDropdownn <DocumentActionsDropdown
documentFragmentRef={document} documentFragmentRef={document}
versionFragmentRef={currentVersion} versionFragmentRef={currentVersion}
onRefetch={onRefetch} onRefetch={onRefetch}
@@ -223,7 +222,6 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
ref={publishDialogRef} ref={publishDialogRef}
documentId={document.id} documentId={document.id}
documentFragmentRef={document} documentFragmentRef={document}
hasPendingApproval={hasPendingApproval}
onSuccess={handlePublishOrApproval} onSuccess={handlePublishOrApproval}
/> />
</> </>

View File

@@ -12,6 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE. // PERFORMANCE OF THIS SOFTWARE.
import { formatError } from "@probo/helpers";
import { useTranslate } from "@probo/i18n"; import { useTranslate } from "@probo/i18n";
import { import {
Badge, Badge,
@@ -24,8 +25,10 @@ import {
Label, Label,
PropertyRow, PropertyRow,
useDialogRef, useDialogRef,
useToast,
} from "@probo/ui"; } from "@probo/ui";
import { type ReactNode } from "react"; import { type ReactNode } from "react";
import { useMutation } from "react-relay";
import { graphql } from "relay-runtime"; import { graphql } from "relay-runtime";
import { z } from "zod"; import { z } from "zod";
@@ -33,11 +36,11 @@ import type { CreateDocumentDialogMutation } from "#/__generated__/core/CreateDo
import { ControlledField } from "#/components/form/ControlledField"; import { ControlledField } from "#/components/form/ControlledField";
import { DocumentClassificationOptions } from "#/components/form/DocumentClassificationOptions"; import { DocumentClassificationOptions } from "#/components/form/DocumentClassificationOptions";
import { DocumentTypeOptions } from "#/components/form/DocumentTypeOptions"; import { DocumentTypeOptions } from "#/components/form/DocumentTypeOptions";
import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField";
import { useFormWithSchema } from "#/hooks/useFormWithSchema"; import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import { useOrganizationId } from "#/hooks/useOrganizationId"; import { useOrganizationId } from "#/hooks/useOrganizationId";
type Props = { type CreateDocumentDialogProps = {
trigger?: ReactNode; trigger?: ReactNode;
connection: string; connection: string;
}; };
@@ -68,14 +71,16 @@ const documentSchema = z.object({
title: z.string().min(1, "Title is required"), title: z.string().min(1, "Title is required"),
documentType: z.enum(["OTHER", "GOVERNANCE", "POLICY", "PROCEDURE", "PLAN", "REGISTER", "RECORD", "REPORT", "TEMPLATE"]), documentType: z.enum(["OTHER", "GOVERNANCE", "POLICY", "PROCEDURE", "PLAN", "REGISTER", "RECORD", "REPORT", "TEMPLATE"]),
classification: z.enum(["PUBLIC", "INTERNAL", "CONFIDENTIAL", "SECRET"]), classification: z.enum(["PUBLIC", "INTERNAL", "CONFIDENTIAL", "SECRET"]),
defaultApproverIds: z.array(z.string()),
}); });
/** /**
* Dialog to create or update a document * Dialog to create or update a document
*/ */
export function CreateDocumentDialog({ trigger, connection }: Props) { export function CreateDocumentDialog({ trigger, connection }: CreateDocumentDialogProps) {
const { __ } = useTranslate(); const { __ } = useTranslate();
const organizationId = useOrganizationId(); const organizationId = useOrganizationId();
const { toast } = useToast();
const { control, handleSubmit, register, formState, reset } = useFormWithSchema( const { control, handleSubmit, register, formState, reset } = useFormWithSchema(
documentSchema, documentSchema,
@@ -83,15 +88,16 @@ export function CreateDocumentDialog({ trigger, connection }: Props) {
defaultValues: { defaultValues: {
documentType: "POLICY", documentType: "POLICY",
classification: "INTERNAL", classification: "INTERNAL",
defaultApproverIds: [],
}, },
}, },
); );
const errors = formState.errors ?? {}; const errors = formState.errors ?? {};
const [createDocument, isLoading] const [createDocument, isLoading]
= useMutationWithToasts<CreateDocumentDialogMutation>(createDocumentMutation); = useMutation<CreateDocumentDialogMutation>(createDocumentMutation);
const onSubmit = async (data: z.infer<typeof documentSchema>) => { const onSubmit = (data: z.infer<typeof documentSchema>) => {
await createDocument({ createDocument({
variables: { variables: {
input: { input: {
...data, ...data,
@@ -99,12 +105,18 @@ export function CreateDocumentDialog({ trigger, connection }: Props) {
}, },
connections: [connection], connections: [connection],
}, },
successMessage: __("Document created successfully."), onCompleted(_, errors) {
errorMessage: __("Failed to create document"), if (errors?.length) {
onSuccess: () => { 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(); dialogRef.current?.close();
reset(); reset();
}, },
onError(error) {
toast({ title: __("Error"), description: error.message, variant: "error" });
},
}); });
}; };
@@ -165,6 +177,15 @@ export function CreateDocumentDialog({ trigger, connection }: Props) {
</ControlledField> </ControlledField>
</PropertyRow> </PropertyRow>
<PropertyRow label={__("Approvers")}>
<PeopleMultiSelectField
name="defaultApproverIds"
control={control}
organizationId={organizationId}
placeholder={__("Add approvers...")}
/>
</PropertyRow>
</div> </div>
</DialogContent> </DialogContent>
<DialogFooter> <DialogFooter>

View File

@@ -23,12 +23,11 @@ import { ConnectionHandler, graphql } from "relay-runtime";
import type { DocumentActionsDropdown_archiveMutation } from "#/__generated__/core/DocumentActionsDropdown_archiveMutation.graphql"; import type { DocumentActionsDropdown_archiveMutation } from "#/__generated__/core/DocumentActionsDropdown_archiveMutation.graphql";
import type { DocumentActionsDropdown_createDraftMutation } from "#/__generated__/core/DocumentActionsDropdown_createDraftMutation.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_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_unarchiveMutation } from "#/__generated__/core/DocumentActionsDropdown_unarchiveMutation.graphql";
import type { DocumentActionsDropdown_versionFragment$key } from "#/__generated__/core/DocumentActionsDropdown_versionFragment.graphql"; import type { DocumentActionsDropdown_versionFragment$key } from "#/__generated__/core/DocumentActionsDropdown_versionFragment.graphql";
import type { DocumentActionsDropdownn_exportVersionMutation } from "#/__generated__/core/DocumentActionsDropdownn_exportVersionMutation.graphql";
import { PdfDownloadDialog, type PdfDownloadDialogRef } from "#/components/documents/PdfDownloadDialog"; import { PdfDownloadDialog, type PdfDownloadDialogRef } from "#/components/documents/PdfDownloadDialog";
import { DocumentsConnectionKey, useDeleteDocumentMutation, useDeleteDraftDocumentVersionMutation } from "#/hooks/graph/DocumentGraph"; import { DocumentsConnectionKey, useDeleteDocumentMutation, useDeleteDraftDocumentVersionMutation } from "#/hooks/graph/DocumentGraph";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import { useOrganizationId } from "#/hooks/useOrganizationId"; import { useOrganizationId } from "#/hooks/useOrganizationId";
import { CurrentUser } from "#/providers/CurrentUser"; import { CurrentUser } from "#/providers/CurrentUser";
@@ -129,7 +128,7 @@ const versionFragment = graphql`
`; `;
const exportDocumentVersionMutation = graphql` const exportDocumentVersionMutation = graphql`
mutation DocumentActionsDropdownn_exportVersionMutation( mutation DocumentActionsDropdown_exportVersionMutation(
$input: ExportDocumentVersionPDFInput! $input: ExportDocumentVersionPDFInput!
) { ) {
exportDocumentVersionPDF(input: $input) { exportDocumentVersionPDF(input: $input) {
@@ -138,7 +137,7 @@ const exportDocumentVersionMutation = graphql`
} }
`; `;
export function DocumentActionsDropdownn(props: { export function DocumentActionsDropdown(props: {
documentFragmentRef: DocumentActionsDropdown_documentFragment$key; documentFragmentRef: DocumentActionsDropdown_documentFragment$key;
versionFragmentRef: DocumentActionsDropdown_versionFragment$key; versionFragmentRef: DocumentActionsDropdown_versionFragment$key;
onRefetch: () => void; onRefetch: () => void;
@@ -158,7 +157,7 @@ export function DocumentActionsDropdownn(props: {
const version = useFragment<DocumentActionsDropdown_versionFragment$key>(versionFragment, versionFragmentRef); const version = useFragment<DocumentActionsDropdown_versionFragment$key>(versionFragment, versionFragmentRef);
const lastVersion = document.versions.edges[0].node; const lastVersion = document.versions.edges[0].node;
const hasDraft = lastVersion.status === "DRAFT"; const isLastVersionPublished = lastVersion.status === "PUBLISHED";
const isDraft = version.status === "DRAFT"; const isDraft = version.status === "DRAFT";
const [createDraftDocumentVersion, isCreatingDraft] const [createDraftDocumentVersion, isCreatingDraft]
@@ -171,13 +170,7 @@ export function DocumentActionsDropdownn(props: {
const [deleteDraftDocumentVersion, isDeletingDraft] const [deleteDraftDocumentVersion, isDeletingDraft]
= useDeleteDraftDocumentVersionMutation(); = useDeleteDraftDocumentVersionMutation();
const [exportDocumentVersion, isExporting] const [exportDocumentVersion, isExporting]
= useMutationWithToasts<DocumentActionsDropdownn_exportVersionMutation>( = useMutation<DocumentActionsDropdown_exportVersionMutation>(exportDocumentVersionMutation);
exportDocumentVersionMutation,
{
successMessage: __("PDF download started."),
errorMessage: __("Failed to generate PDF"),
},
);
const handleCreateDraft = () => { const handleCreateDraft = () => {
const connectionId = ConnectionHandler.getConnectionID(document.id, "DocumentversionsDropdownMenu_versions"); 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}`); void navigate(`/organizations/${organizationId}/documents/${document.id}/versions/${newVersionId}`);
}, },
onError(error) { onError(error) {
console.log(error);
toast({ title: __("Error"), description: error.message, variant: "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; withWatermark: boolean;
withSignatures: boolean; withSignatures: boolean;
watermarkEmail?: string; watermarkEmail?: string;
@@ -333,10 +325,15 @@ export function DocumentActionsDropdownn(props: {
&& options.watermarkEmail && { watermarkEmail: options.watermarkEmail }), && options.watermarkEmail && { watermarkEmail: options.watermarkEmail }),
}; };
await exportDocumentVersion({ exportDocumentVersion({
variables: { input }, variables: { input },
onCompleted: (data, errors) => { onCompleted: (data, errors) => {
if (errors?.length) { if (errors?.length) {
toast({
title: __("Error"),
description: errors[0]?.message || __("Failed to generate PDF"),
variant: "error",
});
return; return;
} }
@@ -349,6 +346,9 @@ export function DocumentActionsDropdownn(props: {
window.document.body.removeChild(link); window.document.body.removeChild(link);
} }
}, },
onError(error) {
toast({ title: __("Error"), description: error.message, variant: "error" });
},
}); });
}; };
@@ -356,12 +356,12 @@ export function DocumentActionsDropdownn(props: {
<> <>
<PdfDownloadDialog <PdfDownloadDialog
ref={pdfDownloadDialogRef} ref={pdfDownloadDialogRef}
onDownload={options => void handleExportDocumentVersion(options)} onDownload={handleExportDocumentVersion}
isLoading={isExporting} isLoading={isExporting}
defaultEmail={defaultEmail} defaultEmail={defaultEmail}
/> />
<ActionDropdown variant="secondary"> <ActionDropdown variant="secondary">
{document.canUpdate && !hasDraft && ( {document.canUpdate && isLastVersionPublished && (
<DropdownItem <DropdownItem
onClick={handleCreateDraft} onClick={handleCreateDraft}
icon={IconPencil} icon={IconPencil}
@@ -388,7 +388,7 @@ export function DocumentActionsDropdownn(props: {
> >
{__("Download PDF")} {__("Download PDF")}
</DropdownItem> </DropdownItem>
{document.canArchive && ( {document.canArchive && document.status === "ACTIVE" && (
<DropdownItem <DropdownItem
icon={IconArchive} icon={IconArchive}
disabled={isArchiving} disabled={isArchiving}
@@ -397,7 +397,7 @@ export function DocumentActionsDropdownn(props: {
{__("Archive document")} {__("Archive document")}
</DropdownItem> </DropdownItem>
)} )}
{document.canUnarchive && ( {document.canUnarchive && document.status === "ARCHIVED" && (
<DropdownItem <DropdownItem
icon={IconArchive} icon={IconArchive}
disabled={isUnarchiving} disabled={isUnarchiving}

View File

@@ -21,19 +21,27 @@ import { graphql } from "relay-runtime";
import { z } from "zod"; import { z } from "zod";
import type { DocumentLayoutDrawer_documentFragment$key } from "#/__generated__/core/DocumentLayoutDrawer_documentFragment.graphql"; import type { DocumentLayoutDrawer_documentFragment$key } from "#/__generated__/core/DocumentLayoutDrawer_documentFragment.graphql";
import type { DocumentLayoutDrawer_updateApproversMutation } from "#/__generated__/core/DocumentLayoutDrawer_updateApproversMutation.graphql";
import type { DocumentLayoutDrawer_updateClassificationMutation } from "#/__generated__/core/DocumentLayoutDrawer_updateClassificationMutation.graphql"; import type { DocumentLayoutDrawer_updateClassificationMutation } from "#/__generated__/core/DocumentLayoutDrawer_updateClassificationMutation.graphql";
import type { DocumentLayoutDrawer_versionFragment$key } from "#/__generated__/core/DocumentLayoutDrawer_versionFragment.graphql"; import type { DocumentLayoutDrawer_versionFragment$key } from "#/__generated__/core/DocumentLayoutDrawer_versionFragment.graphql";
import type { DocumentLayoutDrawerMutation } from "#/__generated__/core/DocumentLayoutDrawerMutation.graphql"; import type { DocumentLayoutDrawerMutation } from "#/__generated__/core/DocumentLayoutDrawerMutation.graphql";
import { ControlledField } from "#/components/form/ControlledField"; import { ControlledField } from "#/components/form/ControlledField";
import { DocumentClassificationOptions } from "#/components/form/DocumentClassificationOptions"; import { DocumentClassificationOptions } from "#/components/form/DocumentClassificationOptions";
import { DocumentTypeOptions } from "#/components/form/DocumentTypeOptions"; import { DocumentTypeOptions } from "#/components/form/DocumentTypeOptions";
import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField";
import { useFormWithSchema } from "#/hooks/useFormWithSchema"; import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useOrganizationId } from "#/hooks/useOrganizationId";
const documentFragment = graphql` const documentFragment = graphql`
fragment DocumentLayoutDrawer_documentFragment on Document { fragment DocumentLayoutDrawer_documentFragment on Document {
id id
status status
archivedAt archivedAt
canUpdate: permission(action: "core:document:update") canUpdate: permission(action: "core:document:update")
defaultApprovers {
id
fullName
emailAddress
}
} }
`; `;
@@ -72,6 +80,21 @@ const updateClassificationMutation = graphql`
} }
`; `;
const updateApproversMutation = graphql`
mutation DocumentLayoutDrawer_updateApproversMutation($input: UpdateDocumentInput!) {
updateDocument(input: $input) {
document {
id
defaultApprovers {
id
fullName
emailAddress
}
}
}
}
`;
const schema = z.object({ const schema = z.object({
documentType: z.enum(documentTypes), documentType: z.enum(documentTypes),
}); });
@@ -80,6 +103,10 @@ const classificationSchema = z.object({
classification: z.enum(documentClassifications), classification: z.enum(documentClassifications),
}); });
const approversSchema = z.object({
approverIds: z.array(z.string()),
});
export function DocumentLayoutDrawer(props: { export function DocumentLayoutDrawer(props: {
documentFragmentRef: DocumentLayoutDrawer_documentFragment$key; documentFragmentRef: DocumentLayoutDrawer_documentFragment$key;
versionFragmentRef: DocumentLayoutDrawer_versionFragment$key; versionFragmentRef: DocumentLayoutDrawer_versionFragment$key;
@@ -87,9 +114,11 @@ export function DocumentLayoutDrawer(props: {
const { documentFragmentRef, versionFragmentRef } = props; const { documentFragmentRef, versionFragmentRef } = props;
const { __ } = useTranslate(); const { __ } = useTranslate();
const organizationId = useOrganizationId();
const [isEditingType, setIsEditingType] = useState(false); const [isEditingType, setIsEditingType] = useState(false);
const [isEditingClassification, setIsEditingClassification] = useState(false); const [isEditingClassification, setIsEditingClassification] = useState(false);
const [isEditingApprovers, setIsEditingApprovers] = useState(false);
const { toast } = useToast(); const { toast } = useToast();
const document = useFragment<DocumentLayoutDrawer_documentFragment$key>(documentFragment, documentFragmentRef); const document = useFragment<DocumentLayoutDrawer_documentFragment$key>(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] const [updateDocumentType, isUpdatingDocumentType]
= useMutation<DocumentLayoutDrawerMutation>(updateDocumentTypeMutation); = useMutation<DocumentLayoutDrawerMutation>(updateDocumentTypeMutation);
const [updateClassification, isUpdatingClassification] const [updateClassification, isUpdatingClassification]
= useMutation<DocumentLayoutDrawer_updateClassificationMutation>(updateClassificationMutation); = useMutation<DocumentLayoutDrawer_updateClassificationMutation>(updateClassificationMutation);
const [updateApprovers, isUpdatingApprovers]
= useMutation<DocumentLayoutDrawer_updateApproversMutation>(updateApproversMutation);
const handleUpdateDocumentType = (data: { const handleUpdateDocumentType = (data: {
documentType: (typeof documentTypes)[number]; 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 ( return (
<Drawer> <Drawer>
<div className="text-base text-txt-primary font-medium mb-4"> <div className="text-base text-txt-primary font-medium mb-4">
{__("Properties")} {__("Properties")}
</div> </div>
<PropertyRow label={__("Approvers")}>
{isEditingApprovers
? (
<EditablePropertyContent
onSave={() => void handleApproversSubmit(handleUpdateApprovers)()}
onCancel={() => {
setIsEditingApprovers(false);
resetApprovers({ approverIds: document.defaultApprovers.map(a => a.id) });
}}
disabled={isUpdatingApprovers}
>
<PeopleMultiSelectField
name="approverIds"
control={approversControl}
organizationId={organizationId}
selectedPeople={document.defaultApprovers.map(a => ({
id: a.id,
fullName: a.fullName,
emailAddress: a.emailAddress,
}))}
placeholder={__("Add approvers...")}
/>
</EditablePropertyContent>
)
: (
<ReadOnlyPropertyContent
onEdit={() => setIsEditingApprovers(true)}
canEdit={canEdit}
>
<div className="text-sm text-txt-secondary">
{document.defaultApprovers.length > 0
? document.defaultApprovers.map(a => a.fullName).join(", ")
: __("None")}
</div>
</ReadOnlyPropertyContent>
)}
</PropertyRow>
<PropertyRow label={__("Type")}> <PropertyRow label={__("Type")}>
{isEditingType {isEditingType
? ( ? (
@@ -251,11 +359,11 @@ export function DocumentLayoutDrawer(props: {
</PropertyRow> </PropertyRow>
<PropertyRow label={__("Status")}> <PropertyRow label={__("Status")}>
<Badge <Badge
variant={isDraft ? "highlight" : "success"} variant={version.status === "PUBLISHED" ? "success" : version.status === "PENDING_APPROVAL" ? "warning" : "highlight"}
size="md" size="md"
className="gap-2" className="gap-2"
> >
{isDraft ? __("Draft") : __("Published")} {version.status === "PUBLISHED" ? __("Published") : version.status === "PENDING_APPROVAL" ? __("Pending approval") : __("Draft")}
</Badge> </Badge>
</PropertyRow> </PropertyRow>
<PropertyRow label={__("Version")}> <PropertyRow label={__("Version")}>

View File

@@ -365,15 +365,14 @@ export function DocumentList(props: {
</SortableTh> </SortableTh>
<Th className="w-32">{__("Classification")}</Th> <Th className="w-32">{__("Classification")}</Th>
<Th className="w-60">{__("Approvers")}</Th> <Th className="w-60">{__("Approvers")}</Th>
<Th className="w-60">{__("Last update")}</Th> <Th className="w-40">{__("Last update")}</Th>
<Th className="w-20">{__("Approvals")}</Th>
<Th className="w-20">{__("Signatures")}</Th> <Th className="w-20">{__("Signatures")}</Th>
{hasAnyAction && <Th className="w-18"></Th>} {hasAnyAction && <Th className="w-18"></Th>}
</Tr> </Tr>
) )
: ( : (
<Tr> <Tr>
<Th colspan={hasAnyAction ? 11 : 10} compact> <Th colspan={hasAnyAction ? 10 : 9} compact>
<div className="flex justify-between items-center h-8"> <div className="flex justify-between items-center h-8">
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
{sprintf(__("%s documents selected"), selection.length)} {sprintf(__("%s documents selected"), selection.length)}

View File

@@ -28,6 +28,10 @@ const fragment = graphql`
title title
updatedAt updatedAt
canDelete: permission(action: "core:document:delete") canDelete: permission(action: "core:document:delete")
defaultApprovers {
id
fullName
}
recentVersions: versions(first: 2 orderBy: { field: CREATED_AT direction: DESC }) { recentVersions: versions(first: 2 orderBy: { field: CREATED_AT direction: DESC }) {
edges { edges {
node { node {
@@ -41,15 +45,8 @@ const fragment = graphql`
edges { edges {
node { node {
status status
decisions(first: 20) { decisions(first: 0) {
totalCount totalCount
edges {
node {
approver {
fullName
}
}
}
} }
approvedDecisions: decisions(first: 0 filter: { states: [APPROVED] }) { approvedDecisions: decisions(first: 0 filter: { states: [APPROVED] }) {
totalCount totalCount
@@ -96,29 +93,30 @@ export function DocumentListItem(props: {
} = props; } = props;
const organizationId = useOrganizationId(); const organizationId = useOrganizationId();
const { __ } = useTranslate();
const [deleteDocument] = useMutation<DocumentListItem_deleteMutation>(deleteDocumentMutation);
const confirm = useConfirm();
const document = useFragment<DocumentListItemFragment$key>( const document = useFragment<DocumentListItemFragment$key>(
fragment, fragment,
fragmentRef, 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 = { const statusVariant = {
DRAFT: "neutral", DRAFT: "neutral",
PENDING_APPROVAL: "warning",
PUBLISHED: "success", PUBLISHED: "success",
} as const; } as const;
const statusLabel = { const statusLabel = {
DRAFT: __("Draft"), DRAFT: __("Draft"),
PENDING_APPROVAL: __("Pending approval"),
PUBLISHED: __("Published"), PUBLISHED: __("Published"),
} as const; } as const;
const [deleteDocument] = useMutation<DocumentListItem_deleteMutation>(deleteDocumentMutation);
const confirm = useConfirm();
const handleDelete = () => { const handleDelete = () => {
confirm( confirm(
() => () =>
@@ -172,23 +170,19 @@ export function DocumentListItem(props: {
</Td> </Td>
<Td className="w-60"> <Td className="w-60">
{(() => { {(() => {
const decisions = approverQuorum?.decisions; if (lastVersion.status === "PENDING_APPROVAL") {
if (!decisions?.edges.length) return "—"; const quorum = lastVersion.approvalQuorums?.edges?.[0]?.node;
const names = decisions.edges.map(e => e.node.approver.fullName).join(", "); if (quorum) {
return decisions.totalCount > 20 ? `${names}...` : names; if (quorum.status === "REJECTED") return __("Rejected");
})()} return `${quorum.approvedDecisions.totalCount}/${quorum.decisions.totalCount}`;
</Td> }
<Td className="w-60">{formatDate(document.updatedAt)}</Td> return "—";
<Td className="w-20"> }
{(() => { if (!document.defaultApprovers.length) return "—";
const lastQuorum = lastVersion.approvalQuorums?.edges?.[0]?.node; return document.defaultApprovers.map(a => a.fullName).join(", ");
return lastQuorum
? lastQuorum.status === "REJECTED"
? __("Rejected")
: `${lastQuorum.approvedDecisions.totalCount}/${lastQuorum.decisions.totalCount}`
: "—";
})()} })()}
</Td> </Td>
<Td className="w-40">{formatDate(document.updatedAt)}</Td>
<Td className="w-20"> <Td className="w-20">
{lastVersion.signedSignatures.totalCount} {lastVersion.signedSignatures.totalCount}
/ /

View File

@@ -78,6 +78,11 @@ export function DocumentVersionsDropdownItem(props: {
{__("Draft")} {__("Draft")}
</Badge> </Badge>
)} )}
{version.status === "PENDING_APPROVAL" && (
<Badge variant="warning" size="sm">
{__("Pending approval")}
</Badge>
)}
</div> </div>
<div className="text-xs text-txt-secondary whitespace-nowrap overflow-hidden text-ellipsis"> <div className="text-xs text-txt-secondary whitespace-nowrap overflow-hidden text-ellipsis">
{dateTimeFormat(version.publishedAt ?? version.updatedAt)} {dateTimeFormat(version.publishedAt ?? version.updatedAt)}

View File

@@ -21,12 +21,11 @@ import {
DialogFooter, DialogFooter,
IconSend, IconSend,
IconUpload, IconUpload,
IconWarning,
Textarea, Textarea,
useDialogRef, useDialogRef,
useToast, useToast,
} from "@probo/ui"; } 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 { useFragment, useMutation } from "react-relay";
import { graphql } from "relay-runtime"; import { graphql } from "relay-runtime";
import { z } from "zod"; import { z } from "zod";
@@ -43,38 +42,19 @@ export type PublishDialogRef = {
open: () => void; open: () => void;
}; };
type Props = { type PublishDialogProps = {
ref: Ref<PublishDialogRef>; ref: Ref<PublishDialogRef>;
documentId: string; documentId: string;
documentFragmentRef: PublishDialog_documentFragment$key; documentFragmentRef: PublishDialog_documentFragment$key;
hasPendingApproval: boolean;
onSuccess: () => void; onSuccess: () => void;
}; };
const documentFragment = graphql` const documentFragment = graphql`
fragment PublishDialog_documentFragment on Document { fragment PublishDialog_documentFragment on Document {
lastPublishedVersion: versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }, filter: { statuses: [PUBLISHED] }) { defaultApprovers {
edges {
node {
approvalQuorums(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
edges {
node {
decisions(first: 100) {
edges {
node {
approver {
id id
} }
} }
}
}
}
}
}
}
}
}
}
`; `;
const publishMajorMutation = graphql` const publishMajorMutation = graphql`
@@ -147,24 +127,20 @@ export function PublishDialog({
ref, ref,
documentId, documentId,
documentFragmentRef, documentFragmentRef,
hasPendingApproval,
onSuccess, onSuccess,
}: Props) { }: PublishDialogProps) {
const document = useFragment(documentFragment, documentFragmentRef); const document = useFragment(documentFragment, documentFragmentRef);
const { __ } = useTranslate(); const { __ } = useTranslate();
const { toast } = useToast(); const { toast } = useToast();
const organizationId = useOrganizationId(); const organizationId = useOrganizationId();
const dialogRef = useDialogRef(); const dialogRef = useDialogRef();
const previousApproverIds = document.lastPublishedVersion.edges[0] const publishSchema = useMemo(() => z.object({
?.node.approvalQuorums.edges[0]
?.node.decisions?.edges.map(e => e.node.approver.id)
?? [];
const schema = z.object({
changelog: z.string().min(1, __("Changelog is required")), changelog: z.string().min(1, __("Changelog is required")),
approverIds: z.array(z.string()), approverIds: z.array(z.string()),
}); }), [__]);
const defaultApproverIds = document.defaultApprovers.map(a => a.id);
const { const {
control, control,
@@ -173,7 +149,7 @@ export function PublishDialog({
reset, reset,
watch, watch,
formState: { errors }, formState: { errors },
} = useFormWithSchema(schema, { } = useFormWithSchema(publishSchema, {
defaultValues: { defaultValues: {
changelog: "", changelog: "",
approverIds: [], approverIds: [],
@@ -184,7 +160,7 @@ export function PublishDialog({
open: () => { open: () => {
reset({ reset({
changelog: "", changelog: "",
approverIds: previousApproverIds, approverIds: defaultApproverIds,
}); });
dialogRef.current?.open(); dialogRef.current?.open();
}, },
@@ -199,6 +175,7 @@ export function PublishDialog({
const isBusy = isPublishingMajor || isPublishingMinor || isRequesting; const isBusy = isPublishingMajor || isPublishingMinor || isRequesting;
const approverIds = watch("approverIds"); const approverIds = watch("approverIds");
const hasApprovers = approverIds.length > 0;
const actionRef = useRef<"publish" | "publish-minor" | "request-approval">("publish"); const actionRef = useRef<"publish" | "publish-minor" | "request-approval">("publish");
const onPublishCompleted = (_: unknown, errors: ReadonlyArray<{ message: string }> | null) => { const onPublishCompleted = (_: unknown, errors: ReadonlyArray<{ message: string }> | null) => {
@@ -223,7 +200,7 @@ export function PublishDialog({
toast({ title: __("Error"), description: error.message, variant: "error" }); toast({ title: __("Error"), description: error.message, variant: "error" });
}; };
const handlePublishMajor = (data: z.infer<typeof schema>) => { const handlePublishMajor = (data: z.infer<typeof publishSchema>) => {
publishMajor({ publishMajor({
variables: { input: { documentId, changelog: data.changelog } }, variables: { input: { documentId, changelog: data.changelog } },
onCompleted: onPublishCompleted, onCompleted: onPublishCompleted,
@@ -231,7 +208,7 @@ export function PublishDialog({
}); });
}; };
const handlePublishMinor = (data: z.infer<typeof schema>) => { const handlePublishMinor = (data: z.infer<typeof publishSchema>) => {
publishMinor({ publishMinor({
variables: { input: { documentId, changelog: data.changelog } }, variables: { input: { documentId, changelog: data.changelog } },
onCompleted: onPublishCompleted, onCompleted: onPublishCompleted,
@@ -239,7 +216,7 @@ export function PublishDialog({
}); });
}; };
const onRequestApproval = (data: z.infer<typeof schema>) => { const onRequestApproval = (data: z.infer<typeof publishSchema>) => {
requestApproval({ requestApproval({
variables: { variables: {
input: { input: {
@@ -276,12 +253,16 @@ export function PublishDialog({
<Dialog className="max-w-xl" ref={dialogRef} title={__("Publish document")}> <Dialog className="max-w-xl" ref={dialogRef} title={__("Publish document")}>
<form <form
onSubmit={e => void handleSubmit((data) => { onSubmit={e => void handleSubmit((data) => {
if (actionRef.current === "publish") { const action = actionRef.current;
handlePublishMajor(data); actionRef.current = "publish";
} else if (actionRef.current === "publish-minor") { if (action === "publish-minor") {
handlePublishMinor(data); handlePublishMinor(data);
} else { } else if (action === "request-approval") {
onRequestApproval(data); onRequestApproval(data);
} else if (data.approverIds.length > 0) {
onRequestApproval(data);
} else {
handlePublishMajor(data);
} }
})(e)} })(e)}
> >
@@ -303,22 +284,9 @@ export function PublishDialog({
<p className="text-xs text-txt-danger mt-1">{errors.changelog.message}</p> <p className="text-xs text-txt-danger mt-1">{errors.changelog.message}</p>
)} )}
</div> </div>
{hasPendingApproval
? (
<div className="flex items-start gap-2 rounded-lg bg-bg-warning/10 border border-border-warning p-3">
<IconWarning size={16} className="text-txt-warning shrink-0 mt-0.5" />
<p className="text-sm text-txt-warning">
{__("An approval review is currently in progress. Publishing now will bypass the pending approval.")}
</p>
</div>
)
: (
<div> <div>
<div className="text-sm font-medium text-txt-primary mb-1">
{__("Request approval before publishing")}
</div>
<p className="text-xs text-txt-secondary mb-3"> <p className="text-xs text-txt-secondary mb-3">
{__("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.")}
</p> </p>
<PeopleMultiSelectField <PeopleMultiSelectField
name="approverIds" name="approverIds"
@@ -328,10 +296,12 @@ export function PublishDialog({
placeholder={__("Add approvers...")} placeholder={__("Add approvers...")}
/> />
</div> </div>
)}
</div> </div>
</DialogContent> </DialogContent>
<DialogFooter> <DialogFooter>
{hasApprovers
? (
<>
<Button <Button
type="submit" type="submit"
variant="secondary" variant="secondary"
@@ -341,24 +311,36 @@ export function PublishDialog({
> >
{__("Publish as minor")} {__("Publish as minor")}
</Button> </Button>
<Button
type="submit"
icon={IconSend}
onClick={() => { actionRef.current = "request-approval"; }}
disabled={isBusy}
>
{__("Request approval")}
</Button>
</>
)
: (
<>
<Button <Button
type="submit" type="submit"
variant="secondary" variant="secondary"
icon={IconUpload} icon={IconUpload}
onClick={() => { actionRef.current = "publish-minor"; }}
disabled={isBusy}
>
{__("Publish as minor")}
</Button>
<Button
type="submit"
icon={IconUpload}
onClick={() => { actionRef.current = "publish"; }} onClick={() => { actionRef.current = "publish"; }}
disabled={isBusy} disabled={isBusy}
> >
{__("Publish now")} {__("Publish as major")}
</Button>
{!hasPendingApproval && (
<Button
type="submit"
icon={IconSend}
onClick={() => { actionRef.current = "request-approval"; }}
disabled={isBusy || approverIds.length === 0}
>
{__("Request approval")}
</Button> </Button>
</>
)} )}
</DialogFooter> </DialogFooter>
</form> </form>

View File

@@ -156,9 +156,10 @@ export function PublishDocumentsDialog({
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-start gap-2 rounded-lg bg-bg-warning/10 border border-border-warning p-3"> <div className="flex items-start gap-2 rounded-lg bg-bg-warning/10 border border-border-warning p-3">
<IconWarning size={16} className="text-txt-warning shrink-0 mt-0.5" /> <IconWarning size={16} className="text-txt-warning shrink-0 mt-0.5" />
<p className="text-sm text-txt-warning"> <div className="text-sm text-txt-warning space-y-1">
{__("This will publish the selected documents directly without requiring approval.")} <p>{__("Publishing as major will request approval for documents that have default approvers configured. Approvers will receive an email notification.")}</p>
</p> <p>{__("Documents already published and pending approval will be skipped.")}</p>
</div>
</div> </div>
<div> <div>
<label htmlFor="changelog" className="text-sm font-medium text-txt-primary mb-1 block"> <label htmlFor="changelog" className="text-sm font-medium text-txt-primary mb-1 block">

View File

@@ -81,9 +81,8 @@ const versionFragment = graphql`
export function DocumentApprovalsPage(props: { export function DocumentApprovalsPage(props: {
queryRef: PreloadedQuery<DocumentApprovalsPageQuery>; queryRef: PreloadedQuery<DocumentApprovalsPageQuery>;
onRefetch: () => void;
}) { }) {
const { queryRef, onRefetch } = props; const { queryRef } = props;
const { document, version } = usePreloadedQuery<DocumentApprovalsPageQuery>( const { document, version } = usePreloadedQuery<DocumentApprovalsPageQuery>(
documentApprovalsPageQuery, documentApprovalsPageQuery,
@@ -109,7 +108,6 @@ export function DocumentApprovalsPage(props: {
<DocumentApprovalsPageContent <DocumentApprovalsPageContent
approvalListRef={approvalListRef} approvalListRef={approvalListRef}
versionFragmentRef={versionFragmentRef} versionFragmentRef={versionFragmentRef}
onRefetch={onRefetch}
/> />
</Suspense> </Suspense>
); );
@@ -118,9 +116,8 @@ export function DocumentApprovalsPage(props: {
function DocumentApprovalsPageContent(props: { function DocumentApprovalsPageContent(props: {
approvalListRef: Parameters<typeof DocumentApprovalList>[0]["versionFragmentRef"]; approvalListRef: Parameters<typeof DocumentApprovalList>[0]["versionFragmentRef"];
versionFragmentRef: DocumentApprovalsPage_versionFragment$key; versionFragmentRef: DocumentApprovalsPage_versionFragment$key;
onRefetch: () => void;
}) { }) {
const { approvalListRef, versionFragmentRef, onRefetch } = props; const { approvalListRef, versionFragmentRef } = props;
const { __, dateTimeFormat } = useTranslate(); const { __, dateTimeFormat } = useTranslate();
const versionData = useFragment(versionFragment, versionFragmentRef); const versionData = useFragment(versionFragment, versionFragmentRef);
@@ -129,7 +126,7 @@ function DocumentApprovalsPageContent(props: {
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<DocumentApprovalList versionFragmentRef={approvalListRef} onRefetch={onRefetch} /> <DocumentApprovalList versionFragmentRef={approvalListRef} />
{pastQuorums.length > 0 && ( {pastQuorums.length > 0 && (
<div className="space-y-4"> <div className="space-y-4">
@@ -137,8 +134,8 @@ function DocumentApprovalsPageContent(props: {
{pastQuorums.map(({ node: quorum }) => ( {pastQuorums.map(({ node: quorum }) => (
<div key={quorum.id} className="border border-border-solid rounded-lg p-4"> <div key={quorum.id} className="border border-border-solid rounded-lg p-4">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<Badge variant={quorum.status === "APPROVED" ? "success" : "danger"}> <Badge variant={quorum.status === "APPROVED" ? "success" : quorum.status === "VOIDED" ? "neutral" : "danger"}>
{quorum.status === "APPROVED" ? __("Approved") : __("Rejected")} {quorum.status === "APPROVED" ? __("Approved") : quorum.status === "VOIDED" ? __("Voided") : __("Rejected")}
</Badge> </Badge>
<span className="text-xs text-txt-secondary"> <span className="text-xs text-txt-secondary">
{dateTimeFormat(quorum.createdAt)} {dateTimeFormat(quorum.createdAt)}
@@ -159,8 +156,8 @@ function DocumentApprovalsPageContent(props: {
)} )}
</div> </div>
<div className="ml-auto"> <div className="ml-auto">
<Badge variant={decision.state === "APPROVED" ? "success" : decision.state === "REJECTED" ? "danger" : "warning"}> <Badge variant={decision.state === "APPROVED" ? "success" : decision.state === "REJECTED" ? "danger" : decision.state === "VOIDED" ? "neutral" : "warning"}>
{decision.state === "APPROVED" ? __("Approved") : decision.state === "REJECTED" ? __("Rejected") : __("Pending")} {decision.state === "APPROVED" ? __("Approved") : decision.state === "REJECTED" ? __("Rejected") : decision.state === "VOIDED" ? __("Voided") : __("Pending")}
</Badge> </Badge>
</div> </div>
</div> </div>

View File

@@ -27,9 +27,8 @@ function DocumentApprovalsPageQueryLoader() {
throw new Error(":documentId missing in route params"); throw new Error(":documentId missing in route params");
} }
const { onRefetch: parentRefetch, approvalRequestedAt } const { approvalRequestedAt }
= useOutletContext<{ = useOutletContext<{
onRefetch: () => void;
approvalRequestedAt?: number; approvalRequestedAt?: number;
}>(); }>();
@@ -48,16 +47,9 @@ function DocumentApprovalsPageQueryLoader() {
useEffect(() => { useEffect(() => {
if (!queryRef) { if (!queryRef) {
loadQuery( loadQueryParams();
{
documentId: documentId,
versionId: versionId ?? "",
versionSpecified: !!versionId,
},
{ fetchPolicy: "network-only" },
);
} }
}, [queryRef, documentId, versionId, loadQuery]); }, [queryRef, loadQueryParams]);
// Reload approvals data whenever a new approval round is requested from the layout // Reload approvals data whenever a new approval round is requested from the layout
useEffect(() => { useEffect(() => {
@@ -66,16 +58,11 @@ function DocumentApprovalsPageQueryLoader() {
} }
}, [approvalRequestedAt, loadQueryParams]); }, [approvalRequestedAt, loadQueryParams]);
const onRefetch = useCallback(() => {
parentRefetch();
loadQueryParams();
}, [parentRefetch, loadQueryParams]);
if (!queryRef) { if (!queryRef) {
return <LinkCardSkeleton />; return <LinkCardSkeleton />;
} }
return <DocumentApprovalsPage queryRef={queryRef} onRefetch={onRefetch} />; return <DocumentApprovalsPage queryRef={queryRef} />;
} }
export default function DocumentApprovalsPageLoader() { export default function DocumentApprovalsPageLoader() {

View File

@@ -13,42 +13,27 @@
// PERFORMANCE OF THIS SOFTWARE. // PERFORMANCE OF THIS SOFTWARE.
import { useTranslate } from "@probo/i18n"; import { useTranslate } from "@probo/i18n";
import { import { Badge, Button, IconCrossLargeX, useConfirm } from "@probo/ui";
Button,
Dialog,
DialogContent,
DialogFooter,
IconPlusSmall,
useDialogRef,
useToast,
} from "@probo/ui";
import { Suspense, useState } from "react";
import { useFragment, useMutation } from "react-relay"; import { useFragment, useMutation } from "react-relay";
import { graphql } from "relay-runtime"; import { graphql } from "relay-runtime";
import type { DocumentApprovalList_addApproverMutation } from "#/__generated__/core/DocumentApprovalList_addApproverMutation.graphql";
import type { DocumentApprovalList_versionFragment$key } from "#/__generated__/core/DocumentApprovalList_versionFragment.graphql"; import type { DocumentApprovalList_versionFragment$key } from "#/__generated__/core/DocumentApprovalList_versionFragment.graphql";
import { usePeople } from "#/hooks/graph/PeopleGraph"; import type { DocumentApprovalList_voidMutation } from "#/__generated__/core/DocumentApprovalList_voidMutation.graphql";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { DocumentApprovalListItem } from "./DocumentApprovalListItem"; import { DocumentApprovalListItem } from "./DocumentApprovalListItem";
const versionFragment = graphql` const versionFragment = graphql`
fragment DocumentApprovalList_versionFragment on DocumentVersion { fragment DocumentApprovalList_versionFragment on DocumentVersion {
id id
canAddApprover: permission(action: "core:document-version:add-approver")
approvalQuorums(first: 100, orderBy: { field: CREATED_AT, direction: DESC }) { approvalQuorums(first: 100, orderBy: { field: CREATED_AT, direction: DESC }) {
edges { edges {
node { node {
status
decisions(first: 100, orderBy: { field: CREATED_AT, direction: ASC }) decisions(first: 100, orderBy: { field: CREATED_AT, direction: ASC })
@connection(key: "DocumentApprovalList_decisions") { @connection(key: "DocumentApprovalList_decisions") {
__id
edges { edges {
node { node {
id id
approver {
id
}
...DocumentApprovalListItemFragment ...DocumentApprovalListItemFragment
} }
} }
@@ -59,20 +44,21 @@ const versionFragment = graphql`
} }
`; `;
const addApproverMutation = graphql` const voidMutation = graphql`
mutation DocumentApprovalList_addApproverMutation( mutation DocumentApprovalList_voidMutation(
$input: AddDocumentVersionApproverInput! $input: VoidDocumentVersionApprovalInput!
$connections: [ID!]!
) { ) {
addDocumentVersionApprover(input: $input) { voidDocumentVersionApproval(input: $input) {
approvalDecisionEdge @appendEdge(connections: $connections) { documentVersion {
node {
id id
approver { status
major
minor
...DocumentApprovalList_versionFragment
}
approvalQuorum {
id id
} status
...DocumentApprovalListItemFragment
}
} }
} }
} }
@@ -80,33 +66,82 @@ const addApproverMutation = graphql`
export function DocumentApprovalList(props: { export function DocumentApprovalList(props: {
versionFragmentRef: DocumentApprovalList_versionFragment$key; versionFragmentRef: DocumentApprovalList_versionFragment$key;
onRefetch: () => void;
}) { }) {
const { versionFragmentRef, onRefetch } = props; const { versionFragmentRef } = props;
const { __ } = useTranslate(); const { __ } = useTranslate();
const version = useFragment(versionFragment, versionFragmentRef); const version = useFragment(versionFragment, versionFragmentRef);
const dialogRef = useDialogRef();
const canManage = version.canAddApprover;
const lastQuorum = version.approvalQuorums?.edges?.[0]?.node ?? null; const lastQuorum = version.approvalQuorums?.edges?.[0]?.node ?? null;
const decisions = lastQuorum?.decisions; const isPending = lastQuorum?.status === "PENDING";
const edges = decisions?.edges ?? []; const edges = lastQuorum?.decisions?.edges ?? [];
const existingApproverIds = edges.map(({ node }) => node.approver.id);
const [voidApproval, isVoiding]
= useMutation<DocumentApprovalList_voidMutation>(voidMutation);
const confirm = useConfirm();
const handleVoid = () => {
confirm(
() =>
new Promise<void>((resolve, reject) => {
voidApproval({
variables: {
input: { documentVersionId: version.id },
},
onCompleted: (_, errors) => {
if (errors?.length) {
reject(new Error(errors[0].message));
} else {
resolve();
}
},
onError: err => reject(err),
});
}),
{
message: __(
"This will void the current approval request and return the version to draft. This action cannot be undone.",
),
label: __("Void approval"),
variant: "danger",
},
);
};
const statusVariant = {
PENDING: "warning",
APPROVED: "success",
REJECTED: "danger",
VOIDED: "neutral",
} as const;
const statusLabel = {
PENDING: __("Pending"),
APPROVED: __("Approved"),
REJECTED: __("Rejected"),
VOIDED: __("Voided"),
} as const;
return ( return (
<div> <div>
{canManage && ( {lastQuorum && (
<div className="flex justify-end pb-3"> <div className="flex items-center justify-between mb-4">
<Badge variant={statusVariant[lastQuorum.status]}>
{statusLabel[lastQuorum.status]}
</Badge>
{isPending && (
<Button <Button
variant="secondary" variant="quaternary"
icon={IconPlusSmall} icon={IconCrossLargeX}
onClick={() => dialogRef.current?.open()} onClick={handleVoid}
disabled={isVoiding}
> >
{__("Add approver")} {__("Cancel")}
</Button> </Button>
)}
</div> </div>
)} )}
{edges.length === 0 {edges.length === 0
? ( ? (
<div className="text-sm text-txt-secondary text-center py-8"> <div className="text-sm text-txt-secondary text-center py-8">
@@ -119,108 +154,10 @@ export function DocumentApprovalList(props: {
<DocumentApprovalListItem <DocumentApprovalListItem
key={node.id} key={node.id}
fragmentRef={node} fragmentRef={node}
canManage={canManage}
connectionId={decisions?.__id ?? ""}
onRefetch={onRefetch}
/> />
))} ))}
</div> </div>
)} )}
<Dialog ref={dialogRef} title={__("Add approver")}>
<Suspense fallback={<DialogContent padded>{__("Loading...")}</DialogContent>}>
<AddApproverDialogContent
documentVersionId={version.id}
existingApproverIds={existingApproverIds}
connectionId={decisions?.__id ?? ""}
onSuccess={() => {
dialogRef.current?.close();
onRefetch();
}}
/>
</Suspense>
</Dialog>
</div> </div>
); );
} }
function AddApproverDialogContent(props: {
documentVersionId: string;
existingApproverIds: string[];
connectionId: string;
onSuccess: () => void;
}) {
const { documentVersionId, existingApproverIds, connectionId, onSuccess } = props;
const { __ } = useTranslate();
const { toast } = useToast();
const organizationId = useOrganizationId();
const allPeople = usePeople(organizationId, { excludeContractEnded: true });
const people = allPeople.filter(p => !existingApproverIds.includes(p.id));
const [selectedId, setSelectedId] = useState("");
const [addApprover, isAdding] = useMutation<DocumentApprovalList_addApproverMutation>(
addApproverMutation,
);
return (
<form
onSubmit={(e) => {
e.preventDefault();
if (!selectedId) return;
void addApprover({
variables: {
input: {
documentVersionId,
approverId: selectedId,
},
connections: [connectionId],
},
onCompleted: (_data, errors) => {
if (errors?.length) {
toast({
title: __("Error"),
description: errors[0].message,
variant: "error",
});
return;
}
toast({
title: __("Approver added"),
description: __("The approver has been added successfully."),
variant: "success",
});
onSuccess();
},
onError: (error) => {
toast({
title: __("Error"),
description: error.message,
variant: "error",
});
},
});
}}
>
<DialogContent padded>
<label htmlFor="add-approver-select" className="block text-sm font-medium mb-1">{__("Approver")}</label>
<select
id="add-approver-select"
className="w-full rounded-md border border-border-solid bg-bg-primary px-3 py-2 text-sm"
value={selectedId}
onChange={e => setSelectedId(e.target.value)}
>
<option value="">{__("Select a person...")}</option>
{people.map(p => (
<option key={p.id} value={p.id}>
{p.fullName}
</option>
))}
</select>
</DialogContent>
<DialogFooter>
<Button type="submit" disabled={!selectedId || isAdding}>
{__("Add approver")}
</Button>
</DialogFooter>
</form>
);
}

View File

@@ -20,14 +20,10 @@ import {
IconCircleCheck, IconCircleCheck,
IconCircleX, IconCircleX,
IconClock, IconClock,
IconTrashCan,
Spinner,
useToast,
} from "@probo/ui"; } from "@probo/ui";
import { useFragment, useMutation } from "react-relay"; import { useFragment } from "react-relay";
import { graphql } from "relay-runtime"; import { graphql } from "relay-runtime";
import type { DocumentApprovalListItem_removeApproverMutation } from "#/__generated__/core/DocumentApprovalListItem_removeApproverMutation.graphql";
import type { DocumentApprovalListItemFragment$key } from "#/__generated__/core/DocumentApprovalListItemFragment.graphql"; import type { DocumentApprovalListItemFragment$key } from "#/__generated__/core/DocumentApprovalListItemFragment.graphql";
import { useOrganizationId } from "#/hooks/useOrganizationId"; import { useOrganizationId } from "#/hooks/useOrganizationId";
@@ -52,43 +48,11 @@ const fragment = graphql`
} }
`; `;
const removeApproverMutation = graphql`
mutation DocumentApprovalListItem_removeApproverMutation(
$input: RemoveDocumentVersionApproverInput!
$connections: [ID!]!
) {
removeDocumentVersionApprover(input: $input) {
deletedApprovalDecisionId @deleteEdge(connections: $connections)
documentVersion {
id
approvalQuorums(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
edges {
node {
id
status
decisions(first: 0) {
totalCount
}
approvedDecisions: decisions(first: 0 filter: { states: [APPROVED] }) {
totalCount
}
}
}
}
}
}
}
`;
export function DocumentApprovalListItem(props: { export function DocumentApprovalListItem(props: {
fragmentRef: DocumentApprovalListItemFragment$key; fragmentRef: DocumentApprovalListItemFragment$key;
canManage: boolean;
connectionId: string;
onRefetch: () => void;
}) { }) {
const { fragmentRef, canManage, connectionId, onRefetch } = props; const { fragmentRef } = props;
const { __, dateTimeFormat } = useTranslate(); const { __, dateTimeFormat } = useTranslate();
const { toast } = useToast();
const organizationId = useOrganizationId(); const organizationId = useOrganizationId();
const decision = useFragment(fragment, fragmentRef); const decision = useFragment(fragment, fragmentRef);
@@ -96,10 +60,7 @@ export function DocumentApprovalListItem(props: {
const isPending = decision.state === "PENDING"; const isPending = decision.state === "PENDING";
const isApproved = decision.state === "APPROVED"; const isApproved = decision.state === "APPROVED";
const isRejected = decision.state === "REJECTED"; const isRejected = decision.state === "REJECTED";
const isVoided = decision.state === "VOIDED";
const [removeApprover, isRemoving] = useMutation<DocumentApprovalListItem_removeApproverMutation>(
removeApproverMutation,
);
const reviewUrl = `/organizations/${organizationId}/employee/approvals/${decision.documentVersion.document.id}`; const reviewUrl = `/organizations/${organizationId}/employee/approvals/${decision.documentVersion.document.id}`;
@@ -113,10 +74,12 @@ export function DocumentApprovalListItem(props: {
{isApproved && <IconCircleCheck size={16} className="text-txt-accent" />} {isApproved && <IconCircleCheck size={16} className="text-txt-accent" />}
{isRejected && <IconCircleX size={16} className="text-txt-danger" />} {isRejected && <IconCircleX size={16} className="text-txt-danger" />}
{isPending && <IconClock size={16} />} {isPending && <IconClock size={16} />}
{isVoided && <IconClock size={16} className="text-txt-secondary" />}
<span> <span>
{isPending && sprintf(__("Requested on %s"), dateTimeFormat(decision.createdAt))} {isPending && sprintf(__("Requested on %s"), dateTimeFormat(decision.createdAt))}
{isApproved && sprintf(__("Approved on %s"), dateTimeFormat(decision.decidedAt))} {isApproved && sprintf(__("Approved on %s"), dateTimeFormat(decision.decidedAt))}
{isRejected && sprintf(__("Rejected on %s"), dateTimeFormat(decision.decidedAt))} {isRejected && sprintf(__("Rejected on %s"), dateTimeFormat(decision.decidedAt))}
{isVoided && sprintf(__("Requested on %s"), dateTimeFormat(decision.createdAt))}
</span> </span>
</div> </div>
{decision.comment && ( {decision.comment && (
@@ -137,49 +100,12 @@ export function DocumentApprovalListItem(props: {
{__("Review")} {__("Review")}
</Button> </Button>
)} )}
{canManage && ( {isPending && !decision.canApprove && !decision.canReject && (
<Button
variant="quaternary"
icon={isRemoving ? Spinner : IconTrashCan}
disabled={isRemoving}
onClick={() => {
void removeApprover({
variables: {
input: {
approvalDecisionId: decision.id,
},
connections: [connectionId],
},
onCompleted: (_data, errors) => {
if (errors?.length) {
toast({
title: __("Error"),
description: errors[0].message,
variant: "error",
});
return;
}
toast({
title: __("Approver removed"),
description: __("The approver has been removed successfully."),
variant: "success",
});
onRefetch();
},
onError: (error) => {
toast({
title: __("Error"),
description: error.message,
variant: "error",
});
},
});
}}
/>
)}
{isPending && !decision.canApprove && !decision.canReject && !canManage && (
<Badge variant="warning">{__("Pending")}</Badge> <Badge variant="warning">{__("Pending")}</Badge>
)} )}
{isVoided && (
<Badge variant="neutral">{__("Voided")}</Badge>
)}
</div> </div>
</div> </div>
); );

View File

@@ -12,7 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE. // PERFORMANCE OF THIS SOFTWARE.
import { formatError, type GraphQLError } from "@probo/helpers"; import { formatDate, formatError, type GraphQLError } from "@probo/helpers";
import { usePageTitle } from "@probo/hooks"; import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n"; import { useTranslate } from "@probo/i18n";
import { import {
@@ -173,6 +173,7 @@ function VersionRow({
const state = approvalDecision?.state; const state = approvalDecision?.state;
const isApproved = state === "APPROVED"; const isApproved = state === "APPROVED";
const isRejected = state === "REJECTED"; const isRejected = state === "REJECTED";
const isVoided = state === "VOIDED";
return ( return (
<div <div
@@ -189,6 +190,8 @@ function VersionRow({
? <IconCircleCheck size={20} className="text-txt-success" /> ? <IconCircleCheck size={20} className="text-txt-success" />
: isRejected : isRejected
? <IconCircleX size={20} className="text-txt-danger" /> ? <IconCircleX size={20} className="text-txt-danger" />
: isVoided
? <IconRadioUnchecked size={20} className="text-txt-secondary" />
: <IconRadioUnchecked size={20} className="text-txt-tertiary" />} : <IconRadioUnchecked size={20} className="text-txt-tertiary" />}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -199,13 +202,7 @@ function VersionRow({
)} )}
> >
{versionData.publishedAt {versionData.publishedAt
? `v${versionData.major}.${versionData.minor} - ${(() => { ? `v${versionData.major}.${versionData.minor} - ${formatDate(versionData.publishedAt)}`
const date = new Date(versionData.publishedAt);
const day = String(date.getDate()).padStart(2, "0");
const month = String(date.getMonth() + 1).padStart(2, "0");
const year = date.getFullYear();
return `${day}/${month}/${year}`;
})()}`
: `v${versionData.major}.${versionData.minor}`} : `v${versionData.major}.${versionData.minor}`}
</p> </p>
</div> </div>
@@ -214,6 +211,8 @@ function VersionRow({
? <Badge variant="success">{__("Approved")}</Badge> ? <Badge variant="success">{__("Approved")}</Badge>
: isRejected : isRejected
? <Badge variant="danger">{__("Rejected")}</Badge> ? <Badge variant="danger">{__("Rejected")}</Badge>
: isVoided
? <Badge variant="neutral">{__("Voided")}</Badge>
: isSelected : isSelected
? <Badge variant="info">{__("In review")}</Badge> ? <Badge variant="info">{__("In review")}</Badge>
: <Badge variant="warning">{__("Pending")}</Badge>} : <Badge variant="warning">{__("Pending")}</Badge>}
@@ -245,6 +244,20 @@ function ViewerDecision(props: {
const isPending = decision.state === "PENDING"; const isPending = decision.state === "PENDING";
const isApproved = decision.state === "APPROVED"; const isApproved = decision.state === "APPROVED";
const isRejected = decision.state === "REJECTED"; const isRejected = decision.state === "REJECTED";
const isVoided = decision.state === "VOIDED";
if (isVoided) {
return (
<>
<div className="flex items-center gap-2 text-sm text-txt-secondary mb-4">
<span>{__("Your approval is no longer required for this version.")}</span>
</div>
<Button onClick={onBack} className="h-10 w-full" variant="secondary">
{__("Back to Documents")}
</Button>
</>
);
}
if (!decision.canApprove && !decision.canReject) { if (!decision.canApprove && !decision.canReject) {
return ( return (
@@ -484,10 +497,7 @@ function DocumentApproveContent({
}, [selectedVersion?.id, exportPDF, toast, __]); }, [selectedVersion?.id, exportPDF, toast, __]);
return ( return (
<div <div className="fixed inset-0 top-12 bg-level-2 flex flex-col">
className="fixed bg-level-2 flex flex-col"
style={{ top: "3rem", left: 0, right: 0, bottom: 0 }}
>
<div className="grid lg:grid-cols-2 min-h-0 h-full"> <div className="grid lg:grid-cols-2 min-h-0 h-full">
<div className="w-full lg:w-[440px] mx-auto py-20 overflow-y-auto scrollbar-hide"> <div className="w-full lg:w-[440px] mx-auto py-20 overflow-y-auto scrollbar-hide">
<h1 className="text-2xl font-semibold mb-6"> <h1 className="text-2xl font-semibold mb-6">

View File

@@ -18,7 +18,6 @@ import { Card, Tbody, Th, Thead, Tr } from "@probo/ui";
import { graphql, type PreloadedQuery, usePreloadedQuery } from "react-relay"; import { graphql, type PreloadedQuery, usePreloadedQuery } from "react-relay";
import type { EmployeeApprovalsPageQuery } from "#/__generated__/core/EmployeeApprovalsPageQuery.graphql"; import type { EmployeeApprovalsPageQuery } from "#/__generated__/core/EmployeeApprovalsPageQuery.graphql";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { ApprovableDocumentRow } from "./_components/ApprovableDocumentRow"; import { ApprovableDocumentRow } from "./_components/ApprovableDocumentRow";
@@ -46,7 +45,6 @@ export function EmployeeApprovalsPage(props: {
}) { }) {
const { queryRef } = props; const { queryRef } = props;
const { __ } = useTranslate(); const { __ } = useTranslate();
const organizationId = useOrganizationId();
const { const {
viewer: { approvableDocuments }, viewer: { approvableDocuments },
@@ -79,7 +77,6 @@ export function EmployeeApprovalsPage(props: {
<ApprovableDocumentRow <ApprovableDocumentRow
key={document.id} key={document.id}
fKey={document} fKey={document}
organizationId={organizationId}
/> />
))} ))}
</Tbody> </Tbody>

View File

@@ -22,6 +22,7 @@ import { Badge, Td, Tr } from "@probo/ui";
import { graphql, useFragment } from "react-relay"; import { graphql, useFragment } from "react-relay";
import type { ApprovableDocumentRowFragment$key } from "#/__generated__/core/ApprovableDocumentRowFragment.graphql"; import type { ApprovableDocumentRowFragment$key } from "#/__generated__/core/ApprovableDocumentRowFragment.graphql";
import { useOrganizationId } from "#/hooks/useOrganizationId";
const fragment = graphql` const fragment = graphql`
fragment ApprovableDocumentRowFragment on EmployeeDocument { fragment ApprovableDocumentRowFragment on EmployeeDocument {
@@ -42,25 +43,31 @@ const fragment = graphql`
export function ApprovableDocumentRow({ export function ApprovableDocumentRow({
fKey, fKey,
organizationId,
}: { }: {
fKey: ApprovableDocumentRowFragment$key; fKey: ApprovableDocumentRowFragment$key;
organizationId: string;
}) { }) {
const document = useFragment<ApprovableDocumentRowFragment$key>(fragment, fKey); const organizationId = useOrganizationId();
const lastVersion = document.lastVersion.edges[0].node;
const { __ } = useTranslate(); const { __ } = useTranslate();
const document = useFragment<ApprovableDocumentRowFragment$key>(fragment, fKey);
const lastVersionEdge = document.lastVersion.edges[0];
if (!lastVersionEdge) return null;
const lastVersion = lastVersionEdge.node;
const stateVariant = document.approvalState === "APPROVED" const stateVariant = document.approvalState === "APPROVED"
? "success" ? "success"
: document.approvalState === "REJECTED" : document.approvalState === "REJECTED"
? "danger" ? "danger"
: document.approvalState === "VOIDED"
? "neutral"
: "warning"; : "warning";
const stateLabel = document.approvalState === "APPROVED" const stateLabel = document.approvalState === "APPROVED"
? __("Approved") ? __("Approved")
: document.approvalState === "REJECTED" : document.approvalState === "REJECTED"
? __("Rejected") ? __("Rejected")
: document.approvalState === "VOIDED"
? __("No longer required")
: __("Pending"); : __("Pending");
return ( return (

View File

@@ -353,15 +353,49 @@ func TestDocumentVersion_RequestSignature(t *testing.T) {
assert.Equal(t, signerProfileID, result.RequestSignature.DocumentVersionSignatureEdge.Node.SignedBy.ID) 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) { func TestDocumentVersion_BulkPublish(t *testing.T) {
t.Parallel() t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner) owner := testutil.NewClient(t, testutil.RoleOwner)
// Create multiple documents // Create multiple draft documents (no default approvers — should publish directly)
docID1, _ := createTestDocument(t, owner) docID1, _ := createTestDocument(t, owner)
docID2, _ := createTestDocument(t, owner) docID2, _ := createTestDocument(t, owner)
approveTestDocument(t, owner, docID1)
approveTestDocument(t, owner, docID2)
query := ` query := `
mutation BulkPublishMajorDocumentVersions($input: BulkPublishDocumentVersionsInput!) { 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) { func TestDocumentVersion_BulkRequestSignatures(t *testing.T) {
t.Parallel() t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner) 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, docID1)
assert.Contains(t, result.BulkDeleteDocuments.DeletedDocumentIds, docID2) 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)
})
}

View File

@@ -17,14 +17,23 @@ import { useTranslate } from "@probo/i18n";
import { Badge } from "../../Atoms/Badge/Badge"; import { Badge } from "../../Atoms/Badge/Badge";
type Props = { type Props = {
state: "DRAFT" | "PUBLISHED"; state: "DRAFT" | "PENDING_APPROVAL" | "PUBLISHED";
}; };
export function DocumentVersionBadge(props: Props) { export function DocumentVersionBadge(props: Props) {
const { __ } = useTranslate(); const { __ } = useTranslate();
return (
<Badge variant={props.state === "DRAFT" ? "neutral" : "success"}> const variant = {
{props.state === "DRAFT" ? __("Draft") : __("Published")} DRAFT: "neutral",
</Badge> PENDING_APPROVAL: "warning",
); PUBLISHED: "success",
} as const;
const label = {
DRAFT: __("Draft"),
PENDING_APPROVAL: __("Pending approval"),
PUBLISHED: __("Published"),
} as const;
return <Badge variant={variant[props.state]}>{label[props.state]}</Badge>;
} }

View File

@@ -66,45 +66,14 @@ func (p Document) CursorKey(orderBy DocumentOrderField) page.CursorKey {
// AuthorizationAttributes returns the authorization attributes for policy evaluation. // AuthorizationAttributes returns the authorization attributes for policy evaluation.
func (d *Document) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { func (d *Document) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) {
q := ` q := `
WITH document AS ( SELECT organization_id
SELECT id, organization_id, status
FROM documents FROM documents
WHERE id = $1 WHERE id = $1
LIMIT 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;
` `
var ( var organizationID gid.GID
organizationID gid.GID if err := conn.QueryRow(ctx, q, d.ID).Scan(&organizationID); err != nil {
documentStatus DocumentStatus
latestVersionStatus string
lastQuorumStatus string
)
if err := conn.QueryRow(ctx, q, d.ID).Scan(&organizationID, &documentStatus, &latestVersionStatus, &lastQuorumStatus); err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrResourceNotFound return nil, ErrResourceNotFound
} }
@@ -113,9 +82,6 @@ LEFT JOIN last_quorum lq ON lq.document_id = document.id;
return map[string]string{ return map[string]string{
"organization_id": organizationID.String(), "organization_id": organizationID.String(),
"document_status": documentStatus.String(),
"version_status": latestVersionStatus,
"last_quorum_status": lastQuorumStatus,
}, nil }, nil
} }
@@ -1117,7 +1083,7 @@ LIMIT 1
state, err := pgx.CollectOneRow(rows, pgx.RowTo[DocumentVersionApprovalDecisionState]) state, err := pgx.CollectOneRow(rows, pgx.RowTo[DocumentVersionApprovalDecisionState])
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return DocumentVersionApprovalDecisionStatePending, nil return "", nil
} }
return "", fmt.Errorf("cannot collect approval state: %w", err) return "", fmt.Errorf("cannot collect approval state: %w", err)
} }

View File

@@ -0,0 +1,143 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package coredata
import (
"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
}

View File

@@ -52,44 +52,15 @@ type (
// AuthorizationAttributes returns the authorization attributes for policy evaluation. // AuthorizationAttributes returns the authorization attributes for policy evaluation.
func (dv *DocumentVersion) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { func (dv *DocumentVersion) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) {
q := ` q := `
WITH document_version AS ( SELECT organization_id
SELECT id, document_id, organization_id, status AS version_status
FROM document_versions FROM document_versions
WHERE id = $1 WHERE id = $1
LIMIT 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;
` `
var ( var organizationID gid.GID
organizationID gid.GID
documentStatus DocumentStatus
documentVersionStatus DocumentVersionStatus
lastQuorumStatus string
)
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) { if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrResourceNotFound return nil, ErrResourceNotFound
} }
@@ -98,9 +69,6 @@ LEFT JOIN last_quorum lq ON lq.version_id = document_version.id;
return map[string]string{ return map[string]string{
"organization_id": organizationID.String(), "organization_id": organizationID.String(),
"document_status": documentStatus.String(),
"version_status": documentVersionStatus.String(),
"last_quorum_status": lastQuorumStatus,
}, nil }, nil
} }
@@ -213,6 +181,9 @@ LIMIT 1;
documentVersion, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[DocumentVersion]) documentVersion, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[DocumentVersion])
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect document version: %w", err) return fmt.Errorf("cannot collect document version: %w", err)
} }
@@ -343,6 +314,9 @@ LIMIT 1;
documentVersion, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[DocumentVersion]) documentVersion, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[DocumentVersion])
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect document version: %w", err) return fmt.Errorf("cannot collect document version: %w", err)
} }
@@ -395,6 +369,9 @@ LIMIT 1;
documentVersion, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[DocumentVersion]) documentVersion, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[DocumentVersion])
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect document version: %w", err) return fmt.Errorf("cannot collect document version: %w", err)
} }
@@ -449,6 +426,9 @@ LIMIT 1;
documentVersion, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[DocumentVersion]) documentVersion, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[DocumentVersion])
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect document version: %w", err) return fmt.Errorf("cannot collect document version: %w", err)
} }

View File

@@ -424,6 +424,40 @@ WHERE
return nil 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( func (d *DocumentVersionApprovalDecisions) CountByQuorumID(
ctx context.Context, ctx context.Context,
conn pg.Querier, conn pg.Querier,

View File

@@ -29,6 +29,7 @@ const (
DocumentVersionApprovalDecisionStatePending DocumentVersionApprovalDecisionState = "PENDING" DocumentVersionApprovalDecisionStatePending DocumentVersionApprovalDecisionState = "PENDING"
DocumentVersionApprovalDecisionStateApproved DocumentVersionApprovalDecisionState = "APPROVED" DocumentVersionApprovalDecisionStateApproved DocumentVersionApprovalDecisionState = "APPROVED"
DocumentVersionApprovalDecisionStateRejected DocumentVersionApprovalDecisionState = "REJECTED" DocumentVersionApprovalDecisionStateRejected DocumentVersionApprovalDecisionState = "REJECTED"
DocumentVersionApprovalDecisionStateVoided DocumentVersionApprovalDecisionState = "VOIDED"
) )
func (s DocumentVersionApprovalDecisionState) MarshalText() ([]byte, error) { func (s DocumentVersionApprovalDecisionState) MarshalText() ([]byte, error) {
@@ -45,6 +46,8 @@ func (s *DocumentVersionApprovalDecisionState) UnmarshalText(data []byte) error
*s = DocumentVersionApprovalDecisionStateApproved *s = DocumentVersionApprovalDecisionStateApproved
case DocumentVersionApprovalDecisionStateRejected.String(): case DocumentVersionApprovalDecisionStateRejected.String():
*s = DocumentVersionApprovalDecisionStateRejected *s = DocumentVersionApprovalDecisionStateRejected
case DocumentVersionApprovalDecisionStateVoided.String():
*s = DocumentVersionApprovalDecisionStateVoided
default: default:
return fmt.Errorf("invalid DocumentVersionApprovalDecisionState value: %q", val) return fmt.Errorf("invalid DocumentVersionApprovalDecisionState value: %q", val)
} }
@@ -62,6 +65,8 @@ func (s DocumentVersionApprovalDecisionState) String() string {
val = "APPROVED" val = "APPROVED"
case DocumentVersionApprovalDecisionStateRejected: case DocumentVersionApprovalDecisionStateRejected:
val = "REJECTED" val = "REJECTED"
case DocumentVersionApprovalDecisionStateVoided:
val = "VOIDED"
default: default:
panic(fmt.Errorf("invalid DocumentVersionApprovalDecisionState value: %q", string(s))) panic(fmt.Errorf("invalid DocumentVersionApprovalDecisionState value: %q", string(s)))
} }

View File

@@ -25,6 +25,7 @@ const (
DocumentVersionApprovalQuorumStatusPending DocumentVersionApprovalQuorumStatus = "PENDING" DocumentVersionApprovalQuorumStatusPending DocumentVersionApprovalQuorumStatus = "PENDING"
DocumentVersionApprovalQuorumStatusApproved DocumentVersionApprovalQuorumStatus = "APPROVED" DocumentVersionApprovalQuorumStatusApproved DocumentVersionApprovalQuorumStatus = "APPROVED"
DocumentVersionApprovalQuorumStatusRejected DocumentVersionApprovalQuorumStatus = "REJECTED" DocumentVersionApprovalQuorumStatusRejected DocumentVersionApprovalQuorumStatus = "REJECTED"
DocumentVersionApprovalQuorumStatusVoided DocumentVersionApprovalQuorumStatus = "VOIDED"
) )
func (s DocumentVersionApprovalQuorumStatus) MarshalText() ([]byte, error) { func (s DocumentVersionApprovalQuorumStatus) MarshalText() ([]byte, error) {
@@ -41,6 +42,8 @@ func (s *DocumentVersionApprovalQuorumStatus) UnmarshalText(data []byte) error {
*s = DocumentVersionApprovalQuorumStatusApproved *s = DocumentVersionApprovalQuorumStatusApproved
case DocumentVersionApprovalQuorumStatusRejected.String(): case DocumentVersionApprovalQuorumStatusRejected.String():
*s = DocumentVersionApprovalQuorumStatusRejected *s = DocumentVersionApprovalQuorumStatusRejected
case DocumentVersionApprovalQuorumStatusVoided.String():
*s = DocumentVersionApprovalQuorumStatusVoided
default: default:
return fmt.Errorf("invalid DocumentVersionApprovalQuorumStatus value: %q", val) return fmt.Errorf("invalid DocumentVersionApprovalQuorumStatus value: %q", val)
} }
@@ -58,6 +61,8 @@ func (s DocumentVersionApprovalQuorumStatus) String() string {
val = "APPROVED" val = "APPROVED"
case DocumentVersionApprovalQuorumStatusRejected: case DocumentVersionApprovalQuorumStatusRejected:
val = "REJECTED" val = "REJECTED"
case DocumentVersionApprovalQuorumStatusVoided:
val = "VOIDED"
default: default:
panic(fmt.Errorf("invalid DocumentVersionApprovalQuorumStatus value: %q", string(s))) panic(fmt.Errorf("invalid DocumentVersionApprovalQuorumStatus value: %q", string(s)))
} }

View File

@@ -20,12 +20,13 @@ import (
) )
type ( type (
DocumentVersionStatus uint8 DocumentVersionStatus string
) )
const ( const (
DocumentVersionStatusDraft DocumentVersionStatus = iota DocumentVersionStatusDraft DocumentVersionStatus = "DRAFT"
DocumentVersionStatusPublished DocumentVersionStatusPendingApproval DocumentVersionStatus = "PENDING_APPROVAL"
DocumentVersionStatusPublished DocumentVersionStatus = "PUBLISHED"
) )
func (ps DocumentVersionStatus) MarshalText() ([]byte, error) { func (ps DocumentVersionStatus) MarshalText() ([]byte, error) {
@@ -38,6 +39,8 @@ func (ps *DocumentVersionStatus) UnmarshalText(data []byte) error {
switch val { switch val {
case DocumentVersionStatusDraft.String(): case DocumentVersionStatusDraft.String():
*ps = DocumentVersionStatusDraft *ps = DocumentVersionStatusDraft
case DocumentVersionStatusPendingApproval.String():
*ps = DocumentVersionStatusPendingApproval
case DocumentVersionStatusPublished.String(): case DocumentVersionStatusPublished.String():
*ps = DocumentVersionStatusPublished *ps = DocumentVersionStatusPublished
default: default:
@@ -48,16 +51,16 @@ func (ps *DocumentVersionStatus) UnmarshalText(data []byte) error {
} }
func (ps DocumentVersionStatus) String() string { func (ps DocumentVersionStatus) String() string {
var val string
switch ps { switch ps {
case DocumentVersionStatusDraft: case DocumentVersionStatusDraft:
val = "DRAFT" return "DRAFT"
case DocumentVersionStatusPendingApproval:
return "PENDING_APPROVAL"
case DocumentVersionStatusPublished: 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 { func (ps *DocumentVersionStatus) Scan(value any) error {

View File

@@ -0,0 +1,17 @@
-- Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
--
-- Permission to use, copy, modify, and/or distribute this software for any
-- purpose with or without fee is hereby granted, provided that the above
-- copyright notice and this permission notice appear in all copies.
--
-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
-- PERFORMANCE OF THIS SOFTWARE.
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';

View File

@@ -0,0 +1,35 @@
-- Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
--
-- Permission to use, copy, modify, and/or distribute this software for any
-- purpose with or without fee is hereby granted, provided that the above
-- copyright notice and this permission notice appear in all copies.
--
-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
-- PERFORMANCE OF THIS SOFTWARE.
-- 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')
);

View File

@@ -0,0 +1,47 @@
-- Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
--
-- Permission to use, copy, modify, and/or distribute this software for any
-- purpose with or without fee is hereby granted, provided that the above
-- copyright notice and this permission notice appear in all copies.
--
-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
-- PERFORMANCE OF THIS SOFTWARE.
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;

View File

@@ -197,11 +197,10 @@ const (
ActionDocumentVersionUpdate = "core:document-version:update" ActionDocumentVersionUpdate = "core:document-version:update"
ActionDocumentVersionDeleteDraft = "core:document-version:delete-draft" ActionDocumentVersionDeleteDraft = "core:document-version:delete-draft"
ActionDocumentVersionRequestApproval = "core:document-version:request-approval" ActionDocumentVersionRequestApproval = "core:document-version:request-approval"
ActionDocumentVersionVoidApproval = "core:document-version:void-approval"
ActionDocumentVersionApprove = "core:document-version:approve" ActionDocumentVersionApprove = "core:document-version:approve"
ActionDocumentVersionReject = "core:document-version:reject" ActionDocumentVersionReject = "core:document-version:reject"
ActionDocumentVersionApprovalList = "core:document-version:approval-list" ActionDocumentVersionApprovalList = "core:document-version:approval-list"
ActionDocumentVersionAddApprover = "core:document-version:add-approver"
ActionDocumentVersionRemoveApprover = "core:document-version:remove-approver"
ActionDocumentVersionPublish = "core:document-version:publish" ActionDocumentVersionPublish = "core:document-version:publish"
ActionDocumentVersionExport = "core:document-version:export" ActionDocumentVersionExport = "core:document-version:export"

View File

@@ -19,9 +19,8 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"time"
"net/url" "net/url"
"time"
"go.gearno.de/crypto/uuid" "go.gearno.de/crypto/uuid"
"go.gearno.de/kit/pg" "go.gearno.de/kit/pg"
@@ -84,10 +83,10 @@ func (req *RequestApprovalRequest) Validate() error {
v := validator.New() v := validator.New()
v.Check(req.DocumentID, "document_id", validator.Required(), validator.GID(coredata.DocumentEntityType)) 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.Min(1), validator.Max(100))
v.Check(len(req.ApproverIDs), "approver_ids", validator.Max(100)) v.Check(req.ApproverIDs, "approver_ids", validator.NoDuplicates())
v.CheckEach(req.ApproverIDs, "approver_ids", func(_ int, item any) { v.CheckEach(req.ApproverIDs, "approver_ids", func(index int, item any) {
v.Check(item, "approver_ids", validator.GID(coredata.MembershipProfileEntityType)) v.Check(item, fmt.Sprintf("approver_ids[%d]", index), validator.GID(coredata.MembershipProfileEntityType))
}) })
v.Check(req.Changelog, "changelog", validator.Required(), validator.SafeText(5000)) 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) return fmt.Errorf("cannot load latest version: %w", err)
} }
if documentVersion.Status == coredata.DocumentVersionStatusPublished { if documentVersion.Status != coredata.DocumentVersionStatusDraft {
return fmt.Errorf("cannot request approval for a published document") return &ErrDocumentVersionNotDraft{}
} }
if err := s.rejectPendingQuorum(ctx, tx, documentVersion.ID); err != nil { q, err := s.requestApprovalInTx(ctx, tx, document, documentVersion, req.ApproverIDs, req.Changelog)
return fmt.Errorf("cannot reject pending quorum: %w", err) if err != nil {
return err
} }
quorum = q
organization := &coredata.Organization{} defaultApprovers := &coredata.DocumentDefaultApprovers{}
if err := organization.LoadByID(ctx, tx, s.svc.scope, document.OrganizationID); err != nil { if err := defaultApprovers.MergeByDocumentID(ctx, tx, s.svc.scope, req.DocumentID, document.OrganizationID, req.ApproverIDs); err != nil {
return fmt.Errorf("cannot load organization: %w", err) return fmt.Errorf("cannot update default approvers: %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)
} }
return nil return nil
@@ -181,6 +146,138 @@ func (s *DocumentApprovalService) RequestApproval(
return quorum, nil 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( func (s *DocumentApprovalService) Approve(
ctx context.Context, ctx context.Context,
req ApproveDocumentVersionRequest, req ApproveDocumentVersionRequest,
@@ -205,6 +302,10 @@ func (s *DocumentApprovalService) Approve(
return fmt.Errorf("cannot load document: %w", err) return fmt.Errorf("cannot load document: %w", err)
} }
if document.ArchivedAt != nil {
return &ErrDocumentArchived{}
}
var profile *coredata.MembershipProfile var profile *coredata.MembershipProfile
var err error var err error
quorum, profile, err = s.loadQuorumAndProfile(ctx, conn, req.DocumentVersionID, req.IdentityID, documentVersion.OrganizationID) quorum, profile, err = s.loadQuorumAndProfile(ctx, conn, req.DocumentVersionID, req.IdentityID, documentVersion.OrganizationID)
@@ -269,9 +370,20 @@ func (s *DocumentApprovalService) Approve(
approverID := decision.ApproverID approverID := decision.ApproverID
quorumID := quorum.ID
err = s.svc.pg.WithTx( err = s.svc.pg.WithTx(
ctx, ctx,
func(ctx context.Context, tx pg.Tx) error { 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{} decision = &coredata.DocumentVersionApprovalDecision{}
if err := decision.LoadByQuorumIDAndApproverID(ctx, tx, s.svc.scope, quorum.ID, approverID); err != nil { if err := decision.LoadByQuorumIDAndApproverID(ctx, tx, s.svc.scope, quorum.ID, approverID); err != nil {
return fmt.Errorf("cannot load approval decision: %w", err) 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) 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) quorum, profile, err := s.loadQuorumAndProfile(ctx, tx, req.DocumentVersionID, req.IdentityID, documentVersion.OrganizationID)
if err != nil { if err != nil {
return fmt.Errorf("cannot load quorum and profile: %w", err) 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) 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 return nil
}, },
) )
@@ -386,70 +526,71 @@ func (s *DocumentApprovalService) Reject(
return decision, nil return decision, nil
} }
func (s *DocumentApprovalService) AddApprover( func (s *DocumentApprovalService) VoidApproval(
ctx context.Context, ctx context.Context,
documentVersionID gid.GID, documentVersionID gid.GID,
approverID gid.GID, ) (*coredata.DocumentVersionApprovalQuorum, *coredata.DocumentVersion, error) {
) (*coredata.DocumentVersionApprovalDecision, error) { var (
var decision *coredata.DocumentVersionApprovalDecision quorum *coredata.DocumentVersionApprovalQuorum
documentVersion *coredata.DocumentVersion
)
err := s.svc.pg.WithTx( err := s.svc.pg.WithTx(
ctx, ctx,
func(ctx context.Context, tx pg.Tx) error { 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 { if err := documentVersion.LoadByID(ctx, tx, s.svc.scope, documentVersionID); err != nil {
return fmt.Errorf("cannot load document version: %w", err) return fmt.Errorf("cannot load document version: %w", err)
} }
quorum := &coredata.DocumentVersionApprovalQuorum{} document := &coredata.Document{}
if err := quorum.LoadLastByDocumentVersionID(ctx, tx, s.svc.scope, documentVersionID); err != nil { 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{} 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 { if quorum.Status != coredata.DocumentVersionApprovalQuorumStatusPending {
return &ErrDocumentVersionNotPendingApproval{} return &ErrDocumentVersionNotPendingApproval{}
} }
now := time.Now() now := time.Now()
decision = &coredata.DocumentVersionApprovalDecision{ quorum.Status = coredata.DocumentVersionApprovalQuorumStatusVoided
ID: gid.New(s.svc.scope.GetTenantID(), coredata.DocumentVersionApprovalDecisionEntityType), quorum.UpdatedAt = now
OrganizationID: documentVersion.OrganizationID,
QuorumID: quorum.ID, if err := quorum.Update(ctx, tx, s.svc.scope); err != nil {
ApproverID: approverID, return fmt.Errorf("cannot update approval quorum: %w", err)
State: coredata.DocumentVersionApprovalDecisionStatePending,
CreatedAt: now,
UpdatedAt: now,
} }
if err := decision.Insert(ctx, tx, s.svc.scope); err != nil { decisions := &coredata.DocumentVersionApprovalDecisions{}
return fmt.Errorf("cannot insert approval decision: %w", err) 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{} documentVersion.Status = coredata.DocumentVersionStatusDraft
if err := document.LoadByID(ctx, tx, s.svc.scope, documentVersion.DocumentID); err != nil { if document.CurrentPublishedMajor != nil {
return fmt.Errorf("cannot load document: %w", err) documentVersion.Major = *document.CurrentPublishedMajor
documentVersion.Minor = *document.CurrentPublishedMinor + 1
} else {
documentVersion.Major = 0
documentVersion.Minor = 1
} }
documentVersion.UpdatedAt = now
organization := &coredata.Organization{} if err := documentVersion.Update(ctx, tx, s.svc.scope); err != nil {
if err := organization.LoadByID(ctx, tx, s.svc.scope, document.OrganizationID); err != nil { return fmt.Errorf("cannot update document version status: %w", err)
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)
} }
return nil return nil
@@ -457,66 +598,10 @@ func (s *DocumentApprovalService) AddApprover(
) )
if err != nil { if err != nil {
return nil, err return nil, nil, err
} }
return decision, nil return quorum, documentVersion, 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
} }
func (s *DocumentApprovalService) GetQuorum( func (s *DocumentApprovalService) GetQuorum(
@@ -726,34 +811,6 @@ func (s *DocumentApprovalService) loadQuorumAndProfile(
return quorum, profile, nil 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( func (s *DocumentApprovalService) createDecisions(
ctx context.Context, ctx context.Context,
tx pg.Tx, tx pg.Tx,
@@ -918,7 +975,10 @@ func (s *DocumentApprovalService) maybeApproveQuorum(
return fmt.Errorf("cannot count total decisions: %w", err) return fmt.Errorf("cannot count total decisions: %w", err)
} }
if totalCount > 0 { if totalCount == 0 {
return nil
}
decisions := &coredata.DocumentVersionApprovalDecisions{} decisions := &coredata.DocumentVersionApprovalDecisions{}
approvedCount, err := decisions.CountApprovedByQuorumID(ctx, tx, s.svc.scope, quorumID) approvedCount, err := decisions.CountApprovedByQuorumID(ctx, tx, s.svc.scope, quorumID)
if err != nil { if err != nil {
@@ -928,7 +988,6 @@ func (s *DocumentApprovalService) maybeApproveQuorum(
if approvedCount != totalCount { if approvedCount != totalCount {
return nil return nil
} }
}
quorum := &coredata.DocumentVersionApprovalQuorum{} quorum := &coredata.DocumentVersionApprovalQuorum{}
if err := quorum.LoadByID(ctx, tx, s.svc.scope, quorumID); err != nil { if err := quorum.LoadByID(ctx, tx, s.svc.scope, quorumID); err != nil {
@@ -960,13 +1019,17 @@ func (s *DocumentApprovalService) publishVersion(
return fmt.Errorf("cannot load document version: %w", err) return fmt.Errorf("cannot load document version: %w", err)
} }
_, _, err := s.svc.Documents.publishMajorVersionInTx( document := &coredata.Document{}
ctx, if err := document.LoadByID(ctx, tx, s.svc.scope, version.DocumentID); err != nil {
tx, return fmt.Errorf("cannot load document: %w", err)
version.DocumentID, }
nil,
false, document.CurrentPublishedMajor = &version.Major
) document.CurrentPublishedMinor = &version.Minor
return err if err := s.svc.Documents.finalizePublish(ctx, tx, document, version, nil); err != nil {
return fmt.Errorf("cannot finalize publish: %w", err)
}
return nil
} }

View File

@@ -62,6 +62,12 @@ type (
ErrDocumentVersionNotDraft struct { ErrDocumentVersionNotDraft struct {
} }
ErrDocumentVersionNotPublished struct {
}
ErrDocumentVersionPendingApproval struct {
}
ErrDocumentArchived struct { ErrDocumentArchived struct {
} }
@@ -78,12 +84,14 @@ type (
Classification coredata.DocumentClassification Classification coredata.DocumentClassification
DocumentType coredata.DocumentType DocumentType coredata.DocumentType
TrustCenterVisibility *coredata.TrustCenterVisibility TrustCenterVisibility *coredata.TrustCenterVisibility
DefaultApproverIDs []gid.GID
} }
UpdateDocumentRequest struct { UpdateDocumentRequest struct {
DocumentID gid.GID DocumentID gid.GID
Title *string Title *string
TrustCenterVisibility *coredata.TrustCenterVisibility TrustCenterVisibility *coredata.TrustCenterVisibility
DefaultApproverIDs *[]gid.GID
} }
UpdateDocumentVersionRequest struct { 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.Classification, "classification", validator.Required(), validator.OneOfSlice(coredata.DocumentClassifications()))
v.Check(cdr.DocumentType, "document_type", validator.Required(), validator.OneOfSlice(coredata.DocumentTypes())) 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(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() 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.DocumentID, "document_id", validator.Required(), validator.GID(coredata.DocumentEntityType))
v.Check(udr.Title, "title", validator.SafeTextNoNewLine(TitleMaxLength)) v.Check(udr.Title, "title", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(udr.TrustCenterVisibility, "trust_center_visibility", validator.OneOfSlice(coredata.TrustCenterVisibilities())) 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() return v.Error()
} }
@@ -179,7 +199,15 @@ func (e ErrSignatureNotCancellable) Error() string {
} }
func (e ErrDocumentVersionNotDraft) 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 { func (e ErrDocumentArchived) Error() string {
@@ -214,6 +242,48 @@ func (s *DocumentService) Get(
return document, nil 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( func (s *DocumentService) GetByIDs(
ctx context.Context, ctx context.Context,
documentIDs ...gid.GID, documentIDs ...gid.GID,
@@ -343,6 +413,10 @@ func (s DocumentService) GenerateChangelog(
return fmt.Errorf("cannot load document: %w", err) return fmt.Errorf("cannot load document: %w", err)
} }
if document.ArchivedAt != nil {
return &ErrDocumentArchived{}
}
if document.CurrentPublishedMajor == nil { if document.CurrentPublishedMajor == nil {
initialVersionChangelog := "Initial version" initialVersionChangelog := "Initial version"
changelog = &initialVersionChangelog changelog = &initialVersionChangelog
@@ -375,37 +449,6 @@ func (s DocumentService) GenerateChangelog(
return changelog, nil 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( func (s *DocumentService) BulkPublishMinorVersions(
ctx context.Context, ctx context.Context,
req BulkPublishVersionsRequest, req BulkPublishVersionsRequest,
@@ -417,6 +460,16 @@ func (s *DocumentService) BulkPublishMinorVersions(
ctx, ctx,
func(ctx context.Context, tx pg.Tx) error { func(ctx context.Context, tx pg.Tx) error {
for _, documentID := range req.DocumentIDs { 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) document, version, err := s.publishMinorVersionInTx(ctx, tx, documentID, &req.Changelog, true)
if err != nil { if err != nil {
return fmt.Errorf("cannot publish document %q: %w", documentID, err) return fmt.Errorf("cannot publish document %q: %w", documentID, err)
@@ -449,6 +502,15 @@ func (s *DocumentService) PublishMajorVersion(
err := s.svc.pg.WithTx( err := s.svc.pg.WithTx(
ctx, ctx,
func(ctx context.Context, tx pg.Tx) error { 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 var err error
document, documentVersion, err = s.publishMajorVersionInTx(ctx, tx, documentID, changelog, false) document, documentVersion, err = s.publishMajorVersionInTx(ctx, tx, documentID, changelog, false)
@@ -479,6 +541,15 @@ func (s *DocumentService) PublishMinorVersion(
err := s.svc.pg.WithTx( err := s.svc.pg.WithTx(
ctx, ctx,
func(ctx context.Context, tx pg.Tx) error { 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 var err error
document, documentVersion, err = s.publishMinorVersionInTx(ctx, tx, documentID, changelog, false) 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) 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 return nil
}, },
) )
@@ -916,19 +994,30 @@ func (s *DocumentService) RequestSignature(
ctx context.Context, ctx context.Context,
req RequestSignatureRequest, req RequestSignatureRequest,
) (*coredata.DocumentVersionSignature, error) { ) (*coredata.DocumentVersionSignature, error) {
documentVersion, err := s.GetVersion(ctx, req.DocumentVersionID) var signature *coredata.DocumentVersionSignature
if err != nil {
return nil, fmt.Errorf("cannot get document version: %w", err) 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 { if documentVersion.Status != coredata.DocumentVersionStatusPublished {
return nil, fmt.Errorf("cannot request signature for unpublished version") return fmt.Errorf("cannot request signature for unpublished version")
} }
var signature *coredata.DocumentVersionSignature var err error
err = s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
signature, err = s.createSignatureRequestInTx(ctx, tx, req.DocumentVersionID, req.Signatory, false) signature, err = s.createSignatureRequestInTx(ctx, tx, req.DocumentVersionID, req.Signatory, false)
if err != nil { if err != nil {
return fmt.Errorf("cannot create signature request: %w", err) 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) 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 { if err := latestVersion.LoadLatestVersion(ctx, conn, s.svc.scope, documentID); err != nil {
return fmt.Errorf("cannot load latest version: %w", err) return fmt.Errorf("cannot load latest version: %w", err)
} }
if latestVersion.Status != coredata.DocumentVersionStatusPublished { if latestVersion.Status != coredata.DocumentVersionStatusPublished {
return fmt.Errorf("cannot create draft from unpublished version") return &ErrDocumentVersionNotPublished{}
} }
draftVersion.ID = draftVersionID draftVersion.ID = draftVersionID
@@ -1064,6 +1157,15 @@ func (s *DocumentService) DeleteDraft(
return fmt.Errorf("cannot load document version: %w", err) 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 { if documentVersion.Status != coredata.DocumentVersionStatusDraft {
return fmt.Errorf("cannot delete published document version") 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 return nil
}, },
) )
@@ -1753,6 +1862,20 @@ func (s *DocumentService) CancelSignatureRequest(
return fmt.Errorf("cannot load document version signature: %w", err) 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 { if documentVersionSignature.State != coredata.DocumentVersionSignatureStateRequested {
return ErrSignatureNotCancellable{ return ErrSignatureNotCancellable{
currentState: documentVersionSignature.State, currentState: documentVersionSignature.State,
@@ -2269,7 +2392,7 @@ func (s *DocumentService) loadDraftForPublish(
return document, documentVersion, nil return document, documentVersion, nil
} }
if documentVersion.Status != coredata.DocumentVersionStatusDraft { if documentVersion.Status != coredata.DocumentVersionStatusDraft && documentVersion.Status != coredata.DocumentVersionStatusPendingApproval {
return nil, nil, &ErrDocumentVersionNotDraft{} return nil, nil, &ErrDocumentVersionNotDraft{}
} }

View File

@@ -21,70 +21,12 @@ import (
var ( var (
organizationCondition = policy.Equals("principal.organization_id", "resource.organization_id") 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"),
)
) )
// OwnerPolicy defines permissions for organization owners. // OwnerPolicy defines permissions for organization owners.
var OwnerPolicy = policy.NewPolicy( var OwnerPolicy = policy.NewPolicy(
"probo:owner", "probo:owner",
"Probo Owner", "Probo Owner",
documentWriteActiveOnly,
documentUnarchiveArchivedOnly,
documentRequestApprovalNoPendingQuorum,
documentRequestApprovalNotPublished,
documentApproverRequiresPendingQuorum,
policy.Allow("core:*").WithSID("full-core-access").When(organizationCondition), policy.Allow("core:*").WithSID("full-core-access").When(organizationCondition),
).WithDescription("Full probo access for organization owners") ).WithDescription("Full probo access for organization owners")
@@ -92,13 +34,6 @@ var OwnerPolicy = policy.NewPolicy(
var AdminPolicy = policy.NewPolicy( var AdminPolicy = policy.NewPolicy(
"probo:admin", "probo:admin",
"Probo Admin", "Probo Admin",
documentWriteActiveOnly,
documentUnarchiveArchivedOnly,
documentRequestApprovalNoPendingQuorum,
documentRequestApprovalNotPublished,
documentApproverRequiresPendingQuorum,
policy.Allow("core:*").WithSID("full-core-access").When(organizationCondition), policy.Allow("core:*").WithSID("full-core-access").When(organizationCondition),
).WithDescription("Probo admin access - can manage core entities") ).WithDescription("Probo admin access - can manage core entities")
@@ -106,7 +41,6 @@ var AdminPolicy = policy.NewPolicy(
var ViewerPolicy = policy.NewPolicy( var ViewerPolicy = policy.NewPolicy(
"probo:viewer", "probo:viewer",
"Probo Viewer", "Probo Viewer",
documentWriteActiveOnly,
policy.Allow( policy.Allow(
ActionOrganizationGet, ActionOrganizationGet,
ActionOrganizationGetLogoUrl, ActionOrganizationGetLogoUrl,
@@ -244,7 +178,6 @@ var AuditorPolicy = policy.NewPolicy(
var EmployeePolicy = policy.NewPolicy( var EmployeePolicy = policy.NewPolicy(
"probo:employee", "probo:employee",
"Probo Employee", "Probo Employee",
documentWriteActiveOnly,
policy.Allow( policy.Allow(
ActionOrganizationGet, ActionOrganizationGet,
ActionOrganizationGetLogoUrl, ActionOrganizationGetLogoUrl,
@@ -263,7 +196,6 @@ var EmployeePolicy = policy.NewPolicy(
ActionDocumentVersionApprovalList, ActionDocumentVersionApprovalList,
ActionDocumentVersionApprove, ActionDocumentVersionApprove,
ActionDocumentVersionReject, ActionDocumentVersionReject,
ActionEmployeeDocumentVersionExportPDF,
).WithSID("document-version-approval").When(organizationCondition), ).WithSID("document-version-approval").When(organizationCondition),
).WithDescription("Employee access - can sign documents, approve documents, and view internal content") ).WithDescription("Employee access - can sign documents, approve documents, and view internal content")

View File

@@ -104,6 +104,10 @@ enum DocumentVersionStatus
@goEnum( @goEnum(
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionStatusDraft" value: "go.probo.inc/probo/pkg/coredata.DocumentVersionStatusDraft"
) )
PENDING_APPROVAL
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionStatusPendingApproval"
)
PUBLISHED PUBLISHED
@goEnum( @goEnum(
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionStatusPublished" value: "go.probo.inc/probo/pkg/coredata.DocumentVersionStatusPublished"
@@ -2583,6 +2587,8 @@ type Document implements Node {
filter: ControlFilter filter: ControlFilter
): ControlConnection! @goField(forceResolver: true) ): ControlConnection! @goField(forceResolver: true)
defaultApprovers: [Profile!]! @goField(forceResolver: true)
status: DocumentStatus! status: DocumentStatus!
archivedAt: Datetime archivedAt: Datetime
@@ -3934,6 +3940,9 @@ type Mutation {
requestDocumentVersionApproval( requestDocumentVersionApproval(
input: RequestDocumentVersionApprovalInput! input: RequestDocumentVersionApprovalInput!
): RequestDocumentVersionApprovalPayload! ): RequestDocumentVersionApprovalPayload!
voidDocumentVersionApproval(
input: VoidDocumentVersionApprovalInput!
): VoidDocumentVersionApprovalPayload!
bulkDeleteDocuments( bulkDeleteDocuments(
input: BulkDeleteDocumentsInput! input: BulkDeleteDocumentsInput!
): BulkDeleteDocumentsPayload! ): BulkDeleteDocumentsPayload!
@@ -3969,12 +3978,6 @@ type Mutation {
input: CancelSignatureRequestInput! input: CancelSignatureRequestInput!
): CancelSignatureRequestPayload! ): CancelSignatureRequestPayload!
signDocument(input: SignDocumentInput!): SignDocumentPayload! signDocument(input: SignDocumentInput!): SignDocumentPayload!
addDocumentVersionApprover(
input: AddDocumentVersionApproverInput!
): AddDocumentVersionApproverPayload!
removeDocumentVersionApprover(
input: RemoveDocumentVersionApproverInput!
): RemoveDocumentVersionApproverPayload!
approveDocumentVersion( approveDocumentVersion(
input: ApproveDocumentVersionInput! input: ApproveDocumentVersionInput!
): ApproveDocumentVersionPayload! ): ApproveDocumentVersionPayload!
@@ -4668,6 +4671,7 @@ input CreateDocumentInput {
documentType: DocumentType! documentType: DocumentType!
classification: DocumentClassification! classification: DocumentClassification!
trustCenterVisibility: TrustCenterVisibility trustCenterVisibility: TrustCenterVisibility
defaultApproverIds: [ID!]
} }
input UpdateDocumentInput { input UpdateDocumentInput {
@@ -4675,6 +4679,7 @@ input UpdateDocumentInput {
title: String title: String
content: String content: String
trustCenterVisibility: TrustCenterVisibility trustCenterVisibility: TrustCenterVisibility
defaultApproverIds: [ID!]
} }
input ExportDocumentVersionPDFInput { input ExportDocumentVersionPDFInput {
@@ -5686,6 +5691,10 @@ enum DocumentVersionApprovalDecisionState
@goEnum( @goEnum(
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalDecisionStateRejected" value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalDecisionStateRejected"
) )
VOIDED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalDecisionStateVoided"
)
} }
enum DocumentVersionApprovalDecisionOrderField enum DocumentVersionApprovalDecisionOrderField
@@ -5714,6 +5723,10 @@ enum DocumentVersionApprovalQuorumStatus
@goEnum( @goEnum(
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalQuorumStatusRejected" value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalQuorumStatusRejected"
) )
VOIDED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalQuorumStatusVoided"
)
} }
enum DocumentVersionApprovalQuorumOrderField enum DocumentVersionApprovalQuorumOrderField
@@ -5816,23 +5829,6 @@ type RejectDocumentVersionPayload {
approvalDecision: DocumentVersionApprovalDecision! approvalDecision: DocumentVersionApprovalDecision!
} }
input AddDocumentVersionApproverInput {
documentVersionId: ID!
approverId: ID!
}
type AddDocumentVersionApproverPayload {
approvalDecisionEdge: DocumentVersionApprovalDecisionEdge!
}
input RemoveDocumentVersionApproverInput {
approvalDecisionId: ID!
}
type RemoveDocumentVersionApproverPayload {
deletedApprovalDecisionId: ID!
documentVersion: DocumentVersion!
}
input RequestSignatureInput { input RequestSignatureInput {
documentVersionId: ID! documentVersionId: ID!
@@ -5897,6 +5893,15 @@ type RequestDocumentVersionApprovalPayload {
approvalQuorum: DocumentVersionApprovalQuorum! approvalQuorum: DocumentVersionApprovalQuorum!
} }
input VoidDocumentVersionApprovalInput {
documentVersionId: ID!
}
type VoidDocumentVersionApprovalPayload {
approvalQuorum: DocumentVersionApprovalQuorum!
documentVersion: DocumentVersion!
}
input PublishMajorDocumentVersionInput { input PublishMajorDocumentVersionInput {
documentId: ID! documentId: ID!
changelog: String changelog: String

View File

@@ -1577,6 +1577,28 @@ func (r *documentResolver) Controls(ctx context.Context, obj *types.Document, fi
return types.NewControlConnection(page, r, obj.ID, controlFilter), nil 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. // Permission is the resolver for the permission field.
func (r *documentResolver) Permission(ctx context.Context, obj *types.Document, action string) (bool, error) { func (r *documentResolver) Permission(ctx context.Context, obj *types.Document, action string) (bool, error) {
return r.Resolver.Permission(ctx, obj, action) return r.Resolver.Permission(ctx, obj, action)
@@ -5247,6 +5269,7 @@ func (r *mutationResolver) CreateDocument(ctx context.Context, input types.Creat
Classification: input.Classification, Classification: input.Classification,
DocumentType: input.DocumentType, DocumentType: input.DocumentType,
TrustCenterVisibility: input.TrustCenterVisibility, TrustCenterVisibility: input.TrustCenterVisibility,
DefaultApproverIDs: input.DefaultApproverIds,
}, },
) )
if err != nil { if err != nil {
@@ -5275,12 +5298,18 @@ func (r *mutationResolver) UpdateDocument(ctx context.Context, input types.Updat
prb := r.ProboService(ctx, input.ID.TenantID()) prb := r.ProboService(ctx, input.ID.TenantID())
var defaultApproverIDs *[]gid.GID
if input.DefaultApproverIds != nil {
defaultApproverIDs = &input.DefaultApproverIds
}
document, err := prb.Documents.Update( document, err := prb.Documents.Update(
ctx, ctx,
probo.UpdateDocumentRequest{ probo.UpdateDocumentRequest{
DocumentID: input.ID, DocumentID: input.ID,
Title: input.Title, Title: input.Title,
TrustCenterVisibility: input.TrustCenterVisibility, TrustCenterVisibility: input.TrustCenterVisibility,
DefaultApproverIDs: defaultApproverIDs,
}, },
) )
@@ -5659,6 +5688,10 @@ func (r *mutationResolver) PublishMajorDocumentVersion(ctx context.Context, inpu
return nil, gqlutils.Invalid(ctx, errNotDraft) 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)) r.logger.ErrorCtx(ctx, "cannot publish major document version", log.Error(err))
return nil, gqlutils.Internal(ctx) return nil, gqlutils.Internal(ctx)
} }
@@ -5692,6 +5725,10 @@ func (r *mutationResolver) PublishMinorDocumentVersion(ctx context.Context, inpu
return nil, gqlutils.Invalid(ctx, errNotDraft) 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)) r.logger.ErrorCtx(ctx, "cannot publish minor document version", log.Error(err))
return nil, gqlutils.Internal(ctx) return nil, gqlutils.Internal(ctx)
} }
@@ -5719,7 +5756,7 @@ func (r *mutationResolver) BulkPublishMajorDocumentVersions(ctx context.Context,
prb := r.ProboService(ctx, input.DocumentIds[0].TenantID()) 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, DocumentIDs: input.DocumentIds,
Changelog: input.Changelog, Changelog: input.Changelog,
}) })
@@ -5820,6 +5857,10 @@ func (r *mutationResolver) RequestDocumentVersionApproval(ctx context.Context, i
return nil, gqlutils.Conflict(ctx, errArchived) 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 { if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors) return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors)
} }
@@ -5833,6 +5874,34 @@ func (r *mutationResolver) RequestDocumentVersionApproval(ctx context.Context, i
}, nil }, 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. // BulkDeleteDocuments is the resolver for the bulkDeleteDocuments field.
func (r *mutationResolver) BulkDeleteDocuments(ctx context.Context, input types.BulkDeleteDocumentsInput) (*types.BulkDeleteDocumentsPayload, error) { func (r *mutationResolver) BulkDeleteDocuments(ctx context.Context, input types.BulkDeleteDocumentsInput) (*types.BulkDeleteDocumentsPayload, error) {
if len(input.DocumentIds) == 0 { 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) changelog, err := prb.Documents.GenerateChangelog(ctx, input.DocumentID)
if err != nil { 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)) r.logger.ErrorCtx(ctx, "cannot generate document changelog", log.Error(err))
return nil, gqlutils.Internal(ctx) 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) documentVersion, err := prb.Documents.CreateDraft(ctx, input.DocumentID)
if err != nil { 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)) r.logger.ErrorCtx(ctx, "cannot create draft document version", log.Error(err))
return nil, gqlutils.Internal(ctx) return nil, gqlutils.Internal(ctx)
} }
@@ -5995,6 +6076,10 @@ func (r *mutationResolver) DeleteDraftDocumentVersion(ctx context.Context, input
err := prb.Documents.DeleteDraft(ctx, input.DocumentVersionID) err := prb.Documents.DeleteDraft(ctx, input.DocumentVersionID)
if err != nil { 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)) r.logger.ErrorCtx(ctx, "cannot delete draft document version", log.Error(err))
return nil, gqlutils.Internal(ctx) return nil, gqlutils.Internal(ctx)
} }
@@ -6061,6 +6146,10 @@ func (r *mutationResolver) RequestSignature(ctx context.Context, input types.Req
}, },
) )
if err != nil { 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)) r.logger.ErrorCtx(ctx, "cannot request signature", log.Error(err))
return nil, gqlutils.Internal(ctx) 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) err := prb.Documents.CancelSignatureRequest(ctx, input.DocumentVersionSignatureID)
if err != nil { 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)) r.logger.ErrorCtx(ctx, "cannot cancel signature request", log.Error(err))
return nil, gqlutils.Internal(ctx) return nil, gqlutils.Internal(ctx)
} }
@@ -6165,53 +6258,6 @@ func (r *mutationResolver) SignDocument(ctx context.Context, input types.SignDoc
}, nil }, 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. // ApproveDocumentVersion is the resolver for the approveDocumentVersion field.
func (r *mutationResolver) ApproveDocumentVersion(ctx context.Context, input types.ApproveDocumentVersionInput) (*types.ApproveDocumentVersionPayload, error) { func (r *mutationResolver) ApproveDocumentVersion(ctx context.Context, input types.ApproveDocumentVersionInput) (*types.ApproveDocumentVersionPayload, error) {
if err := r.authorize(ctx, input.DocumentVersionID, probo.ActionDocumentVersionApprove); err != nil { 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(), SignerUA: httpReq.UserAgent(),
}) })
if err != nil { 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 { if errNotPending, ok := errors.AsType[*probo.ErrDocumentVersionNotPendingApproval](err); ok {
return nil, gqlutils.Invalid(ctx, errNotPending) return nil, gqlutils.Invalid(ctx, errNotPending)
} }
@@ -6275,6 +6325,10 @@ func (r *mutationResolver) RejectDocumentVersion(ctx context.Context, input type
Comment: input.Comment, Comment: input.Comment,
}) })
if err != nil { 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 { if errNotPending, ok := errors.AsType[*probo.ErrDocumentVersionNotPendingApproval](err); ok {
return nil, gqlutils.Invalid(ctx, errNotPending) return nil, gqlutils.Invalid(ctx, errNotPending)
} }

View File

@@ -2095,6 +2095,7 @@ func (r *Resolver) AddDocumentTool(ctx context.Context, req *mcp.CallToolRequest
Classification: input.Classification, Classification: input.Classification,
DocumentType: input.DocumentType, DocumentType: input.DocumentType,
TrustCenterVisibility: trustCenterVisibility, TrustCenterVisibility: trustCenterVisibility,
DefaultApproverIDs: input.DefaultApproverIds,
}, },
) )
if err != nil { if err != nil {
@@ -2109,12 +2110,18 @@ func (r *Resolver) UpdateDocumentTool(ctx context.Context, req *mcp.CallToolRequ
svc := r.ProboService(ctx, input.ID) svc := r.ProboService(ctx, input.ID)
var defaultApproverIDs *[]gid.GID
if input.DefaultApproverIds != nil {
defaultApproverIDs = &input.DefaultApproverIds
}
document, err := svc.Documents.Update( document, err := svc.Documents.Update(
ctx, ctx,
probo.UpdateDocumentRequest{ probo.UpdateDocumentRequest{
DocumentID: input.ID, DocumentID: input.ID,
Title: input.Title, Title: input.Title,
TrustCenterVisibility: input.TrustCenterVisibility, TrustCenterVisibility: input.TrustCenterVisibility,
DefaultApproverIDs: defaultApproverIDs,
}, },
) )
if err != nil { if err != nil {
@@ -3928,3 +3935,18 @@ func (r *Resolver) ListMeasureDocumentsTool(ctx context.Context, req *mcp.CallTo
return nil, types.NewListMeasureDocumentsOutput(docPage), nil 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
}

View File

@@ -5159,6 +5159,7 @@ components:
type: string type: string
enum: enum:
- DRAFT - DRAFT
- PENDING_APPROVAL
- PUBLISHED - PUBLISHED
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.DocumentVersionStatus go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.DocumentVersionStatus
@@ -5495,6 +5496,11 @@ components:
trust_center_visibility: trust_center_visibility:
$ref: "#/components/schemas/TrustCenterVisibility" $ref: "#/components/schemas/TrustCenterVisibility"
description: Trust center visibility description: Trust center visibility
default_approver_ids:
type: array
items:
$ref: "#/components/schemas/GID"
description: Default approver profile IDs
AddDocumentOutput: AddDocumentOutput:
type: object type: object
@@ -5522,6 +5528,11 @@ components:
trust_center_visibility: trust_center_visibility:
$ref: "#/components/schemas/TrustCenterVisibility" $ref: "#/components/schemas/TrustCenterVisibility"
description: Trust center visibility description: Trust center visibility
default_approver_ids:
type: array
items:
$ref: "#/components/schemas/GID"
description: Default approver profile IDs
UpdateDocumentOutput: UpdateDocumentOutput:
type: object type: object
@@ -5860,6 +5871,23 @@ components:
$ref: "#/components/schemas/GID" $ref: "#/components/schemas/GID"
description: Deleted document version signature ID 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: MeetingOrderField:
type: string type: string
enum: enum:
@@ -8474,6 +8502,15 @@ tools:
$ref: "#/components/schemas/CancelSignatureRequestInput" $ref: "#/components/schemas/CancelSignatureRequestInput"
outputSchema: outputSchema:
$ref: "#/components/schemas/CancelSignatureRequestOutput" $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 - name: listMeetings
description: List all meetings for the organization description: List all meetings for the organization
hints: hints:

View File

@@ -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. // NotEmpty validates that a field is not empty.
// Similar to Required, but can be used independently. // Similar to Required, but can be used independently.
func NotEmpty() ValidatorFunc { func NotEmpty() ValidatorFunc {

View File

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