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:
@@ -22,7 +22,7 @@ import { graphql } from "relay-runtime";
|
||||
import type { DocumentLayoutQuery } from "#/__generated__/core/DocumentLayoutQuery.graphql";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
import { DocumentActionsDropdownn } from "./_components/DocumentActionsDropdown";
|
||||
import { DocumentActionsDropdown } from "./_components/DocumentActionsDropdown";
|
||||
import { DocumentLayoutDrawer } from "./_components/DocumentLayoutDrawer";
|
||||
import { DocumentTitleForm } from "./_components/DocumentTitleForm";
|
||||
import { DocumentVersionsDropdown } from "./_components/DocumentVersionsDropdown";
|
||||
@@ -142,7 +142,6 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
|
||||
const isPublished = currentVersion.status === "PUBLISHED";
|
||||
const lastQuorum = currentVersion.approvalQuorums?.edges?.[0]?.node ?? null;
|
||||
const hasApprovals = lastQuorum != null;
|
||||
const hasPendingApproval = lastQuorum?.status === "PENDING";
|
||||
|
||||
const urlPrefix = versionId
|
||||
? `/organizations/${organizationId}/documents/${document.id}/versions/${versionId}`
|
||||
@@ -174,7 +173,7 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
|
||||
</Button>
|
||||
)}
|
||||
<DocumentVersionsDropdown />
|
||||
<DocumentActionsDropdownn
|
||||
<DocumentActionsDropdown
|
||||
documentFragmentRef={document}
|
||||
versionFragmentRef={currentVersion}
|
||||
onRefetch={onRefetch}
|
||||
@@ -223,7 +222,6 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
|
||||
ref={publishDialogRef}
|
||||
documentId={document.id}
|
||||
documentFragmentRef={document}
|
||||
hasPendingApproval={hasPendingApproval}
|
||||
onSuccess={handlePublishOrApproval}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { formatError } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Badge,
|
||||
@@ -24,8 +25,10 @@ import {
|
||||
Label,
|
||||
PropertyRow,
|
||||
useDialogRef,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { type ReactNode } from "react";
|
||||
import { useMutation } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -33,11 +36,11 @@ import type { CreateDocumentDialogMutation } from "#/__generated__/core/CreateDo
|
||||
import { ControlledField } from "#/components/form/ControlledField";
|
||||
import { DocumentClassificationOptions } from "#/components/form/DocumentClassificationOptions";
|
||||
import { DocumentTypeOptions } from "#/components/form/DocumentTypeOptions";
|
||||
import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField";
|
||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
type Props = {
|
||||
type CreateDocumentDialogProps = {
|
||||
trigger?: ReactNode;
|
||||
connection: string;
|
||||
};
|
||||
@@ -68,14 +71,16 @@ const documentSchema = z.object({
|
||||
title: z.string().min(1, "Title is required"),
|
||||
documentType: z.enum(["OTHER", "GOVERNANCE", "POLICY", "PROCEDURE", "PLAN", "REGISTER", "RECORD", "REPORT", "TEMPLATE"]),
|
||||
classification: z.enum(["PUBLIC", "INTERNAL", "CONFIDENTIAL", "SECRET"]),
|
||||
defaultApproverIds: z.array(z.string()),
|
||||
});
|
||||
|
||||
/**
|
||||
* Dialog to create or update a document
|
||||
*/
|
||||
export function CreateDocumentDialog({ trigger, connection }: Props) {
|
||||
export function CreateDocumentDialog({ trigger, connection }: CreateDocumentDialogProps) {
|
||||
const { __ } = useTranslate();
|
||||
const organizationId = useOrganizationId();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { control, handleSubmit, register, formState, reset } = useFormWithSchema(
|
||||
documentSchema,
|
||||
@@ -83,15 +88,16 @@ export function CreateDocumentDialog({ trigger, connection }: Props) {
|
||||
defaultValues: {
|
||||
documentType: "POLICY",
|
||||
classification: "INTERNAL",
|
||||
defaultApproverIds: [],
|
||||
},
|
||||
},
|
||||
);
|
||||
const errors = formState.errors ?? {};
|
||||
const [createDocument, isLoading]
|
||||
= useMutationWithToasts<CreateDocumentDialogMutation>(createDocumentMutation);
|
||||
= useMutation<CreateDocumentDialogMutation>(createDocumentMutation);
|
||||
|
||||
const onSubmit = async (data: z.infer<typeof documentSchema>) => {
|
||||
await createDocument({
|
||||
const onSubmit = (data: z.infer<typeof documentSchema>) => {
|
||||
createDocument({
|
||||
variables: {
|
||||
input: {
|
||||
...data,
|
||||
@@ -99,12 +105,18 @@ export function CreateDocumentDialog({ trigger, connection }: Props) {
|
||||
},
|
||||
connections: [connection],
|
||||
},
|
||||
successMessage: __("Document created successfully."),
|
||||
errorMessage: __("Failed to create document"),
|
||||
onSuccess: () => {
|
||||
onCompleted(_, errors) {
|
||||
if (errors?.length) {
|
||||
toast({ title: __("Error"), description: formatError(__("Failed to create document"), errors), variant: "error" });
|
||||
return;
|
||||
}
|
||||
toast({ title: __("Success"), description: __("Document created successfully."), variant: "success" });
|
||||
dialogRef.current?.close();
|
||||
reset();
|
||||
},
|
||||
onError(error) {
|
||||
toast({ title: __("Error"), description: error.message, variant: "error" });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -165,6 +177,15 @@ export function CreateDocumentDialog({ trigger, connection }: Props) {
|
||||
</ControlledField>
|
||||
</PropertyRow>
|
||||
|
||||
<PropertyRow label={__("Approvers")}>
|
||||
<PeopleMultiSelectField
|
||||
name="defaultApproverIds"
|
||||
control={control}
|
||||
organizationId={organizationId}
|
||||
placeholder={__("Add approvers...")}
|
||||
/>
|
||||
</PropertyRow>
|
||||
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
|
||||
@@ -23,12 +23,11 @@ import { ConnectionHandler, graphql } from "relay-runtime";
|
||||
import type { DocumentActionsDropdown_archiveMutation } from "#/__generated__/core/DocumentActionsDropdown_archiveMutation.graphql";
|
||||
import type { DocumentActionsDropdown_createDraftMutation } from "#/__generated__/core/DocumentActionsDropdown_createDraftMutation.graphql";
|
||||
import type { DocumentActionsDropdown_documentFragment$key } from "#/__generated__/core/DocumentActionsDropdown_documentFragment.graphql";
|
||||
import type { DocumentActionsDropdown_exportVersionMutation } from "#/__generated__/core/DocumentActionsDropdown_exportVersionMutation.graphql";
|
||||
import type { DocumentActionsDropdown_unarchiveMutation } from "#/__generated__/core/DocumentActionsDropdown_unarchiveMutation.graphql";
|
||||
import type { DocumentActionsDropdown_versionFragment$key } from "#/__generated__/core/DocumentActionsDropdown_versionFragment.graphql";
|
||||
import type { DocumentActionsDropdownn_exportVersionMutation } from "#/__generated__/core/DocumentActionsDropdownn_exportVersionMutation.graphql";
|
||||
import { PdfDownloadDialog, type PdfDownloadDialogRef } from "#/components/documents/PdfDownloadDialog";
|
||||
import { DocumentsConnectionKey, useDeleteDocumentMutation, useDeleteDraftDocumentVersionMutation } from "#/hooks/graph/DocumentGraph";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
import { CurrentUser } from "#/providers/CurrentUser";
|
||||
|
||||
@@ -129,7 +128,7 @@ const versionFragment = graphql`
|
||||
`;
|
||||
|
||||
const exportDocumentVersionMutation = graphql`
|
||||
mutation DocumentActionsDropdownn_exportVersionMutation(
|
||||
mutation DocumentActionsDropdown_exportVersionMutation(
|
||||
$input: ExportDocumentVersionPDFInput!
|
||||
) {
|
||||
exportDocumentVersionPDF(input: $input) {
|
||||
@@ -138,7 +137,7 @@ const exportDocumentVersionMutation = graphql`
|
||||
}
|
||||
`;
|
||||
|
||||
export function DocumentActionsDropdownn(props: {
|
||||
export function DocumentActionsDropdown(props: {
|
||||
documentFragmentRef: DocumentActionsDropdown_documentFragment$key;
|
||||
versionFragmentRef: DocumentActionsDropdown_versionFragment$key;
|
||||
onRefetch: () => void;
|
||||
@@ -158,7 +157,7 @@ export function DocumentActionsDropdownn(props: {
|
||||
const version = useFragment<DocumentActionsDropdown_versionFragment$key>(versionFragment, versionFragmentRef);
|
||||
|
||||
const lastVersion = document.versions.edges[0].node;
|
||||
const hasDraft = lastVersion.status === "DRAFT";
|
||||
const isLastVersionPublished = lastVersion.status === "PUBLISHED";
|
||||
const isDraft = version.status === "DRAFT";
|
||||
|
||||
const [createDraftDocumentVersion, isCreatingDraft]
|
||||
@@ -171,13 +170,7 @@ export function DocumentActionsDropdownn(props: {
|
||||
const [deleteDraftDocumentVersion, isDeletingDraft]
|
||||
= useDeleteDraftDocumentVersionMutation();
|
||||
const [exportDocumentVersion, isExporting]
|
||||
= useMutationWithToasts<DocumentActionsDropdownn_exportVersionMutation>(
|
||||
exportDocumentVersionMutation,
|
||||
{
|
||||
successMessage: __("PDF download started."),
|
||||
errorMessage: __("Failed to generate PDF"),
|
||||
},
|
||||
);
|
||||
= useMutation<DocumentActionsDropdown_exportVersionMutation>(exportDocumentVersionMutation);
|
||||
|
||||
const handleCreateDraft = () => {
|
||||
const connectionId = ConnectionHandler.getConnectionID(document.id, "DocumentversionsDropdownMenu_versions");
|
||||
@@ -205,7 +198,6 @@ export function DocumentActionsDropdownn(props: {
|
||||
void navigate(`/organizations/${organizationId}/documents/${document.id}/versions/${newVersionId}`);
|
||||
},
|
||||
onError(error) {
|
||||
console.log(error);
|
||||
toast({ title: __("Error"), description: error.message, variant: "error" });
|
||||
},
|
||||
});
|
||||
@@ -320,7 +312,7 @@ export function DocumentActionsDropdownn(props: {
|
||||
);
|
||||
};
|
||||
|
||||
const handleExportDocumentVersion = async (options: {
|
||||
const handleExportDocumentVersion = (options: {
|
||||
withWatermark: boolean;
|
||||
withSignatures: boolean;
|
||||
watermarkEmail?: string;
|
||||
@@ -333,10 +325,15 @@ export function DocumentActionsDropdownn(props: {
|
||||
&& options.watermarkEmail && { watermarkEmail: options.watermarkEmail }),
|
||||
};
|
||||
|
||||
await exportDocumentVersion({
|
||||
exportDocumentVersion({
|
||||
variables: { input },
|
||||
onCompleted: (data, errors) => {
|
||||
if (errors?.length) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: errors[0]?.message || __("Failed to generate PDF"),
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -349,6 +346,9 @@ export function DocumentActionsDropdownn(props: {
|
||||
window.document.body.removeChild(link);
|
||||
}
|
||||
},
|
||||
onError(error) {
|
||||
toast({ title: __("Error"), description: error.message, variant: "error" });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -356,12 +356,12 @@ export function DocumentActionsDropdownn(props: {
|
||||
<>
|
||||
<PdfDownloadDialog
|
||||
ref={pdfDownloadDialogRef}
|
||||
onDownload={options => void handleExportDocumentVersion(options)}
|
||||
onDownload={handleExportDocumentVersion}
|
||||
isLoading={isExporting}
|
||||
defaultEmail={defaultEmail}
|
||||
/>
|
||||
<ActionDropdown variant="secondary">
|
||||
{document.canUpdate && !hasDraft && (
|
||||
{document.canUpdate && isLastVersionPublished && (
|
||||
<DropdownItem
|
||||
onClick={handleCreateDraft}
|
||||
icon={IconPencil}
|
||||
@@ -388,7 +388,7 @@ export function DocumentActionsDropdownn(props: {
|
||||
>
|
||||
{__("Download PDF")}
|
||||
</DropdownItem>
|
||||
{document.canArchive && (
|
||||
{document.canArchive && document.status === "ACTIVE" && (
|
||||
<DropdownItem
|
||||
icon={IconArchive}
|
||||
disabled={isArchiving}
|
||||
@@ -397,7 +397,7 @@ export function DocumentActionsDropdownn(props: {
|
||||
{__("Archive document")}
|
||||
</DropdownItem>
|
||||
)}
|
||||
{document.canUnarchive && (
|
||||
{document.canUnarchive && document.status === "ARCHIVED" && (
|
||||
<DropdownItem
|
||||
icon={IconArchive}
|
||||
disabled={isUnarchiving}
|
||||
|
||||
@@ -21,19 +21,27 @@ import { graphql } from "relay-runtime";
|
||||
import { z } from "zod";
|
||||
|
||||
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_versionFragment$key } from "#/__generated__/core/DocumentLayoutDrawer_versionFragment.graphql";
|
||||
import type { DocumentLayoutDrawerMutation } from "#/__generated__/core/DocumentLayoutDrawerMutation.graphql";
|
||||
import { ControlledField } from "#/components/form/ControlledField";
|
||||
import { DocumentClassificationOptions } from "#/components/form/DocumentClassificationOptions";
|
||||
import { DocumentTypeOptions } from "#/components/form/DocumentTypeOptions";
|
||||
import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField";
|
||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
const documentFragment = graphql`
|
||||
fragment DocumentLayoutDrawer_documentFragment on Document {
|
||||
id
|
||||
status
|
||||
archivedAt
|
||||
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({
|
||||
documentType: z.enum(documentTypes),
|
||||
});
|
||||
@@ -80,6 +103,10 @@ const classificationSchema = z.object({
|
||||
classification: z.enum(documentClassifications),
|
||||
});
|
||||
|
||||
const approversSchema = z.object({
|
||||
approverIds: z.array(z.string()),
|
||||
});
|
||||
|
||||
export function DocumentLayoutDrawer(props: {
|
||||
documentFragmentRef: DocumentLayoutDrawer_documentFragment$key;
|
||||
versionFragmentRef: DocumentLayoutDrawer_versionFragment$key;
|
||||
@@ -87,9 +114,11 @@ export function DocumentLayoutDrawer(props: {
|
||||
const { documentFragmentRef, versionFragmentRef } = props;
|
||||
|
||||
const { __ } = useTranslate();
|
||||
const organizationId = useOrganizationId();
|
||||
|
||||
const [isEditingType, setIsEditingType] = useState(false);
|
||||
const [isEditingClassification, setIsEditingClassification] = useState(false);
|
||||
const [isEditingApprovers, setIsEditingApprovers] = useState(false);
|
||||
|
||||
const { toast } = useToast();
|
||||
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]
|
||||
= useMutation<DocumentLayoutDrawerMutation>(updateDocumentTypeMutation);
|
||||
|
||||
const [updateClassification, isUpdatingClassification]
|
||||
= useMutation<DocumentLayoutDrawer_updateClassificationMutation>(updateClassificationMutation);
|
||||
|
||||
const [updateApprovers, isUpdatingApprovers]
|
||||
= useMutation<DocumentLayoutDrawer_updateApproversMutation>(updateApproversMutation);
|
||||
|
||||
const handleUpdateDocumentType = (data: {
|
||||
documentType: (typeof documentTypes)[number];
|
||||
}) => {
|
||||
@@ -182,11 +227,74 @@ export function DocumentLayoutDrawer(props: {
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateApprovers = (data: { approverIds: string[] }) => {
|
||||
updateApprovers({
|
||||
variables: {
|
||||
input: {
|
||||
id: document.id,
|
||||
defaultApproverIds: data.approverIds,
|
||||
},
|
||||
},
|
||||
onCompleted: () => {
|
||||
setIsEditingApprovers(false);
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("Approvers updated successfully"),
|
||||
variant: "success",
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: __("Failed to update approvers"),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer>
|
||||
<div className="text-base text-txt-primary font-medium mb-4">
|
||||
{__("Properties")}
|
||||
</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")}>
|
||||
{isEditingType
|
||||
? (
|
||||
@@ -251,11 +359,11 @@ export function DocumentLayoutDrawer(props: {
|
||||
</PropertyRow>
|
||||
<PropertyRow label={__("Status")}>
|
||||
<Badge
|
||||
variant={isDraft ? "highlight" : "success"}
|
||||
variant={version.status === "PUBLISHED" ? "success" : version.status === "PENDING_APPROVAL" ? "warning" : "highlight"}
|
||||
size="md"
|
||||
className="gap-2"
|
||||
>
|
||||
{isDraft ? __("Draft") : __("Published")}
|
||||
{version.status === "PUBLISHED" ? __("Published") : version.status === "PENDING_APPROVAL" ? __("Pending approval") : __("Draft")}
|
||||
</Badge>
|
||||
</PropertyRow>
|
||||
<PropertyRow label={__("Version")}>
|
||||
|
||||
@@ -365,15 +365,14 @@ export function DocumentList(props: {
|
||||
</SortableTh>
|
||||
<Th className="w-32">{__("Classification")}</Th>
|
||||
<Th className="w-60">{__("Approvers")}</Th>
|
||||
<Th className="w-60">{__("Last update")}</Th>
|
||||
<Th className="w-20">{__("Approvals")}</Th>
|
||||
<Th className="w-40">{__("Last update")}</Th>
|
||||
<Th className="w-20">{__("Signatures")}</Th>
|
||||
{hasAnyAction && <Th className="w-18"></Th>}
|
||||
</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 gap-2 items-center">
|
||||
{sprintf(__("%s documents selected"), selection.length)}
|
||||
|
||||
@@ -28,6 +28,10 @@ const fragment = graphql`
|
||||
title
|
||||
updatedAt
|
||||
canDelete: permission(action: "core:document:delete")
|
||||
defaultApprovers {
|
||||
id
|
||||
fullName
|
||||
}
|
||||
recentVersions: versions(first: 2 orderBy: { field: CREATED_AT direction: DESC }) {
|
||||
edges {
|
||||
node {
|
||||
@@ -41,15 +45,8 @@ const fragment = graphql`
|
||||
edges {
|
||||
node {
|
||||
status
|
||||
decisions(first: 20) {
|
||||
decisions(first: 0) {
|
||||
totalCount
|
||||
edges {
|
||||
node {
|
||||
approver {
|
||||
fullName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
approvedDecisions: decisions(first: 0 filter: { states: [APPROVED] }) {
|
||||
totalCount
|
||||
@@ -96,29 +93,30 @@ export function DocumentListItem(props: {
|
||||
} = props;
|
||||
|
||||
const organizationId = useOrganizationId();
|
||||
const { __ } = useTranslate();
|
||||
const [deleteDocument] = useMutation<DocumentListItem_deleteMutation>(deleteDocumentMutation);
|
||||
const confirm = useConfirm();
|
||||
const document = useFragment<DocumentListItemFragment$key>(
|
||||
fragment,
|
||||
fragmentRef,
|
||||
);
|
||||
const lastVersion = document.recentVersions.edges[0].node;
|
||||
const approverQuorum = lastVersion.approvalQuorums?.edges?.[0]?.node
|
||||
?? document.recentVersions.edges[1]?.node.approvalQuorums?.edges?.[0]?.node;
|
||||
|
||||
const { __ } = useTranslate();
|
||||
const lastVersionEdge = document.recentVersions.edges[0];
|
||||
if (!lastVersionEdge) return null;
|
||||
const lastVersion = lastVersionEdge.node;
|
||||
|
||||
const statusVariant = {
|
||||
DRAFT: "neutral",
|
||||
PENDING_APPROVAL: "warning",
|
||||
PUBLISHED: "success",
|
||||
} as const;
|
||||
|
||||
const statusLabel = {
|
||||
DRAFT: __("Draft"),
|
||||
PENDING_APPROVAL: __("Pending approval"),
|
||||
PUBLISHED: __("Published"),
|
||||
} as const;
|
||||
|
||||
const [deleteDocument] = useMutation<DocumentListItem_deleteMutation>(deleteDocumentMutation);
|
||||
const confirm = useConfirm();
|
||||
|
||||
const handleDelete = () => {
|
||||
confirm(
|
||||
() =>
|
||||
@@ -172,23 +170,19 @@ export function DocumentListItem(props: {
|
||||
</Td>
|
||||
<Td className="w-60">
|
||||
{(() => {
|
||||
const decisions = approverQuorum?.decisions;
|
||||
if (!decisions?.edges.length) return "—";
|
||||
const names = decisions.edges.map(e => e.node.approver.fullName).join(", ");
|
||||
return decisions.totalCount > 20 ? `${names}...` : names;
|
||||
})()}
|
||||
</Td>
|
||||
<Td className="w-60">{formatDate(document.updatedAt)}</Td>
|
||||
<Td className="w-20">
|
||||
{(() => {
|
||||
const lastQuorum = lastVersion.approvalQuorums?.edges?.[0]?.node;
|
||||
return lastQuorum
|
||||
? lastQuorum.status === "REJECTED"
|
||||
? __("Rejected")
|
||||
: `${lastQuorum.approvedDecisions.totalCount}/${lastQuorum.decisions.totalCount}`
|
||||
: "—";
|
||||
if (lastVersion.status === "PENDING_APPROVAL") {
|
||||
const quorum = lastVersion.approvalQuorums?.edges?.[0]?.node;
|
||||
if (quorum) {
|
||||
if (quorum.status === "REJECTED") return __("Rejected");
|
||||
return `${quorum.approvedDecisions.totalCount}/${quorum.decisions.totalCount}`;
|
||||
}
|
||||
return "—";
|
||||
}
|
||||
if (!document.defaultApprovers.length) return "—";
|
||||
return document.defaultApprovers.map(a => a.fullName).join(", ");
|
||||
})()}
|
||||
</Td>
|
||||
<Td className="w-40">{formatDate(document.updatedAt)}</Td>
|
||||
<Td className="w-20">
|
||||
{lastVersion.signedSignatures.totalCount}
|
||||
/
|
||||
|
||||
@@ -78,6 +78,11 @@ export function DocumentVersionsDropdownItem(props: {
|
||||
{__("Draft")}
|
||||
</Badge>
|
||||
)}
|
||||
{version.status === "PENDING_APPROVAL" && (
|
||||
<Badge variant="warning" size="sm">
|
||||
{__("Pending approval")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-txt-secondary whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
{dateTimeFormat(version.publishedAt ?? version.updatedAt)}
|
||||
|
||||
@@ -21,12 +21,11 @@ import {
|
||||
DialogFooter,
|
||||
IconSend,
|
||||
IconUpload,
|
||||
IconWarning,
|
||||
Textarea,
|
||||
useDialogRef,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { type Ref, useImperativeHandle, useRef } from "react";
|
||||
import { type Ref, useImperativeHandle, useMemo, useRef } from "react";
|
||||
import { useFragment, useMutation } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
import { z } from "zod";
|
||||
@@ -43,36 +42,17 @@ export type PublishDialogRef = {
|
||||
open: () => void;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
type PublishDialogProps = {
|
||||
ref: Ref<PublishDialogRef>;
|
||||
documentId: string;
|
||||
documentFragmentRef: PublishDialog_documentFragment$key;
|
||||
hasPendingApproval: boolean;
|
||||
onSuccess: () => void;
|
||||
};
|
||||
|
||||
const documentFragment = graphql`
|
||||
fragment PublishDialog_documentFragment on Document {
|
||||
lastPublishedVersion: versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }, filter: { statuses: [PUBLISHED] }) {
|
||||
edges {
|
||||
node {
|
||||
approvalQuorums(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
|
||||
edges {
|
||||
node {
|
||||
decisions(first: 100) {
|
||||
edges {
|
||||
node {
|
||||
approver {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
defaultApprovers {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -147,24 +127,20 @@ export function PublishDialog({
|
||||
ref,
|
||||
documentId,
|
||||
documentFragmentRef,
|
||||
hasPendingApproval,
|
||||
onSuccess,
|
||||
}: Props) {
|
||||
}: PublishDialogProps) {
|
||||
const document = useFragment(documentFragment, documentFragmentRef);
|
||||
const { __ } = useTranslate();
|
||||
const { toast } = useToast();
|
||||
const organizationId = useOrganizationId();
|
||||
const dialogRef = useDialogRef();
|
||||
|
||||
const previousApproverIds = document.lastPublishedVersion.edges[0]
|
||||
?.node.approvalQuorums.edges[0]
|
||||
?.node.decisions?.edges.map(e => e.node.approver.id)
|
||||
?? [];
|
||||
|
||||
const schema = z.object({
|
||||
const publishSchema = useMemo(() => z.object({
|
||||
changelog: z.string().min(1, __("Changelog is required")),
|
||||
approverIds: z.array(z.string()),
|
||||
});
|
||||
}), [__]);
|
||||
|
||||
const defaultApproverIds = document.defaultApprovers.map(a => a.id);
|
||||
|
||||
const {
|
||||
control,
|
||||
@@ -173,7 +149,7 @@ export function PublishDialog({
|
||||
reset,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useFormWithSchema(schema, {
|
||||
} = useFormWithSchema(publishSchema, {
|
||||
defaultValues: {
|
||||
changelog: "",
|
||||
approverIds: [],
|
||||
@@ -184,7 +160,7 @@ export function PublishDialog({
|
||||
open: () => {
|
||||
reset({
|
||||
changelog: "",
|
||||
approverIds: previousApproverIds,
|
||||
approverIds: defaultApproverIds,
|
||||
});
|
||||
dialogRef.current?.open();
|
||||
},
|
||||
@@ -199,6 +175,7 @@ export function PublishDialog({
|
||||
|
||||
const isBusy = isPublishingMajor || isPublishingMinor || isRequesting;
|
||||
const approverIds = watch("approverIds");
|
||||
const hasApprovers = approverIds.length > 0;
|
||||
const actionRef = useRef<"publish" | "publish-minor" | "request-approval">("publish");
|
||||
|
||||
const onPublishCompleted = (_: unknown, errors: ReadonlyArray<{ message: string }> | null) => {
|
||||
@@ -223,7 +200,7 @@ export function PublishDialog({
|
||||
toast({ title: __("Error"), description: error.message, variant: "error" });
|
||||
};
|
||||
|
||||
const handlePublishMajor = (data: z.infer<typeof schema>) => {
|
||||
const handlePublishMajor = (data: z.infer<typeof publishSchema>) => {
|
||||
publishMajor({
|
||||
variables: { input: { documentId, changelog: data.changelog } },
|
||||
onCompleted: onPublishCompleted,
|
||||
@@ -231,7 +208,7 @@ export function PublishDialog({
|
||||
});
|
||||
};
|
||||
|
||||
const handlePublishMinor = (data: z.infer<typeof schema>) => {
|
||||
const handlePublishMinor = (data: z.infer<typeof publishSchema>) => {
|
||||
publishMinor({
|
||||
variables: { input: { documentId, changelog: data.changelog } },
|
||||
onCompleted: onPublishCompleted,
|
||||
@@ -239,7 +216,7 @@ export function PublishDialog({
|
||||
});
|
||||
};
|
||||
|
||||
const onRequestApproval = (data: z.infer<typeof schema>) => {
|
||||
const onRequestApproval = (data: z.infer<typeof publishSchema>) => {
|
||||
requestApproval({
|
||||
variables: {
|
||||
input: {
|
||||
@@ -276,12 +253,16 @@ export function PublishDialog({
|
||||
<Dialog className="max-w-xl" ref={dialogRef} title={__("Publish document")}>
|
||||
<form
|
||||
onSubmit={e => void handleSubmit((data) => {
|
||||
if (actionRef.current === "publish") {
|
||||
handlePublishMajor(data);
|
||||
} else if (actionRef.current === "publish-minor") {
|
||||
const action = actionRef.current;
|
||||
actionRef.current = "publish";
|
||||
if (action === "publish-minor") {
|
||||
handlePublishMinor(data);
|
||||
} else {
|
||||
} else if (action === "request-approval") {
|
||||
onRequestApproval(data);
|
||||
} else if (data.approverIds.length > 0) {
|
||||
onRequestApproval(data);
|
||||
} else {
|
||||
handlePublishMajor(data);
|
||||
}
|
||||
})(e)}
|
||||
>
|
||||
@@ -303,63 +284,64 @@ export function PublishDialog({
|
||||
<p className="text-xs text-txt-danger mt-1">{errors.changelog.message}</p>
|
||||
)}
|
||||
</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 className="text-sm font-medium text-txt-primary mb-1">
|
||||
{__("Request approval before publishing")}
|
||||
</div>
|
||||
<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.")}
|
||||
</p>
|
||||
<PeopleMultiSelectField
|
||||
name="approverIds"
|
||||
label={__("Approvers")}
|
||||
control={control}
|
||||
organizationId={organizationId}
|
||||
placeholder={__("Add approvers...")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-xs text-txt-secondary mb-3">
|
||||
{__("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>
|
||||
<PeopleMultiSelectField
|
||||
name="approverIds"
|
||||
label={__("Approvers")}
|
||||
control={control}
|
||||
organizationId={organizationId}
|
||||
placeholder={__("Add approvers...")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
icon={IconUpload}
|
||||
onClick={() => { actionRef.current = "publish-minor"; }}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{__("Publish as minor")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
icon={IconUpload}
|
||||
onClick={() => { actionRef.current = "publish"; }}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{__("Publish now")}
|
||||
</Button>
|
||||
{!hasPendingApproval && (
|
||||
<Button
|
||||
type="submit"
|
||||
icon={IconSend}
|
||||
onClick={() => { actionRef.current = "request-approval"; }}
|
||||
disabled={isBusy || approverIds.length === 0}
|
||||
>
|
||||
{__("Request approval")}
|
||||
</Button>
|
||||
)}
|
||||
{hasApprovers
|
||||
? (
|
||||
<>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
icon={IconUpload}
|
||||
onClick={() => { actionRef.current = "publish-minor"; }}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{__("Publish as minor")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
icon={IconSend}
|
||||
onClick={() => { actionRef.current = "request-approval"; }}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{__("Request approval")}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
icon={IconUpload}
|
||||
onClick={() => { actionRef.current = "publish-minor"; }}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{__("Publish as minor")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
icon={IconUpload}
|
||||
onClick={() => { actionRef.current = "publish"; }}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{__("Publish as major")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
@@ -156,9 +156,10 @@ export function PublishDocumentsDialog({
|
||||
<div className="space-y-4">
|
||||
<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">
|
||||
{__("This will publish the selected documents directly without requiring approval.")}
|
||||
</p>
|
||||
<div className="text-sm text-txt-warning space-y-1">
|
||||
<p>{__("Publishing as major will request approval for documents that have default approvers configured. Approvers will receive an email notification.")}</p>
|
||||
<p>{__("Documents already published and pending approval will be skipped.")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="changelog" className="text-sm font-medium text-txt-primary mb-1 block">
|
||||
|
||||
@@ -81,9 +81,8 @@ const versionFragment = graphql`
|
||||
|
||||
export function DocumentApprovalsPage(props: {
|
||||
queryRef: PreloadedQuery<DocumentApprovalsPageQuery>;
|
||||
onRefetch: () => void;
|
||||
}) {
|
||||
const { queryRef, onRefetch } = props;
|
||||
const { queryRef } = props;
|
||||
|
||||
const { document, version } = usePreloadedQuery<DocumentApprovalsPageQuery>(
|
||||
documentApprovalsPageQuery,
|
||||
@@ -109,7 +108,6 @@ export function DocumentApprovalsPage(props: {
|
||||
<DocumentApprovalsPageContent
|
||||
approvalListRef={approvalListRef}
|
||||
versionFragmentRef={versionFragmentRef}
|
||||
onRefetch={onRefetch}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
@@ -118,9 +116,8 @@ export function DocumentApprovalsPage(props: {
|
||||
function DocumentApprovalsPageContent(props: {
|
||||
approvalListRef: Parameters<typeof DocumentApprovalList>[0]["versionFragmentRef"];
|
||||
versionFragmentRef: DocumentApprovalsPage_versionFragment$key;
|
||||
onRefetch: () => void;
|
||||
}) {
|
||||
const { approvalListRef, versionFragmentRef, onRefetch } = props;
|
||||
const { approvalListRef, versionFragmentRef } = props;
|
||||
const { __, dateTimeFormat } = useTranslate();
|
||||
|
||||
const versionData = useFragment(versionFragment, versionFragmentRef);
|
||||
@@ -129,7 +126,7 @@ function DocumentApprovalsPageContent(props: {
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<DocumentApprovalList versionFragmentRef={approvalListRef} onRefetch={onRefetch} />
|
||||
<DocumentApprovalList versionFragmentRef={approvalListRef} />
|
||||
|
||||
{pastQuorums.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
@@ -137,8 +134,8 @@ function DocumentApprovalsPageContent(props: {
|
||||
{pastQuorums.map(({ node: quorum }) => (
|
||||
<div key={quorum.id} className="border border-border-solid rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Badge variant={quorum.status === "APPROVED" ? "success" : "danger"}>
|
||||
{quorum.status === "APPROVED" ? __("Approved") : __("Rejected")}
|
||||
<Badge variant={quorum.status === "APPROVED" ? "success" : quorum.status === "VOIDED" ? "neutral" : "danger"}>
|
||||
{quorum.status === "APPROVED" ? __("Approved") : quorum.status === "VOIDED" ? __("Voided") : __("Rejected")}
|
||||
</Badge>
|
||||
<span className="text-xs text-txt-secondary">
|
||||
{dateTimeFormat(quorum.createdAt)}
|
||||
@@ -159,8 +156,8 @@ function DocumentApprovalsPageContent(props: {
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<Badge variant={decision.state === "APPROVED" ? "success" : decision.state === "REJECTED" ? "danger" : "warning"}>
|
||||
{decision.state === "APPROVED" ? __("Approved") : decision.state === "REJECTED" ? __("Rejected") : __("Pending")}
|
||||
<Badge variant={decision.state === "APPROVED" ? "success" : decision.state === "REJECTED" ? "danger" : decision.state === "VOIDED" ? "neutral" : "warning"}>
|
||||
{decision.state === "APPROVED" ? __("Approved") : decision.state === "REJECTED" ? __("Rejected") : decision.state === "VOIDED" ? __("Voided") : __("Pending")}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,9 +27,8 @@ function DocumentApprovalsPageQueryLoader() {
|
||||
throw new Error(":documentId missing in route params");
|
||||
}
|
||||
|
||||
const { onRefetch: parentRefetch, approvalRequestedAt }
|
||||
const { approvalRequestedAt }
|
||||
= useOutletContext<{
|
||||
onRefetch: () => void;
|
||||
approvalRequestedAt?: number;
|
||||
}>();
|
||||
|
||||
@@ -48,16 +47,9 @@ function DocumentApprovalsPageQueryLoader() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!queryRef) {
|
||||
loadQuery(
|
||||
{
|
||||
documentId: documentId,
|
||||
versionId: versionId ?? "",
|
||||
versionSpecified: !!versionId,
|
||||
},
|
||||
{ fetchPolicy: "network-only" },
|
||||
);
|
||||
loadQueryParams();
|
||||
}
|
||||
}, [queryRef, documentId, versionId, loadQuery]);
|
||||
}, [queryRef, loadQueryParams]);
|
||||
|
||||
// Reload approvals data whenever a new approval round is requested from the layout
|
||||
useEffect(() => {
|
||||
@@ -66,16 +58,11 @@ function DocumentApprovalsPageQueryLoader() {
|
||||
}
|
||||
}, [approvalRequestedAt, loadQueryParams]);
|
||||
|
||||
const onRefetch = useCallback(() => {
|
||||
parentRefetch();
|
||||
loadQueryParams();
|
||||
}, [parentRefetch, loadQueryParams]);
|
||||
|
||||
if (!queryRef) {
|
||||
return <LinkCardSkeleton />;
|
||||
}
|
||||
|
||||
return <DocumentApprovalsPage queryRef={queryRef} onRefetch={onRefetch} />;
|
||||
return <DocumentApprovalsPage queryRef={queryRef} />;
|
||||
}
|
||||
|
||||
export default function DocumentApprovalsPageLoader() {
|
||||
|
||||
@@ -13,42 +13,27 @@
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
IconPlusSmall,
|
||||
useDialogRef,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { Suspense, useState } from "react";
|
||||
import { Badge, Button, IconCrossLargeX, useConfirm } from "@probo/ui";
|
||||
import { useFragment, useMutation } from "react-relay";
|
||||
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 { usePeople } from "#/hooks/graph/PeopleGraph";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
import type { DocumentApprovalList_voidMutation } from "#/__generated__/core/DocumentApprovalList_voidMutation.graphql";
|
||||
|
||||
import { DocumentApprovalListItem } from "./DocumentApprovalListItem";
|
||||
|
||||
const versionFragment = graphql`
|
||||
fragment DocumentApprovalList_versionFragment on DocumentVersion {
|
||||
id
|
||||
canAddApprover: permission(action: "core:document-version:add-approver")
|
||||
approvalQuorums(first: 100, orderBy: { field: CREATED_AT, direction: DESC }) {
|
||||
edges {
|
||||
node {
|
||||
status
|
||||
decisions(first: 100, orderBy: { field: CREATED_AT, direction: ASC })
|
||||
@connection(key: "DocumentApprovalList_decisions") {
|
||||
__id
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
approver {
|
||||
id
|
||||
}
|
||||
...DocumentApprovalListItemFragment
|
||||
}
|
||||
}
|
||||
@@ -59,20 +44,21 @@ const versionFragment = graphql`
|
||||
}
|
||||
`;
|
||||
|
||||
const addApproverMutation = graphql`
|
||||
mutation DocumentApprovalList_addApproverMutation(
|
||||
$input: AddDocumentVersionApproverInput!
|
||||
$connections: [ID!]!
|
||||
const voidMutation = graphql`
|
||||
mutation DocumentApprovalList_voidMutation(
|
||||
$input: VoidDocumentVersionApprovalInput!
|
||||
) {
|
||||
addDocumentVersionApprover(input: $input) {
|
||||
approvalDecisionEdge @appendEdge(connections: $connections) {
|
||||
node {
|
||||
id
|
||||
approver {
|
||||
id
|
||||
}
|
||||
...DocumentApprovalListItemFragment
|
||||
}
|
||||
voidDocumentVersionApproval(input: $input) {
|
||||
documentVersion {
|
||||
id
|
||||
status
|
||||
major
|
||||
minor
|
||||
...DocumentApprovalList_versionFragment
|
||||
}
|
||||
approvalQuorum {
|
||||
id
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,33 +66,82 @@ const addApproverMutation = graphql`
|
||||
|
||||
export function DocumentApprovalList(props: {
|
||||
versionFragmentRef: DocumentApprovalList_versionFragment$key;
|
||||
onRefetch: () => void;
|
||||
}) {
|
||||
const { versionFragmentRef, onRefetch } = props;
|
||||
const { versionFragmentRef } = props;
|
||||
const { __ } = useTranslate();
|
||||
|
||||
const version = useFragment(versionFragment, versionFragmentRef);
|
||||
const dialogRef = useDialogRef();
|
||||
const canManage = version.canAddApprover;
|
||||
|
||||
const lastQuorum = version.approvalQuorums?.edges?.[0]?.node ?? null;
|
||||
const decisions = lastQuorum?.decisions;
|
||||
const edges = decisions?.edges ?? [];
|
||||
const existingApproverIds = edges.map(({ node }) => node.approver.id);
|
||||
const isPending = lastQuorum?.status === "PENDING";
|
||||
const edges = lastQuorum?.decisions?.edges ?? [];
|
||||
|
||||
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 (
|
||||
<div>
|
||||
{canManage && (
|
||||
<div className="flex justify-end pb-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={IconPlusSmall}
|
||||
onClick={() => dialogRef.current?.open()}
|
||||
>
|
||||
{__("Add approver")}
|
||||
</Button>
|
||||
{lastQuorum && (
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Badge variant={statusVariant[lastQuorum.status]}>
|
||||
{statusLabel[lastQuorum.status]}
|
||||
</Badge>
|
||||
{isPending && (
|
||||
<Button
|
||||
variant="quaternary"
|
||||
icon={IconCrossLargeX}
|
||||
onClick={handleVoid}
|
||||
disabled={isVoiding}
|
||||
>
|
||||
{__("Cancel")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{edges.length === 0
|
||||
? (
|
||||
<div className="text-sm text-txt-secondary text-center py-8">
|
||||
@@ -119,108 +154,10 @@ export function DocumentApprovalList(props: {
|
||||
<DocumentApprovalListItem
|
||||
key={node.id}
|
||||
fragmentRef={node}
|
||||
canManage={canManage}
|
||||
connectionId={decisions?.__id ?? ""}
|
||||
onRefetch={onRefetch}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,14 +20,10 @@ import {
|
||||
IconCircleCheck,
|
||||
IconCircleX,
|
||||
IconClock,
|
||||
IconTrashCan,
|
||||
Spinner,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { useFragment, useMutation } from "react-relay";
|
||||
import { useFragment } from "react-relay";
|
||||
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 { 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: {
|
||||
fragmentRef: DocumentApprovalListItemFragment$key;
|
||||
canManage: boolean;
|
||||
connectionId: string;
|
||||
onRefetch: () => void;
|
||||
}) {
|
||||
const { fragmentRef, canManage, connectionId, onRefetch } = props;
|
||||
const { fragmentRef } = props;
|
||||
const { __, dateTimeFormat } = useTranslate();
|
||||
const { toast } = useToast();
|
||||
const organizationId = useOrganizationId();
|
||||
|
||||
const decision = useFragment(fragment, fragmentRef);
|
||||
@@ -96,10 +60,7 @@ export function DocumentApprovalListItem(props: {
|
||||
const isPending = decision.state === "PENDING";
|
||||
const isApproved = decision.state === "APPROVED";
|
||||
const isRejected = decision.state === "REJECTED";
|
||||
|
||||
const [removeApprover, isRemoving] = useMutation<DocumentApprovalListItem_removeApproverMutation>(
|
||||
removeApproverMutation,
|
||||
);
|
||||
const isVoided = decision.state === "VOIDED";
|
||||
|
||||
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" />}
|
||||
{isRejected && <IconCircleX size={16} className="text-txt-danger" />}
|
||||
{isPending && <IconClock size={16} />}
|
||||
{isVoided && <IconClock size={16} className="text-txt-secondary" />}
|
||||
<span>
|
||||
{isPending && sprintf(__("Requested on %s"), dateTimeFormat(decision.createdAt))}
|
||||
{isApproved && sprintf(__("Approved on %s"), dateTimeFormat(decision.decidedAt))}
|
||||
{isRejected && sprintf(__("Rejected on %s"), dateTimeFormat(decision.decidedAt))}
|
||||
{isVoided && sprintf(__("Requested on %s"), dateTimeFormat(decision.createdAt))}
|
||||
</span>
|
||||
</div>
|
||||
{decision.comment && (
|
||||
@@ -137,49 +100,12 @@ export function DocumentApprovalListItem(props: {
|
||||
{__("Review")}
|
||||
</Button>
|
||||
)}
|
||||
{canManage && (
|
||||
<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 && (
|
||||
{isPending && !decision.canApprove && !decision.canReject && (
|
||||
<Badge variant="warning">{__("Pending")}</Badge>
|
||||
)}
|
||||
{isVoided && (
|
||||
<Badge variant="neutral">{__("Voided")}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// 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 { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
@@ -173,6 +173,7 @@ function VersionRow({
|
||||
const state = approvalDecision?.state;
|
||||
const isApproved = state === "APPROVED";
|
||||
const isRejected = state === "REJECTED";
|
||||
const isVoided = state === "VOIDED";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -189,7 +190,9 @@ function VersionRow({
|
||||
? <IconCircleCheck size={20} className="text-txt-success" />
|
||||
: isRejected
|
||||
? <IconCircleX size={20} className="text-txt-danger" />
|
||||
: <IconRadioUnchecked size={20} className="text-txt-tertiary" />}
|
||||
: isVoided
|
||||
? <IconRadioUnchecked size={20} className="text-txt-secondary" />
|
||||
: <IconRadioUnchecked size={20} className="text-txt-tertiary" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
@@ -199,13 +202,7 @@ function VersionRow({
|
||||
)}
|
||||
>
|
||||
{versionData.publishedAt
|
||||
? `v${versionData.major}.${versionData.minor} - ${(() => {
|
||||
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} - ${formatDate(versionData.publishedAt)}`
|
||||
: `v${versionData.major}.${versionData.minor}`}
|
||||
</p>
|
||||
</div>
|
||||
@@ -214,9 +211,11 @@ function VersionRow({
|
||||
? <Badge variant="success">{__("Approved")}</Badge>
|
||||
: isRejected
|
||||
? <Badge variant="danger">{__("Rejected")}</Badge>
|
||||
: isSelected
|
||||
? <Badge variant="info">{__("In review")}</Badge>
|
||||
: <Badge variant="warning">{__("Pending")}</Badge>}
|
||||
: isVoided
|
||||
? <Badge variant="neutral">{__("Voided")}</Badge>
|
||||
: isSelected
|
||||
? <Badge variant="info">{__("In review")}</Badge>
|
||||
: <Badge variant="warning">{__("Pending")}</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -245,6 +244,20 @@ function ViewerDecision(props: {
|
||||
const isPending = decision.state === "PENDING";
|
||||
const isApproved = decision.state === "APPROVED";
|
||||
const isRejected = decision.state === "REJECTED";
|
||||
const isVoided = decision.state === "VOIDED";
|
||||
|
||||
if (isVoided) {
|
||||
return (
|
||||
<>
|
||||
<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) {
|
||||
return (
|
||||
@@ -484,10 +497,7 @@ function DocumentApproveContent({
|
||||
}, [selectedVersion?.id, exportPDF, toast, __]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed bg-level-2 flex flex-col"
|
||||
style={{ top: "3rem", left: 0, right: 0, bottom: 0 }}
|
||||
>
|
||||
<div className="fixed inset-0 top-12 bg-level-2 flex flex-col">
|
||||
<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">
|
||||
<h1 className="text-2xl font-semibold mb-6">
|
||||
|
||||
@@ -18,7 +18,6 @@ import { Card, Tbody, Th, Thead, Tr } from "@probo/ui";
|
||||
import { graphql, type PreloadedQuery, usePreloadedQuery } from "react-relay";
|
||||
|
||||
import type { EmployeeApprovalsPageQuery } from "#/__generated__/core/EmployeeApprovalsPageQuery.graphql";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
import { ApprovableDocumentRow } from "./_components/ApprovableDocumentRow";
|
||||
|
||||
@@ -46,7 +45,6 @@ export function EmployeeApprovalsPage(props: {
|
||||
}) {
|
||||
const { queryRef } = props;
|
||||
const { __ } = useTranslate();
|
||||
const organizationId = useOrganizationId();
|
||||
|
||||
const {
|
||||
viewer: { approvableDocuments },
|
||||
@@ -79,7 +77,6 @@ export function EmployeeApprovalsPage(props: {
|
||||
<ApprovableDocumentRow
|
||||
key={document.id}
|
||||
fKey={document}
|
||||
organizationId={organizationId}
|
||||
/>
|
||||
))}
|
||||
</Tbody>
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Badge, Td, Tr } from "@probo/ui";
|
||||
import { graphql, useFragment } from "react-relay";
|
||||
|
||||
import type { ApprovableDocumentRowFragment$key } from "#/__generated__/core/ApprovableDocumentRowFragment.graphql";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
const fragment = graphql`
|
||||
fragment ApprovableDocumentRowFragment on EmployeeDocument {
|
||||
@@ -42,26 +43,32 @@ const fragment = graphql`
|
||||
|
||||
export function ApprovableDocumentRow({
|
||||
fKey,
|
||||
organizationId,
|
||||
}: {
|
||||
fKey: ApprovableDocumentRowFragment$key;
|
||||
organizationId: string;
|
||||
}) {
|
||||
const document = useFragment<ApprovableDocumentRowFragment$key>(fragment, fKey);
|
||||
const lastVersion = document.lastVersion.edges[0].node;
|
||||
const organizationId = useOrganizationId();
|
||||
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"
|
||||
? "success"
|
||||
: document.approvalState === "REJECTED"
|
||||
? "danger"
|
||||
: "warning";
|
||||
: document.approvalState === "VOIDED"
|
||||
? "neutral"
|
||||
: "warning";
|
||||
|
||||
const stateLabel = document.approvalState === "APPROVED"
|
||||
? __("Approved")
|
||||
: document.approvalState === "REJECTED"
|
||||
? __("Rejected")
|
||||
: __("Pending");
|
||||
: document.approvalState === "VOIDED"
|
||||
? __("No longer required")
|
||||
: __("Pending");
|
||||
|
||||
return (
|
||||
<Tr to={`/organizations/${organizationId}/employee/approvals/${document.id}`}>
|
||||
|
||||
@@ -353,15 +353,49 @@ func TestDocumentVersion_RequestSignature(t *testing.T) {
|
||||
assert.Equal(t, signerProfileID, result.RequestSignature.DocumentVersionSignatureEdge.Node.SignedBy.ID)
|
||||
}
|
||||
|
||||
func createTestDocumentWithApprovers(t *testing.T, owner *testutil.Client, approverIDs []string) (docID string) {
|
||||
t.Helper()
|
||||
|
||||
var result struct {
|
||||
CreateDocument struct {
|
||||
DocumentEdge struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"node"`
|
||||
} `json:"documentEdge"`
|
||||
} `json:"createDocument"`
|
||||
}
|
||||
|
||||
err := owner.Execute(`
|
||||
mutation($input: CreateDocumentInput!) {
|
||||
createDocument(input: $input) {
|
||||
documentEdge {
|
||||
node { id }
|
||||
}
|
||||
}
|
||||
}
|
||||
`, map[string]any{
|
||||
"input": map[string]any{
|
||||
"organizationId": owner.GetOrganizationID().String(),
|
||||
"title": "Test Document With Approvers",
|
||||
"content": testutil.ProseMirrorTextDoc("Initial content"),
|
||||
"documentType": "POLICY",
|
||||
"classification": "INTERNAL",
|
||||
"defaultApproverIds": approverIDs,
|
||||
},
|
||||
}, &result)
|
||||
require.NoError(t, err)
|
||||
|
||||
return result.CreateDocument.DocumentEdge.Node.ID
|
||||
}
|
||||
|
||||
func TestDocumentVersion_BulkPublish(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
// Create multiple documents
|
||||
// Create multiple draft documents (no default approvers — should publish directly)
|
||||
docID1, _ := createTestDocument(t, owner)
|
||||
docID2, _ := createTestDocument(t, owner)
|
||||
approveTestDocument(t, owner, docID1)
|
||||
approveTestDocument(t, owner, docID2)
|
||||
|
||||
query := `
|
||||
mutation BulkPublishMajorDocumentVersions($input: BulkPublishDocumentVersionsInput!) {
|
||||
@@ -397,6 +431,170 @@ func TestDocumentVersion_BulkPublish(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentVersion_BulkPublishRequestsApproval(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
approverID := getOwnerProfileID(t, owner)
|
||||
|
||||
// Create a document with default approvers
|
||||
docID := createTestDocumentWithApprovers(t, owner, []string{approverID})
|
||||
|
||||
query := `
|
||||
mutation BulkPublishMajorDocumentVersions($input: BulkPublishDocumentVersionsInput!) {
|
||||
bulkPublishMajorDocumentVersions(input: $input) {
|
||||
documentVersions {
|
||||
id
|
||||
status
|
||||
major
|
||||
minor
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
var result struct {
|
||||
BulkPublishMajorDocumentVersions struct {
|
||||
DocumentVersions []struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Major int `json:"major"`
|
||||
Minor int `json:"minor"`
|
||||
} `json:"documentVersions"`
|
||||
} `json:"bulkPublishMajorDocumentVersions"`
|
||||
}
|
||||
|
||||
err := owner.Execute(query, map[string]any{
|
||||
"input": map[string]any{
|
||||
"documentIds": []string{docID},
|
||||
"changelog": "Needs approval",
|
||||
},
|
||||
}, &result)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, result.BulkPublishMajorDocumentVersions.DocumentVersions, 1)
|
||||
dv := result.BulkPublishMajorDocumentVersions.DocumentVersions[0]
|
||||
assert.Equal(t, "PENDING_APPROVAL", dv.Status)
|
||||
assert.Equal(t, 1, dv.Major)
|
||||
assert.Equal(t, 0, dv.Minor)
|
||||
}
|
||||
|
||||
func TestDocumentVersion_BulkPublishSkipsPendingApproval(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
approverID := getOwnerProfileID(t, owner)
|
||||
|
||||
// Create a document with default approvers and bulk publish it (puts it in PENDING_APPROVAL)
|
||||
docID := createTestDocumentWithApprovers(t, owner, []string{approverID})
|
||||
|
||||
_, err := owner.Do(`
|
||||
mutation($input: BulkPublishDocumentVersionsInput!) {
|
||||
bulkPublishMajorDocumentVersions(input: $input) {
|
||||
documentVersions { id }
|
||||
}
|
||||
}
|
||||
`, map[string]any{
|
||||
"input": map[string]any{
|
||||
"documentIds": []string{docID},
|
||||
"changelog": "First approval request",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Bulk publish again — should skip the pending document and return empty
|
||||
var result struct {
|
||||
BulkPublishMajorDocumentVersions struct {
|
||||
DocumentVersions []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"documentVersions"`
|
||||
} `json:"bulkPublishMajorDocumentVersions"`
|
||||
}
|
||||
|
||||
err = owner.Execute(`
|
||||
mutation($input: BulkPublishDocumentVersionsInput!) {
|
||||
bulkPublishMajorDocumentVersions(input: $input) {
|
||||
documentVersions { id }
|
||||
}
|
||||
}
|
||||
`, map[string]any{
|
||||
"input": map[string]any{
|
||||
"documentIds": []string{docID},
|
||||
"changelog": "Second attempt",
|
||||
},
|
||||
}, &result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Empty(t, result.BulkPublishMajorDocumentVersions.DocumentVersions)
|
||||
}
|
||||
|
||||
func TestDocumentVersion_BulkPublishMinorSkipsPendingApproval(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
// Create and publish a document first (need a published version for minor publish)
|
||||
docID, _ := createTestDocument(t, owner)
|
||||
approveTestDocument(t, owner, docID)
|
||||
|
||||
// Create a draft so we can publish minor
|
||||
_, err := owner.Do(`
|
||||
mutation($input: CreateDraftDocumentVersionInput!) {
|
||||
createDraftDocumentVersion(input: $input) {
|
||||
documentVersionEdge {
|
||||
node { id }
|
||||
}
|
||||
}
|
||||
}
|
||||
`, map[string]any{
|
||||
"input": map[string]any{
|
||||
"documentID": docID,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Request approval to put it in PENDING_APPROVAL
|
||||
approverID := getOwnerProfileID(t, owner)
|
||||
_, err = owner.Do(`
|
||||
mutation($input: RequestDocumentVersionApprovalInput!) {
|
||||
requestDocumentVersionApproval(input: $input) {
|
||||
approvalQuorum { id }
|
||||
}
|
||||
}
|
||||
`, map[string]any{
|
||||
"input": map[string]any{
|
||||
"documentId": docID,
|
||||
"approverIds": []string{approverID},
|
||||
"changelog": "Approval request",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Bulk publish minor — should skip the pending document
|
||||
var result struct {
|
||||
BulkPublishMinorDocumentVersions struct {
|
||||
DocumentVersions []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"documentVersions"`
|
||||
} `json:"bulkPublishMinorDocumentVersions"`
|
||||
}
|
||||
|
||||
err = owner.Execute(`
|
||||
mutation($input: BulkPublishDocumentVersionsInput!) {
|
||||
bulkPublishMinorDocumentVersions(input: $input) {
|
||||
documentVersions { id }
|
||||
}
|
||||
}
|
||||
`, map[string]any{
|
||||
"input": map[string]any{
|
||||
"documentIds": []string{docID},
|
||||
"changelog": "Minor publish attempt",
|
||||
},
|
||||
}, &result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Empty(t, result.BulkPublishMinorDocumentVersions.DocumentVersions)
|
||||
}
|
||||
|
||||
func TestDocumentVersion_BulkRequestSignatures(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
@@ -480,3 +678,468 @@ func TestDocumentVersion_BulkDelete(t *testing.T) {
|
||||
assert.Contains(t, result.BulkDeleteDocuments.DeletedDocumentIds, docID1)
|
||||
assert.Contains(t, result.BulkDeleteDocuments.DeletedDocumentIds, docID2)
|
||||
}
|
||||
|
||||
func TestDocumentVersion_VoidApproval(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
docID, _ := createTestDocument(t, owner)
|
||||
approverID := getOwnerProfileID(t, owner)
|
||||
|
||||
// Request approval
|
||||
_, err := owner.Do(`
|
||||
mutation($input: RequestDocumentVersionApprovalInput!) {
|
||||
requestDocumentVersionApproval(input: $input) {
|
||||
approvalQuorum { id }
|
||||
}
|
||||
}
|
||||
`, map[string]any{
|
||||
"input": map[string]any{
|
||||
"documentId": docID,
|
||||
"approverIds": []string{approverID},
|
||||
"changelog": "Test changelog",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get version ID and verify version bumped to 1.0
|
||||
var versionResult struct {
|
||||
Node struct {
|
||||
Versions struct {
|
||||
Edges []struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Major int `json:"major"`
|
||||
Minor int `json:"minor"`
|
||||
} `json:"node"`
|
||||
} `json:"edges"`
|
||||
} `json:"versions"`
|
||||
} `json:"node"`
|
||||
}
|
||||
|
||||
err = owner.Execute(`
|
||||
query($id: ID!) {
|
||||
node(id: $id) {
|
||||
... on Document {
|
||||
versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
|
||||
edges { node { id status major minor } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`, map[string]any{"id": docID}, &versionResult)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, versionResult.Node.Versions.Edges)
|
||||
assert.Equal(t, "PENDING_APPROVAL", versionResult.Node.Versions.Edges[0].Node.Status)
|
||||
assert.Equal(t, 1, versionResult.Node.Versions.Edges[0].Node.Major)
|
||||
assert.Equal(t, 0, versionResult.Node.Versions.Edges[0].Node.Minor)
|
||||
|
||||
versionID := versionResult.Node.Versions.Edges[0].Node.ID
|
||||
|
||||
// Void approval — version should revert to 0.1
|
||||
var voidResult struct {
|
||||
VoidDocumentVersionApproval struct {
|
||||
ApprovalQuorum struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
} `json:"approvalQuorum"`
|
||||
DocumentVersion struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Major int `json:"major"`
|
||||
Minor int `json:"minor"`
|
||||
} `json:"documentVersion"`
|
||||
} `json:"voidDocumentVersionApproval"`
|
||||
}
|
||||
|
||||
err = owner.Execute(`
|
||||
mutation($input: VoidDocumentVersionApprovalInput!) {
|
||||
voidDocumentVersionApproval(input: $input) {
|
||||
approvalQuorum { id status }
|
||||
documentVersion { id status major minor }
|
||||
}
|
||||
}
|
||||
`, map[string]any{
|
||||
"input": map[string]any{
|
||||
"documentVersionId": versionID,
|
||||
},
|
||||
}, &voidResult)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "VOIDED", voidResult.VoidDocumentVersionApproval.ApprovalQuorum.Status)
|
||||
assert.Equal(t, "DRAFT", voidResult.VoidDocumentVersionApproval.DocumentVersion.Status)
|
||||
assert.Equal(t, 0, voidResult.VoidDocumentVersionApproval.DocumentVersion.Major)
|
||||
assert.Equal(t, 1, voidResult.VoidDocumentVersionApproval.DocumentVersion.Minor)
|
||||
|
||||
// Verify decisions are VOIDED after voiding
|
||||
var quorumResult struct {
|
||||
Node struct {
|
||||
Versions struct {
|
||||
Edges []struct {
|
||||
Node struct {
|
||||
ApprovalQuorums struct {
|
||||
Edges []struct {
|
||||
Node struct {
|
||||
Decisions struct {
|
||||
Edges []struct {
|
||||
Node struct {
|
||||
State string `json:"state"`
|
||||
} `json:"node"`
|
||||
} `json:"edges"`
|
||||
} `json:"decisions"`
|
||||
} `json:"node"`
|
||||
} `json:"edges"`
|
||||
} `json:"approvalQuorums"`
|
||||
} `json:"node"`
|
||||
} `json:"edges"`
|
||||
} `json:"versions"`
|
||||
} `json:"node"`
|
||||
}
|
||||
|
||||
err = owner.Execute(`
|
||||
query($id: ID!) {
|
||||
node(id: $id) {
|
||||
... on Document {
|
||||
versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
|
||||
edges {
|
||||
node {
|
||||
approvalQuorums(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
|
||||
edges {
|
||||
node {
|
||||
decisions(first: 100) {
|
||||
edges { node { state } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`, map[string]any{"id": docID}, &quorumResult)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, quorumResult.Node.Versions.Edges)
|
||||
require.NotEmpty(t, quorumResult.Node.Versions.Edges[0].Node.ApprovalQuorums.Edges)
|
||||
decisions := quorumResult.Node.Versions.Edges[0].Node.ApprovalQuorums.Edges[0].Node.Decisions.Edges
|
||||
require.NotEmpty(t, decisions)
|
||||
for _, d := range decisions {
|
||||
assert.Equal(t, "VOIDED", d.Node.State, "decisions should be VOIDED after voiding")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentVersion_RejectApproval(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
docID, _ := createTestDocument(t, owner)
|
||||
approverID := getOwnerProfileID(t, owner)
|
||||
|
||||
// Request approval — version should bump to 1.0
|
||||
_, err := owner.Do(`
|
||||
mutation($input: RequestDocumentVersionApprovalInput!) {
|
||||
requestDocumentVersionApproval(input: $input) {
|
||||
approvalQuorum { id }
|
||||
}
|
||||
}
|
||||
`, map[string]any{
|
||||
"input": map[string]any{
|
||||
"documentId": docID,
|
||||
"approverIds": []string{approverID},
|
||||
"changelog": "Test changelog",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get version ID
|
||||
var versionResult struct {
|
||||
Node struct {
|
||||
Versions struct {
|
||||
Edges []struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Major int `json:"major"`
|
||||
Minor int `json:"minor"`
|
||||
} `json:"node"`
|
||||
} `json:"edges"`
|
||||
} `json:"versions"`
|
||||
} `json:"node"`
|
||||
}
|
||||
|
||||
err = owner.Execute(`
|
||||
query($id: ID!) {
|
||||
node(id: $id) {
|
||||
... on Document {
|
||||
versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
|
||||
edges { node { id status major minor } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`, map[string]any{"id": docID}, &versionResult)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, versionResult.Node.Versions.Edges)
|
||||
assert.Equal(t, "PENDING_APPROVAL", versionResult.Node.Versions.Edges[0].Node.Status)
|
||||
assert.Equal(t, 1, versionResult.Node.Versions.Edges[0].Node.Major)
|
||||
assert.Equal(t, 0, versionResult.Node.Versions.Edges[0].Node.Minor)
|
||||
|
||||
versionID := versionResult.Node.Versions.Edges[0].Node.ID
|
||||
|
||||
// Reject approval
|
||||
_, err = owner.Do(`
|
||||
mutation($input: RejectDocumentVersionInput!) {
|
||||
rejectDocumentVersion(input: $input) {
|
||||
approvalDecision { id state }
|
||||
}
|
||||
}
|
||||
`, map[string]any{
|
||||
"input": map[string]any{
|
||||
"documentVersionId": versionID,
|
||||
"comment": "Needs rework",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify version reverted to 0.1 DRAFT
|
||||
err = owner.Execute(`
|
||||
query($id: ID!) {
|
||||
node(id: $id) {
|
||||
... on Document {
|
||||
versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
|
||||
edges { node { id status major minor } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`, map[string]any{"id": docID}, &versionResult)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, versionResult.Node.Versions.Edges)
|
||||
assert.Equal(t, "DRAFT", versionResult.Node.Versions.Edges[0].Node.Status)
|
||||
assert.Equal(t, 0, versionResult.Node.Versions.Edges[0].Node.Major)
|
||||
assert.Equal(t, 1, versionResult.Node.Versions.Edges[0].Node.Minor)
|
||||
|
||||
// Verify decisions are VOIDED after reject
|
||||
var quorumResult struct {
|
||||
Node struct {
|
||||
Versions struct {
|
||||
Edges []struct {
|
||||
Node struct {
|
||||
ApprovalQuorums struct {
|
||||
Edges []struct {
|
||||
Node struct {
|
||||
Decisions struct {
|
||||
Edges []struct {
|
||||
Node struct {
|
||||
State string `json:"state"`
|
||||
} `json:"node"`
|
||||
} `json:"edges"`
|
||||
} `json:"decisions"`
|
||||
} `json:"node"`
|
||||
} `json:"edges"`
|
||||
} `json:"approvalQuorums"`
|
||||
} `json:"node"`
|
||||
} `json:"edges"`
|
||||
} `json:"versions"`
|
||||
} `json:"node"`
|
||||
}
|
||||
|
||||
err = owner.Execute(`
|
||||
query($id: ID!) {
|
||||
node(id: $id) {
|
||||
... on Document {
|
||||
versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
|
||||
edges {
|
||||
node {
|
||||
approvalQuorums(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
|
||||
edges {
|
||||
node {
|
||||
decisions(first: 100) {
|
||||
edges { node { state } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`, map[string]any{"id": docID}, &quorumResult)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, quorumResult.Node.Versions.Edges)
|
||||
require.NotEmpty(t, quorumResult.Node.Versions.Edges[0].Node.ApprovalQuorums.Edges)
|
||||
decisions := quorumResult.Node.Versions.Edges[0].Node.ApprovalQuorums.Edges[0].Node.Decisions.Edges
|
||||
require.Len(t, decisions, 1)
|
||||
assert.Equal(t, "REJECTED", decisions[0].Node.State, "rejecting approver's decision should be REJECTED")
|
||||
}
|
||||
|
||||
func TestDocumentVersion_PublishBlockedWhenPendingApproval(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
docID, _ := createTestDocument(t, owner)
|
||||
approverID := getOwnerProfileID(t, owner)
|
||||
|
||||
// Request approval (puts version in PENDING_APPROVAL)
|
||||
_, err := owner.Do(`
|
||||
mutation($input: RequestDocumentVersionApprovalInput!) {
|
||||
requestDocumentVersionApproval(input: $input) {
|
||||
approvalQuorum { id }
|
||||
}
|
||||
}
|
||||
`, map[string]any{
|
||||
"input": map[string]any{
|
||||
"documentId": docID,
|
||||
"approverIds": []string{approverID},
|
||||
"changelog": "Test changelog",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("publish major blocked", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := owner.Do(`
|
||||
mutation($input: PublishMajorDocumentVersionInput!) {
|
||||
publishMajorDocumentVersion(input: $input) {
|
||||
documentVersion { id }
|
||||
}
|
||||
}
|
||||
`, map[string]any{
|
||||
"input": map[string]any{
|
||||
"documentId": docID,
|
||||
"changelog": "Major release",
|
||||
},
|
||||
})
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("publish minor blocked", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := owner.Do(`
|
||||
mutation($input: PublishMinorDocumentVersionInput!) {
|
||||
publishMinorDocumentVersion(input: $input) {
|
||||
documentVersion { id }
|
||||
}
|
||||
}
|
||||
`, map[string]any{
|
||||
"input": map[string]any{
|
||||
"documentId": docID,
|
||||
"changelog": "Minor release",
|
||||
},
|
||||
})
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDocument_DefaultApprovers(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
approverID := getOwnerProfileID(t, owner)
|
||||
|
||||
t.Run("create document with default approvers", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var result struct {
|
||||
CreateDocument struct {
|
||||
DocumentEdge struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
DefaultApprovers []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"defaultApprovers"`
|
||||
} `json:"node"`
|
||||
} `json:"documentEdge"`
|
||||
} `json:"createDocument"`
|
||||
}
|
||||
|
||||
err := owner.Execute(`
|
||||
mutation($input: CreateDocumentInput!) {
|
||||
createDocument(input: $input) {
|
||||
documentEdge {
|
||||
node {
|
||||
id
|
||||
defaultApprovers { id }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`, map[string]any{
|
||||
"input": map[string]any{
|
||||
"organizationId": owner.GetOrganizationID().String(),
|
||||
"title": "Doc With Approvers",
|
||||
"content": testutil.ProseMirrorTextDoc("Content"),
|
||||
"documentType": "POLICY",
|
||||
"classification": "INTERNAL",
|
||||
"defaultApproverIds": []string{approverID},
|
||||
},
|
||||
}, &result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Len(t, result.CreateDocument.DocumentEdge.Node.DefaultApprovers, 1)
|
||||
assert.Equal(t, approverID, result.CreateDocument.DocumentEdge.Node.DefaultApprovers[0].ID)
|
||||
})
|
||||
|
||||
t.Run("update document with default approvers", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
docID, _ := createTestDocument(t, owner)
|
||||
|
||||
var result struct {
|
||||
UpdateDocument struct {
|
||||
Document struct {
|
||||
ID string `json:"id"`
|
||||
DefaultApprovers []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"defaultApprovers"`
|
||||
} `json:"document"`
|
||||
} `json:"updateDocument"`
|
||||
}
|
||||
|
||||
err := owner.Execute(`
|
||||
mutation($input: UpdateDocumentInput!) {
|
||||
updateDocument(input: $input) {
|
||||
document {
|
||||
id
|
||||
defaultApprovers { id }
|
||||
}
|
||||
}
|
||||
}
|
||||
`, map[string]any{
|
||||
"input": map[string]any{
|
||||
"id": docID,
|
||||
"defaultApproverIds": []string{approverID},
|
||||
},
|
||||
}, &result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Len(t, result.UpdateDocument.Document.DefaultApprovers, 1)
|
||||
assert.Equal(t, approverID, result.UpdateDocument.Document.DefaultApprovers[0].ID)
|
||||
|
||||
// Clear approvers
|
||||
err = owner.Execute(`
|
||||
mutation($input: UpdateDocumentInput!) {
|
||||
updateDocument(input: $input) {
|
||||
document {
|
||||
id
|
||||
defaultApprovers { id }
|
||||
}
|
||||
}
|
||||
}
|
||||
`, map[string]any{
|
||||
"input": map[string]any{
|
||||
"id": docID,
|
||||
"defaultApproverIds": []string{},
|
||||
},
|
||||
}, &result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Empty(t, result.UpdateDocument.Document.DefaultApprovers)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17,14 +17,23 @@ import { useTranslate } from "@probo/i18n";
|
||||
import { Badge } from "../../Atoms/Badge/Badge";
|
||||
|
||||
type Props = {
|
||||
state: "DRAFT" | "PUBLISHED";
|
||||
state: "DRAFT" | "PENDING_APPROVAL" | "PUBLISHED";
|
||||
};
|
||||
|
||||
export function DocumentVersionBadge(props: Props) {
|
||||
const { __ } = useTranslate();
|
||||
return (
|
||||
<Badge variant={props.state === "DRAFT" ? "neutral" : "success"}>
|
||||
{props.state === "DRAFT" ? __("Draft") : __("Published")}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
const variant = {
|
||||
DRAFT: "neutral",
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -66,45 +66,14 @@ func (p Document) CursorKey(orderBy DocumentOrderField) page.CursorKey {
|
||||
// AuthorizationAttributes returns the authorization attributes for policy evaluation.
|
||||
func (d *Document) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) {
|
||||
q := `
|
||||
WITH document AS (
|
||||
SELECT id, organization_id, status
|
||||
FROM documents
|
||||
WHERE id = $1
|
||||
LIMIT 1
|
||||
),
|
||||
latest_version AS (
|
||||
SELECT dv.id, dv.document_id, dv.status AS version_status
|
||||
FROM document_versions dv
|
||||
INNER JOIN document ON dv.document_id = document.id
|
||||
ORDER BY dv.created_at DESC
|
||||
LIMIT 1
|
||||
),
|
||||
last_quorum AS (
|
||||
SELECT
|
||||
lv.document_id,
|
||||
q.status::text AS status
|
||||
FROM document_version_approval_quorums q
|
||||
INNER JOIN latest_version lv ON lv.id = q.version_id
|
||||
ORDER BY q.created_at DESC
|
||||
LIMIT 1
|
||||
)
|
||||
SELECT
|
||||
document.organization_id,
|
||||
document.status,
|
||||
COALESCE(lv.version_status::text, ''),
|
||||
COALESCE(lq.status, '')
|
||||
FROM document
|
||||
LEFT JOIN latest_version lv ON lv.document_id = document.id
|
||||
LEFT JOIN last_quorum lq ON lq.document_id = document.id;
|
||||
SELECT organization_id
|
||||
FROM documents
|
||||
WHERE id = $1
|
||||
LIMIT 1;
|
||||
`
|
||||
|
||||
var (
|
||||
organizationID gid.GID
|
||||
documentStatus DocumentStatus
|
||||
latestVersionStatus string
|
||||
lastQuorumStatus string
|
||||
)
|
||||
if err := conn.QueryRow(ctx, q, d.ID).Scan(&organizationID, &documentStatus, &latestVersionStatus, &lastQuorumStatus); err != nil {
|
||||
var organizationID gid.GID
|
||||
if err := conn.QueryRow(ctx, q, d.ID).Scan(&organizationID); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrResourceNotFound
|
||||
}
|
||||
@@ -112,10 +81,7 @@ LEFT JOIN last_quorum lq ON lq.document_id = document.id;
|
||||
}
|
||||
|
||||
return map[string]string{
|
||||
"organization_id": organizationID.String(),
|
||||
"document_status": documentStatus.String(),
|
||||
"version_status": latestVersionStatus,
|
||||
"last_quorum_status": lastQuorumStatus,
|
||||
"organization_id": organizationID.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1117,7 +1083,7 @@ LIMIT 1
|
||||
state, err := pgx.CollectOneRow(rows, pgx.RowTo[DocumentVersionApprovalDecisionState])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return DocumentVersionApprovalDecisionStatePending, nil
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("cannot collect approval state: %w", err)
|
||||
}
|
||||
|
||||
143
pkg/coredata/document_default_approver.go
Normal file
143
pkg/coredata/document_default_approver.go
Normal 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
|
||||
}
|
||||
@@ -52,44 +52,15 @@ type (
|
||||
// AuthorizationAttributes returns the authorization attributes for policy evaluation.
|
||||
func (dv *DocumentVersion) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) {
|
||||
q := `
|
||||
WITH document_version AS (
|
||||
SELECT id, document_id, organization_id, status AS version_status
|
||||
FROM document_versions
|
||||
WHERE id = $1
|
||||
LIMIT 1
|
||||
),
|
||||
document AS (
|
||||
SELECT d.id, d.status
|
||||
FROM documents d
|
||||
INNER JOIN document_version ON d.id = document_version.document_id
|
||||
),
|
||||
last_quorum AS (
|
||||
SELECT
|
||||
q.version_id,
|
||||
q.status::text AS status
|
||||
FROM document_version_approval_quorums q
|
||||
INNER JOIN document_version ON q.version_id = document_version.id
|
||||
ORDER BY q.created_at DESC
|
||||
LIMIT 1
|
||||
)
|
||||
SELECT
|
||||
document_version.organization_id,
|
||||
document.status,
|
||||
document_version.version_status,
|
||||
COALESCE(lq.status, '')
|
||||
FROM document_version
|
||||
INNER JOIN document ON document.id = document_version.document_id
|
||||
LEFT JOIN last_quorum lq ON lq.version_id = document_version.id;
|
||||
SELECT organization_id
|
||||
FROM document_versions
|
||||
WHERE id = $1
|
||||
LIMIT 1;
|
||||
`
|
||||
|
||||
var (
|
||||
organizationID gid.GID
|
||||
documentStatus DocumentStatus
|
||||
documentVersionStatus DocumentVersionStatus
|
||||
lastQuorumStatus string
|
||||
)
|
||||
var organizationID gid.GID
|
||||
|
||||
if err := conn.QueryRow(ctx, q, dv.ID).Scan(&organizationID, &documentStatus, &documentVersionStatus, &lastQuorumStatus); err != nil {
|
||||
if err := conn.QueryRow(ctx, q, dv.ID).Scan(&organizationID); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrResourceNotFound
|
||||
}
|
||||
@@ -97,10 +68,7 @@ LEFT JOIN last_quorum lq ON lq.version_id = document_version.id;
|
||||
}
|
||||
|
||||
return map[string]string{
|
||||
"organization_id": organizationID.String(),
|
||||
"document_status": documentStatus.String(),
|
||||
"version_status": documentVersionStatus.String(),
|
||||
"last_quorum_status": lastQuorumStatus,
|
||||
"organization_id": organizationID.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -213,6 +181,9 @@ LIMIT 1;
|
||||
|
||||
documentVersion, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[DocumentVersion])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
return fmt.Errorf("cannot collect document version: %w", err)
|
||||
}
|
||||
|
||||
@@ -343,6 +314,9 @@ LIMIT 1;
|
||||
|
||||
documentVersion, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[DocumentVersion])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
return fmt.Errorf("cannot collect document version: %w", err)
|
||||
}
|
||||
|
||||
@@ -395,6 +369,9 @@ LIMIT 1;
|
||||
|
||||
documentVersion, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[DocumentVersion])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
return fmt.Errorf("cannot collect document version: %w", err)
|
||||
}
|
||||
|
||||
@@ -449,6 +426,9 @@ LIMIT 1;
|
||||
|
||||
documentVersion, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[DocumentVersion])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
return fmt.Errorf("cannot collect document version: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -424,6 +424,40 @@ WHERE
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DocumentVersionApprovalDecisions) VoidPendingByQuorumID(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
scope Scoper,
|
||||
quorumID gid.GID,
|
||||
now time.Time,
|
||||
) error {
|
||||
q := `
|
||||
UPDATE document_version_approval_decisions
|
||||
SET
|
||||
state = 'VOIDED',
|
||||
updated_at = @updated_at
|
||||
WHERE
|
||||
%s
|
||||
AND quorum_id = @quorum_id
|
||||
AND state = 'PENDING'
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"quorum_id": quorumID,
|
||||
"updated_at": now,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot void pending approval decisions: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DocumentVersionApprovalDecisions) CountByQuorumID(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
|
||||
@@ -29,6 +29,7 @@ const (
|
||||
DocumentVersionApprovalDecisionStatePending DocumentVersionApprovalDecisionState = "PENDING"
|
||||
DocumentVersionApprovalDecisionStateApproved DocumentVersionApprovalDecisionState = "APPROVED"
|
||||
DocumentVersionApprovalDecisionStateRejected DocumentVersionApprovalDecisionState = "REJECTED"
|
||||
DocumentVersionApprovalDecisionStateVoided DocumentVersionApprovalDecisionState = "VOIDED"
|
||||
)
|
||||
|
||||
func (s DocumentVersionApprovalDecisionState) MarshalText() ([]byte, error) {
|
||||
@@ -45,6 +46,8 @@ func (s *DocumentVersionApprovalDecisionState) UnmarshalText(data []byte) error
|
||||
*s = DocumentVersionApprovalDecisionStateApproved
|
||||
case DocumentVersionApprovalDecisionStateRejected.String():
|
||||
*s = DocumentVersionApprovalDecisionStateRejected
|
||||
case DocumentVersionApprovalDecisionStateVoided.String():
|
||||
*s = DocumentVersionApprovalDecisionStateVoided
|
||||
default:
|
||||
return fmt.Errorf("invalid DocumentVersionApprovalDecisionState value: %q", val)
|
||||
}
|
||||
@@ -62,6 +65,8 @@ func (s DocumentVersionApprovalDecisionState) String() string {
|
||||
val = "APPROVED"
|
||||
case DocumentVersionApprovalDecisionStateRejected:
|
||||
val = "REJECTED"
|
||||
case DocumentVersionApprovalDecisionStateVoided:
|
||||
val = "VOIDED"
|
||||
default:
|
||||
panic(fmt.Errorf("invalid DocumentVersionApprovalDecisionState value: %q", string(s)))
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ const (
|
||||
DocumentVersionApprovalQuorumStatusPending DocumentVersionApprovalQuorumStatus = "PENDING"
|
||||
DocumentVersionApprovalQuorumStatusApproved DocumentVersionApprovalQuorumStatus = "APPROVED"
|
||||
DocumentVersionApprovalQuorumStatusRejected DocumentVersionApprovalQuorumStatus = "REJECTED"
|
||||
DocumentVersionApprovalQuorumStatusVoided DocumentVersionApprovalQuorumStatus = "VOIDED"
|
||||
)
|
||||
|
||||
func (s DocumentVersionApprovalQuorumStatus) MarshalText() ([]byte, error) {
|
||||
@@ -41,6 +42,8 @@ func (s *DocumentVersionApprovalQuorumStatus) UnmarshalText(data []byte) error {
|
||||
*s = DocumentVersionApprovalQuorumStatusApproved
|
||||
case DocumentVersionApprovalQuorumStatusRejected.String():
|
||||
*s = DocumentVersionApprovalQuorumStatusRejected
|
||||
case DocumentVersionApprovalQuorumStatusVoided.String():
|
||||
*s = DocumentVersionApprovalQuorumStatusVoided
|
||||
default:
|
||||
return fmt.Errorf("invalid DocumentVersionApprovalQuorumStatus value: %q", val)
|
||||
}
|
||||
@@ -58,6 +61,8 @@ func (s DocumentVersionApprovalQuorumStatus) String() string {
|
||||
val = "APPROVED"
|
||||
case DocumentVersionApprovalQuorumStatusRejected:
|
||||
val = "REJECTED"
|
||||
case DocumentVersionApprovalQuorumStatusVoided:
|
||||
val = "VOIDED"
|
||||
default:
|
||||
panic(fmt.Errorf("invalid DocumentVersionApprovalQuorumStatus value: %q", string(s)))
|
||||
}
|
||||
|
||||
@@ -20,12 +20,13 @@ import (
|
||||
)
|
||||
|
||||
type (
|
||||
DocumentVersionStatus uint8
|
||||
DocumentVersionStatus string
|
||||
)
|
||||
|
||||
const (
|
||||
DocumentVersionStatusDraft DocumentVersionStatus = iota
|
||||
DocumentVersionStatusPublished
|
||||
DocumentVersionStatusDraft DocumentVersionStatus = "DRAFT"
|
||||
DocumentVersionStatusPendingApproval DocumentVersionStatus = "PENDING_APPROVAL"
|
||||
DocumentVersionStatusPublished DocumentVersionStatus = "PUBLISHED"
|
||||
)
|
||||
|
||||
func (ps DocumentVersionStatus) MarshalText() ([]byte, error) {
|
||||
@@ -38,6 +39,8 @@ func (ps *DocumentVersionStatus) UnmarshalText(data []byte) error {
|
||||
switch val {
|
||||
case DocumentVersionStatusDraft.String():
|
||||
*ps = DocumentVersionStatusDraft
|
||||
case DocumentVersionStatusPendingApproval.String():
|
||||
*ps = DocumentVersionStatusPendingApproval
|
||||
case DocumentVersionStatusPublished.String():
|
||||
*ps = DocumentVersionStatusPublished
|
||||
default:
|
||||
@@ -48,16 +51,16 @@ func (ps *DocumentVersionStatus) UnmarshalText(data []byte) error {
|
||||
}
|
||||
|
||||
func (ps DocumentVersionStatus) String() string {
|
||||
var val string
|
||||
|
||||
switch ps {
|
||||
case DocumentVersionStatusDraft:
|
||||
val = "DRAFT"
|
||||
return "DRAFT"
|
||||
case DocumentVersionStatusPendingApproval:
|
||||
return "PENDING_APPROVAL"
|
||||
case DocumentVersionStatusPublished:
|
||||
val = "PUBLISHED"
|
||||
return "PUBLISHED"
|
||||
default:
|
||||
panic(fmt.Errorf("invalid DocumentVersionStatus value: %q", string(ps)))
|
||||
}
|
||||
|
||||
return val
|
||||
}
|
||||
|
||||
func (ps *DocumentVersionStatus) Scan(value any) error {
|
||||
|
||||
17
pkg/coredata/migrations/20260408T120000Z.sql
Normal file
17
pkg/coredata/migrations/20260408T120000Z.sql
Normal 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';
|
||||
35
pkg/coredata/migrations/20260408T120004Z.sql
Normal file
35
pkg/coredata/migrations/20260408T120004Z.sql
Normal 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')
|
||||
);
|
||||
47
pkg/coredata/migrations/20260408T130000Z.sql
Normal file
47
pkg/coredata/migrations/20260408T130000Z.sql
Normal 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;
|
||||
@@ -197,11 +197,10 @@ const (
|
||||
ActionDocumentVersionUpdate = "core:document-version:update"
|
||||
ActionDocumentVersionDeleteDraft = "core:document-version:delete-draft"
|
||||
ActionDocumentVersionRequestApproval = "core:document-version:request-approval"
|
||||
ActionDocumentVersionVoidApproval = "core:document-version:void-approval"
|
||||
ActionDocumentVersionApprove = "core:document-version:approve"
|
||||
ActionDocumentVersionReject = "core:document-version:reject"
|
||||
ActionDocumentVersionApprovalList = "core:document-version:approval-list"
|
||||
ActionDocumentVersionAddApprover = "core:document-version:add-approver"
|
||||
ActionDocumentVersionRemoveApprover = "core:document-version:remove-approver"
|
||||
ActionDocumentVersionPublish = "core:document-version:publish"
|
||||
ActionDocumentVersionExport = "core:document-version:export"
|
||||
|
||||
|
||||
@@ -19,9 +19,8 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"go.gearno.de/crypto/uuid"
|
||||
"go.gearno.de/kit/pg"
|
||||
@@ -84,10 +83,10 @@ func (req *RequestApprovalRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(req.DocumentID, "document_id", validator.Required(), validator.GID(coredata.DocumentEntityType))
|
||||
v.Check(req.ApproverIDs, "approver_ids", validator.Required())
|
||||
v.Check(len(req.ApproverIDs), "approver_ids", validator.Max(100))
|
||||
v.CheckEach(req.ApproverIDs, "approver_ids", func(_ int, item any) {
|
||||
v.Check(item, "approver_ids", validator.GID(coredata.MembershipProfileEntityType))
|
||||
v.Check(len(req.ApproverIDs), "approver_ids", validator.Min(1), validator.Max(100))
|
||||
v.Check(req.ApproverIDs, "approver_ids", validator.NoDuplicates())
|
||||
v.CheckEach(req.ApproverIDs, "approver_ids", func(index int, item any) {
|
||||
v.Check(item, fmt.Sprintf("approver_ids[%d]", index), validator.GID(coredata.MembershipProfileEntityType))
|
||||
})
|
||||
v.Check(req.Changelog, "changelog", validator.Required(), validator.SafeText(5000))
|
||||
|
||||
@@ -121,53 +120,19 @@ func (s *DocumentApprovalService) RequestApproval(
|
||||
return fmt.Errorf("cannot load latest version: %w", err)
|
||||
}
|
||||
|
||||
if documentVersion.Status == coredata.DocumentVersionStatusPublished {
|
||||
return fmt.Errorf("cannot request approval for a published document")
|
||||
if documentVersion.Status != coredata.DocumentVersionStatusDraft {
|
||||
return &ErrDocumentVersionNotDraft{}
|
||||
}
|
||||
|
||||
if err := s.rejectPendingQuorum(ctx, tx, documentVersion.ID); err != nil {
|
||||
return fmt.Errorf("cannot reject pending quorum: %w", err)
|
||||
q, err := s.requestApprovalInTx(ctx, tx, document, documentVersion, req.ApproverIDs, req.Changelog)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
quorum = q
|
||||
|
||||
organization := &coredata.Organization{}
|
||||
if err := organization.LoadByID(ctx, tx, s.svc.scope, document.OrganizationID); err != nil {
|
||||
return fmt.Errorf("cannot load organization: %w", err)
|
||||
}
|
||||
|
||||
approverProfiles := &coredata.MembershipProfiles{}
|
||||
if err := approverProfiles.LoadByIDs(ctx, tx, s.svc.scope, req.ApproverIDs); err != nil {
|
||||
return fmt.Errorf("cannot load approver profiles: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
if req.Changelog != nil {
|
||||
documentVersion.Changelog = *req.Changelog
|
||||
documentVersion.UpdatedAt = now
|
||||
if err := documentVersion.Update(ctx, tx, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot update document version changelog: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
quorum = &coredata.DocumentVersionApprovalQuorum{
|
||||
ID: gid.New(s.svc.scope.GetTenantID(), coredata.DocumentVersionApprovalQuorumEntityType),
|
||||
OrganizationID: document.OrganizationID,
|
||||
VersionID: documentVersion.ID,
|
||||
Status: coredata.DocumentVersionApprovalQuorumStatusPending,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := quorum.Insert(ctx, tx, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot insert approval quorum: %w", err)
|
||||
}
|
||||
|
||||
if err := s.createDecisions(ctx, tx, quorum, document.OrganizationID, req.ApproverIDs, now); err != nil {
|
||||
return fmt.Errorf("cannot create approval decisions: %w", err)
|
||||
}
|
||||
|
||||
if err := s.sendApprovalEmails(ctx, tx, *approverProfiles, document, organization, documentVersion.ID); err != nil {
|
||||
return fmt.Errorf("cannot send approval emails: %w", err)
|
||||
defaultApprovers := &coredata.DocumentDefaultApprovers{}
|
||||
if err := defaultApprovers.MergeByDocumentID(ctx, tx, s.svc.scope, req.DocumentID, document.OrganizationID, req.ApproverIDs); err != nil {
|
||||
return fmt.Errorf("cannot update default approvers: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -181,6 +146,138 @@ func (s *DocumentApprovalService) RequestApproval(
|
||||
return quorum, nil
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) requestApprovalInTx(
|
||||
ctx context.Context,
|
||||
tx pg.Tx,
|
||||
document *coredata.Document,
|
||||
documentVersion *coredata.DocumentVersion,
|
||||
approverIDs []gid.GID,
|
||||
changelog *string,
|
||||
) (*coredata.DocumentVersionApprovalQuorum, error) {
|
||||
organization := &coredata.Organization{}
|
||||
if err := organization.LoadByID(ctx, tx, s.svc.scope, document.OrganizationID); err != nil {
|
||||
return nil, fmt.Errorf("cannot load organization: %w", err)
|
||||
}
|
||||
|
||||
approverProfiles := &coredata.MembershipProfiles{}
|
||||
if err := approverProfiles.LoadByIDs(ctx, tx, s.svc.scope, approverIDs); err != nil {
|
||||
return nil, fmt.Errorf("cannot load approver profiles: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
documentVersion.Status = coredata.DocumentVersionStatusPendingApproval
|
||||
if changelog != nil {
|
||||
documentVersion.Changelog = *changelog
|
||||
}
|
||||
|
||||
if document.CurrentPublishedMajor != nil {
|
||||
documentVersion.Major = *document.CurrentPublishedMajor + 1
|
||||
} else {
|
||||
documentVersion.Major = 1
|
||||
}
|
||||
documentVersion.Minor = 0
|
||||
|
||||
documentVersion.UpdatedAt = now
|
||||
if err := documentVersion.Update(ctx, tx, s.svc.scope); err != nil {
|
||||
return nil, fmt.Errorf("cannot update document version: %w", err)
|
||||
}
|
||||
|
||||
quorum := &coredata.DocumentVersionApprovalQuorum{
|
||||
ID: gid.New(s.svc.scope.GetTenantID(), coredata.DocumentVersionApprovalQuorumEntityType),
|
||||
OrganizationID: document.OrganizationID,
|
||||
VersionID: documentVersion.ID,
|
||||
Status: coredata.DocumentVersionApprovalQuorumStatusPending,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := quorum.Insert(ctx, tx, s.svc.scope); err != nil {
|
||||
return nil, fmt.Errorf("cannot insert approval quorum: %w", err)
|
||||
}
|
||||
|
||||
if err := s.createDecisions(ctx, tx, quorum, document.OrganizationID, approverIDs, now); err != nil {
|
||||
return nil, fmt.Errorf("cannot create approval decisions: %w", err)
|
||||
}
|
||||
|
||||
if err := s.sendApprovalEmails(ctx, tx, *approverProfiles, document, organization, documentVersion.ID); err != nil {
|
||||
return nil, fmt.Errorf("cannot send approval emails: %w", err)
|
||||
}
|
||||
|
||||
return quorum, nil
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) BulkPublishMajorVersions(
|
||||
ctx context.Context,
|
||||
req BulkPublishVersionsRequest,
|
||||
) ([]*coredata.DocumentVersion, []*coredata.Document, error) {
|
||||
var publishedVersions []*coredata.DocumentVersion
|
||||
var updatedDocuments []*coredata.Document
|
||||
|
||||
err := s.svc.pg.WithTx(
|
||||
ctx,
|
||||
func(ctx context.Context, tx pg.Tx) error {
|
||||
for _, documentID := range req.DocumentIDs {
|
||||
dv := &coredata.DocumentVersion{}
|
||||
if err := dv.LoadLatestVersion(ctx, tx, s.svc.scope, documentID); err != nil {
|
||||
return fmt.Errorf("cannot load latest version for %q: %w", documentID, err)
|
||||
}
|
||||
|
||||
// Skip documents already pending approval.
|
||||
if dv.Status == coredata.DocumentVersionStatusPendingApproval {
|
||||
continue
|
||||
}
|
||||
|
||||
document := &coredata.Document{}
|
||||
if err := document.LoadByID(ctx, tx, s.svc.scope, documentID); err != nil {
|
||||
return fmt.Errorf("cannot load document %q: %w", documentID, err)
|
||||
}
|
||||
|
||||
if document.ArchivedAt != nil {
|
||||
return &ErrDocumentArchived{}
|
||||
}
|
||||
|
||||
defaultApprovers := &coredata.DocumentDefaultApprovers{}
|
||||
if err := defaultApprovers.LoadByDocumentID(ctx, tx, s.svc.scope, documentID); err != nil {
|
||||
return fmt.Errorf("cannot load default approvers for %q: %w", documentID, err)
|
||||
}
|
||||
|
||||
if dv.Status != coredata.DocumentVersionStatusDraft {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(*defaultApprovers) > 0 {
|
||||
approverIDs := make([]gid.GID, len(*defaultApprovers))
|
||||
for i, a := range *defaultApprovers {
|
||||
approverIDs[i] = a.ApproverProfileID
|
||||
}
|
||||
|
||||
if _, err := s.requestApprovalInTx(ctx, tx, document, dv, approverIDs, &req.Changelog); err != nil {
|
||||
return fmt.Errorf("cannot request approval for %q: %w", documentID, err)
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
document, dv, err = s.svc.Documents.publishMajorVersionInTx(ctx, tx, documentID, &req.Changelog, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot publish document %q: %w", documentID, err)
|
||||
}
|
||||
}
|
||||
|
||||
publishedVersions = append(publishedVersions, dv)
|
||||
updatedDocuments = append(updatedDocuments, document)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return publishedVersions, updatedDocuments, nil
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) Approve(
|
||||
ctx context.Context,
|
||||
req ApproveDocumentVersionRequest,
|
||||
@@ -205,6 +302,10 @@ func (s *DocumentApprovalService) Approve(
|
||||
return fmt.Errorf("cannot load document: %w", err)
|
||||
}
|
||||
|
||||
if document.ArchivedAt != nil {
|
||||
return &ErrDocumentArchived{}
|
||||
}
|
||||
|
||||
var profile *coredata.MembershipProfile
|
||||
var err error
|
||||
quorum, profile, err = s.loadQuorumAndProfile(ctx, conn, req.DocumentVersionID, req.IdentityID, documentVersion.OrganizationID)
|
||||
@@ -269,9 +370,20 @@ func (s *DocumentApprovalService) Approve(
|
||||
|
||||
approverID := decision.ApproverID
|
||||
|
||||
quorumID := quorum.ID
|
||||
|
||||
err = s.svc.pg.WithTx(
|
||||
ctx,
|
||||
func(ctx context.Context, tx pg.Tx) error {
|
||||
quorum = &coredata.DocumentVersionApprovalQuorum{}
|
||||
if err := quorum.LoadByID(ctx, tx, s.svc.scope, quorumID); err != nil {
|
||||
return fmt.Errorf("cannot load quorum: %w", err)
|
||||
}
|
||||
|
||||
if quorum.Status != coredata.DocumentVersionApprovalQuorumStatusPending {
|
||||
return &ErrDocumentVersionNotPendingApproval{}
|
||||
}
|
||||
|
||||
decision = &coredata.DocumentVersionApprovalDecision{}
|
||||
if err := decision.LoadByQuorumIDAndApproverID(ctx, tx, s.svc.scope, quorum.ID, approverID); err != nil {
|
||||
return fmt.Errorf("cannot load approval decision: %w", err)
|
||||
@@ -343,6 +455,15 @@ func (s *DocumentApprovalService) Reject(
|
||||
return fmt.Errorf("cannot load document version: %w", err)
|
||||
}
|
||||
|
||||
document := &coredata.Document{}
|
||||
if err := document.LoadByID(ctx, tx, s.svc.scope, documentVersion.DocumentID); err != nil {
|
||||
return fmt.Errorf("cannot load document: %w", err)
|
||||
}
|
||||
|
||||
if document.ArchivedAt != nil {
|
||||
return &ErrDocumentArchived{}
|
||||
}
|
||||
|
||||
quorum, profile, err := s.loadQuorumAndProfile(ctx, tx, req.DocumentVersionID, req.IdentityID, documentVersion.OrganizationID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot load quorum and profile: %w", err)
|
||||
@@ -375,6 +496,25 @@ func (s *DocumentApprovalService) Reject(
|
||||
return fmt.Errorf("cannot update approval quorum: %w", err)
|
||||
}
|
||||
|
||||
decisions := &coredata.DocumentVersionApprovalDecisions{}
|
||||
if err := decisions.VoidPendingByQuorumID(ctx, tx, s.svc.scope, quorum.ID, now); err != nil {
|
||||
return fmt.Errorf("cannot void pending decisions: %w", err)
|
||||
}
|
||||
|
||||
documentVersion.Status = coredata.DocumentVersionStatusDraft
|
||||
if document.CurrentPublishedMajor != nil {
|
||||
documentVersion.Major = *document.CurrentPublishedMajor
|
||||
documentVersion.Minor = *document.CurrentPublishedMinor + 1
|
||||
} else {
|
||||
documentVersion.Major = 0
|
||||
documentVersion.Minor = 1
|
||||
}
|
||||
documentVersion.UpdatedAt = now
|
||||
|
||||
if err := documentVersion.Update(ctx, tx, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot update document version status: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
@@ -386,70 +526,71 @@ func (s *DocumentApprovalService) Reject(
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) AddApprover(
|
||||
func (s *DocumentApprovalService) VoidApproval(
|
||||
ctx context.Context,
|
||||
documentVersionID gid.GID,
|
||||
approverID gid.GID,
|
||||
) (*coredata.DocumentVersionApprovalDecision, error) {
|
||||
var decision *coredata.DocumentVersionApprovalDecision
|
||||
) (*coredata.DocumentVersionApprovalQuorum, *coredata.DocumentVersion, error) {
|
||||
var (
|
||||
quorum *coredata.DocumentVersionApprovalQuorum
|
||||
documentVersion *coredata.DocumentVersion
|
||||
)
|
||||
|
||||
err := s.svc.pg.WithTx(
|
||||
ctx,
|
||||
func(ctx context.Context, tx pg.Tx) error {
|
||||
documentVersion := &coredata.DocumentVersion{}
|
||||
documentVersion = &coredata.DocumentVersion{}
|
||||
if err := documentVersion.LoadByID(ctx, tx, s.svc.scope, documentVersionID); err != nil {
|
||||
return fmt.Errorf("cannot load document version: %w", err)
|
||||
}
|
||||
|
||||
quorum := &coredata.DocumentVersionApprovalQuorum{}
|
||||
if err := quorum.LoadLastByDocumentVersionID(ctx, tx, s.svc.scope, documentVersionID); err != nil {
|
||||
document := &coredata.Document{}
|
||||
if err := document.LoadByID(ctx, tx, s.svc.scope, documentVersion.DocumentID); err != nil {
|
||||
return fmt.Errorf("cannot load document: %w", err)
|
||||
}
|
||||
|
||||
if document.ArchivedAt != nil {
|
||||
return &ErrDocumentArchived{}
|
||||
}
|
||||
|
||||
if documentVersion.Status != coredata.DocumentVersionStatusPendingApproval {
|
||||
return &ErrDocumentVersionNotPendingApproval{}
|
||||
}
|
||||
|
||||
quorum = &coredata.DocumentVersionApprovalQuorum{}
|
||||
if err := quorum.LoadLastByDocumentVersionID(ctx, tx, s.svc.scope, documentVersionID); err != nil {
|
||||
return fmt.Errorf("cannot load approval quorum: %w", err)
|
||||
}
|
||||
|
||||
if quorum.Status != coredata.DocumentVersionApprovalQuorumStatusPending {
|
||||
return &ErrDocumentVersionNotPendingApproval{}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
decision = &coredata.DocumentVersionApprovalDecision{
|
||||
ID: gid.New(s.svc.scope.GetTenantID(), coredata.DocumentVersionApprovalDecisionEntityType),
|
||||
OrganizationID: documentVersion.OrganizationID,
|
||||
QuorumID: quorum.ID,
|
||||
ApproverID: approverID,
|
||||
State: coredata.DocumentVersionApprovalDecisionStatePending,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
quorum.Status = coredata.DocumentVersionApprovalQuorumStatusVoided
|
||||
quorum.UpdatedAt = now
|
||||
|
||||
if err := quorum.Update(ctx, tx, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot update approval quorum: %w", err)
|
||||
}
|
||||
|
||||
if err := decision.Insert(ctx, tx, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot insert approval decision: %w", err)
|
||||
decisions := &coredata.DocumentVersionApprovalDecisions{}
|
||||
if err := decisions.VoidPendingByQuorumID(ctx, tx, s.svc.scope, quorum.ID, now); err != nil {
|
||||
return fmt.Errorf("cannot void pending decisions: %w", err)
|
||||
}
|
||||
|
||||
document := &coredata.Document{}
|
||||
if err := document.LoadByID(ctx, tx, s.svc.scope, documentVersion.DocumentID); err != nil {
|
||||
return fmt.Errorf("cannot load document: %w", err)
|
||||
documentVersion.Status = coredata.DocumentVersionStatusDraft
|
||||
if document.CurrentPublishedMajor != nil {
|
||||
documentVersion.Major = *document.CurrentPublishedMajor
|
||||
documentVersion.Minor = *document.CurrentPublishedMinor + 1
|
||||
} else {
|
||||
documentVersion.Major = 0
|
||||
documentVersion.Minor = 1
|
||||
}
|
||||
documentVersion.UpdatedAt = now
|
||||
|
||||
organization := &coredata.Organization{}
|
||||
if err := organization.LoadByID(ctx, tx, s.svc.scope, document.OrganizationID); err != nil {
|
||||
return fmt.Errorf("cannot load organization: %w", err)
|
||||
}
|
||||
|
||||
profile := &coredata.MembershipProfile{}
|
||||
if err := profile.LoadByID(ctx, tx, s.svc.scope, approverID); err != nil {
|
||||
return fmt.Errorf("cannot load approver profile: %w", err)
|
||||
}
|
||||
|
||||
if err := s.sendApprovalEmails(
|
||||
ctx,
|
||||
tx,
|
||||
coredata.MembershipProfiles{profile},
|
||||
document,
|
||||
organization,
|
||||
documentVersionID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("cannot send approval email: %w", err)
|
||||
if err := documentVersion.Update(ctx, tx, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot update document version status: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -457,66 +598,10 @@ func (s *DocumentApprovalService) AddApprover(
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) RemoveApprover(
|
||||
ctx context.Context,
|
||||
approvalDecisionID gid.GID,
|
||||
) (gid.GID, error) {
|
||||
var documentVersionID gid.GID
|
||||
|
||||
err := s.svc.pg.WithTx(
|
||||
ctx,
|
||||
func(ctx context.Context, tx pg.Tx) error {
|
||||
decision := &coredata.DocumentVersionApprovalDecision{}
|
||||
if err := decision.LoadByID(ctx, tx, s.svc.scope, approvalDecisionID); err != nil {
|
||||
return fmt.Errorf("cannot load approval decision: %w", err)
|
||||
}
|
||||
|
||||
quorum := &coredata.DocumentVersionApprovalQuorum{}
|
||||
if err := quorum.LoadByID(ctx, tx, s.svc.scope, decision.QuorumID); err != nil {
|
||||
return fmt.Errorf("cannot load approval quorum: %w", err)
|
||||
}
|
||||
|
||||
if quorum.Status != coredata.DocumentVersionApprovalQuorumStatusPending {
|
||||
return &ErrDocumentVersionNotPendingApproval{}
|
||||
}
|
||||
|
||||
documentVersionID = quorum.VersionID
|
||||
|
||||
if err := decision.Delete(ctx, tx, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot delete approval decision: %w", err)
|
||||
}
|
||||
|
||||
remaining, err := s.countDecisions(ctx, tx, quorum.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot count remaining decisions: %w", err)
|
||||
}
|
||||
|
||||
if remaining == 0 {
|
||||
if err := quorum.Delete(ctx, tx, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot delete approval quorum: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := s.maybeApproveQuorum(ctx, tx, quorum.ID); err != nil {
|
||||
return fmt.Errorf("cannot check quorum approval: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return gid.GID{}, err
|
||||
}
|
||||
|
||||
return documentVersionID, nil
|
||||
return quorum, documentVersion, nil
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) GetQuorum(
|
||||
@@ -726,34 +811,6 @@ func (s *DocumentApprovalService) loadQuorumAndProfile(
|
||||
return quorum, profile, nil
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) rejectPendingQuorum(
|
||||
ctx context.Context,
|
||||
tx pg.Tx,
|
||||
documentVersionID gid.GID,
|
||||
) error {
|
||||
existingQuorum := &coredata.DocumentVersionApprovalQuorum{}
|
||||
if err := existingQuorum.LoadLastByDocumentVersionID(ctx, tx, s.svc.scope, documentVersionID); err != nil {
|
||||
if errors.Is(err, coredata.ErrResourceNotFound) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("cannot load last quorum: %w", err)
|
||||
}
|
||||
|
||||
if existingQuorum.Status != coredata.DocumentVersionApprovalQuorumStatusPending {
|
||||
return nil
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
existingQuorum.Status = coredata.DocumentVersionApprovalQuorumStatusRejected
|
||||
existingQuorum.UpdatedAt = now
|
||||
|
||||
if err := existingQuorum.Update(ctx, tx, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot reject existing quorum: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) createDecisions(
|
||||
ctx context.Context,
|
||||
tx pg.Tx,
|
||||
@@ -918,16 +975,18 @@ func (s *DocumentApprovalService) maybeApproveQuorum(
|
||||
return fmt.Errorf("cannot count total decisions: %w", err)
|
||||
}
|
||||
|
||||
if totalCount > 0 {
|
||||
decisions := &coredata.DocumentVersionApprovalDecisions{}
|
||||
approvedCount, err := decisions.CountApprovedByQuorumID(ctx, tx, s.svc.scope, quorumID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot count approved decisions: %w", err)
|
||||
}
|
||||
if totalCount == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if approvedCount != totalCount {
|
||||
return nil
|
||||
}
|
||||
decisions := &coredata.DocumentVersionApprovalDecisions{}
|
||||
approvedCount, err := decisions.CountApprovedByQuorumID(ctx, tx, s.svc.scope, quorumID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot count approved decisions: %w", err)
|
||||
}
|
||||
|
||||
if approvedCount != totalCount {
|
||||
return nil
|
||||
}
|
||||
|
||||
quorum := &coredata.DocumentVersionApprovalQuorum{}
|
||||
@@ -960,13 +1019,17 @@ func (s *DocumentApprovalService) publishVersion(
|
||||
return fmt.Errorf("cannot load document version: %w", err)
|
||||
}
|
||||
|
||||
_, _, err := s.svc.Documents.publishMajorVersionInTx(
|
||||
ctx,
|
||||
tx,
|
||||
version.DocumentID,
|
||||
nil,
|
||||
false,
|
||||
)
|
||||
document := &coredata.Document{}
|
||||
if err := document.LoadByID(ctx, tx, s.svc.scope, version.DocumentID); err != nil {
|
||||
return fmt.Errorf("cannot load document: %w", err)
|
||||
}
|
||||
|
||||
return err
|
||||
document.CurrentPublishedMajor = &version.Major
|
||||
document.CurrentPublishedMinor = &version.Minor
|
||||
|
||||
if err := s.svc.Documents.finalizePublish(ctx, tx, document, version, nil); err != nil {
|
||||
return fmt.Errorf("cannot finalize publish: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -62,6 +62,12 @@ type (
|
||||
ErrDocumentVersionNotDraft struct {
|
||||
}
|
||||
|
||||
ErrDocumentVersionNotPublished struct {
|
||||
}
|
||||
|
||||
ErrDocumentVersionPendingApproval struct {
|
||||
}
|
||||
|
||||
ErrDocumentArchived struct {
|
||||
}
|
||||
|
||||
@@ -78,12 +84,14 @@ type (
|
||||
Classification coredata.DocumentClassification
|
||||
DocumentType coredata.DocumentType
|
||||
TrustCenterVisibility *coredata.TrustCenterVisibility
|
||||
DefaultApproverIDs []gid.GID
|
||||
}
|
||||
|
||||
UpdateDocumentRequest struct {
|
||||
DocumentID gid.GID
|
||||
Title *string
|
||||
TrustCenterVisibility *coredata.TrustCenterVisibility
|
||||
DefaultApproverIDs *[]gid.GID
|
||||
}
|
||||
|
||||
UpdateDocumentVersionRequest struct {
|
||||
@@ -129,6 +137,11 @@ func (cdr *CreateDocumentRequest) Validate() error {
|
||||
v.Check(cdr.Classification, "classification", validator.Required(), validator.OneOfSlice(coredata.DocumentClassifications()))
|
||||
v.Check(cdr.DocumentType, "document_type", validator.Required(), validator.OneOfSlice(coredata.DocumentTypes()))
|
||||
v.Check(cdr.TrustCenterVisibility, "trust_center_visibility", validator.OneOfSlice(coredata.TrustCenterVisibilities()))
|
||||
v.Check(len(cdr.DefaultApproverIDs), "default_approver_ids", validator.Max(100))
|
||||
v.Check(cdr.DefaultApproverIDs, "default_approver_ids", validator.NoDuplicates())
|
||||
v.CheckEach(cdr.DefaultApproverIDs, "default_approver_ids", func(_ int, item any) {
|
||||
v.Check(item, "default_approver_ids", validator.GID(coredata.MembershipProfileEntityType))
|
||||
})
|
||||
|
||||
return v.Error()
|
||||
}
|
||||
@@ -139,6 +152,13 @@ func (udr *UpdateDocumentRequest) Validate() error {
|
||||
v.Check(udr.DocumentID, "document_id", validator.Required(), validator.GID(coredata.DocumentEntityType))
|
||||
v.Check(udr.Title, "title", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.Check(udr.TrustCenterVisibility, "trust_center_visibility", validator.OneOfSlice(coredata.TrustCenterVisibilities()))
|
||||
if udr.DefaultApproverIDs != nil {
|
||||
v.Check(len(*udr.DefaultApproverIDs), "default_approver_ids", validator.Max(100))
|
||||
v.Check(*udr.DefaultApproverIDs, "default_approver_ids", validator.NoDuplicates())
|
||||
v.CheckEach(*udr.DefaultApproverIDs, "default_approver_ids", func(_ int, item any) {
|
||||
v.Check(item, "default_approver_ids", validator.GID(coredata.MembershipProfileEntityType))
|
||||
})
|
||||
}
|
||||
|
||||
return v.Error()
|
||||
}
|
||||
@@ -179,7 +199,15 @@ func (e ErrSignatureNotCancellable) Error() string {
|
||||
}
|
||||
|
||||
func (e ErrDocumentVersionNotDraft) Error() string {
|
||||
return "cannot update a published document version"
|
||||
return "document version is not a draft"
|
||||
}
|
||||
|
||||
func (e ErrDocumentVersionNotPublished) Error() string {
|
||||
return "document version is not published"
|
||||
}
|
||||
|
||||
func (e ErrDocumentVersionPendingApproval) Error() string {
|
||||
return "cannot publish a document version that is pending approval"
|
||||
}
|
||||
|
||||
func (e ErrDocumentArchived) Error() string {
|
||||
@@ -214,6 +242,48 @@ func (s *DocumentService) Get(
|
||||
return document, nil
|
||||
}
|
||||
|
||||
func (s *DocumentService) GetDefaultApprovers(
|
||||
ctx context.Context,
|
||||
documentID gid.GID,
|
||||
) (coredata.MembershipProfiles, error) {
|
||||
var approvers coredata.DocumentDefaultApprovers
|
||||
|
||||
err := s.svc.pg.WithConn(
|
||||
ctx,
|
||||
func(ctx context.Context, conn pg.Querier) error {
|
||||
return approvers.LoadByDocumentID(ctx, conn, s.svc.scope, documentID)
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot load default approvers: %w", err)
|
||||
}
|
||||
|
||||
if len(approvers) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
profileIDs := make([]gid.GID, len(approvers))
|
||||
for i, a := range approvers {
|
||||
profileIDs[i] = a.ApproverProfileID
|
||||
}
|
||||
|
||||
var profiles coredata.MembershipProfiles
|
||||
|
||||
err = s.svc.pg.WithConn(
|
||||
ctx,
|
||||
func(ctx context.Context, conn pg.Querier) error {
|
||||
return profiles.LoadByIDs(ctx, conn, s.svc.scope, profileIDs)
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot load approver profiles: %w", err)
|
||||
}
|
||||
|
||||
return profiles, nil
|
||||
}
|
||||
|
||||
func (s *DocumentService) GetByIDs(
|
||||
ctx context.Context,
|
||||
documentIDs ...gid.GID,
|
||||
@@ -343,6 +413,10 @@ func (s DocumentService) GenerateChangelog(
|
||||
return fmt.Errorf("cannot load document: %w", err)
|
||||
}
|
||||
|
||||
if document.ArchivedAt != nil {
|
||||
return &ErrDocumentArchived{}
|
||||
}
|
||||
|
||||
if document.CurrentPublishedMajor == nil {
|
||||
initialVersionChangelog := "Initial version"
|
||||
changelog = &initialVersionChangelog
|
||||
@@ -375,37 +449,6 @@ func (s DocumentService) GenerateChangelog(
|
||||
return changelog, nil
|
||||
}
|
||||
|
||||
func (s *DocumentService) BulkPublishMajorVersions(
|
||||
ctx context.Context,
|
||||
req BulkPublishVersionsRequest,
|
||||
) ([]*coredata.DocumentVersion, []*coredata.Document, error) {
|
||||
var publishedVersions []*coredata.DocumentVersion
|
||||
var updatedDocuments []*coredata.Document
|
||||
|
||||
err := s.svc.pg.WithTx(
|
||||
ctx,
|
||||
func(ctx context.Context, tx pg.Tx) error {
|
||||
for _, documentID := range req.DocumentIDs {
|
||||
document, version, err := s.publishMajorVersionInTx(ctx, tx, documentID, &req.Changelog, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot publish document %q: %w", documentID, err)
|
||||
}
|
||||
|
||||
publishedVersions = append(publishedVersions, version)
|
||||
updatedDocuments = append(updatedDocuments, document)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return publishedVersions, updatedDocuments, nil
|
||||
}
|
||||
|
||||
func (s *DocumentService) BulkPublishMinorVersions(
|
||||
ctx context.Context,
|
||||
req BulkPublishVersionsRequest,
|
||||
@@ -417,6 +460,16 @@ func (s *DocumentService) BulkPublishMinorVersions(
|
||||
ctx,
|
||||
func(ctx context.Context, tx pg.Tx) error {
|
||||
for _, documentID := range req.DocumentIDs {
|
||||
dv := &coredata.DocumentVersion{}
|
||||
if err := dv.LoadLatestVersion(ctx, tx, s.svc.scope, documentID); err != nil {
|
||||
return fmt.Errorf("cannot load latest version for %q: %w", documentID, err)
|
||||
}
|
||||
|
||||
// Skip documents already pending approval.
|
||||
if dv.Status == coredata.DocumentVersionStatusPendingApproval {
|
||||
continue
|
||||
}
|
||||
|
||||
document, version, err := s.publishMinorVersionInTx(ctx, tx, documentID, &req.Changelog, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot publish document %q: %w", documentID, err)
|
||||
@@ -449,6 +502,15 @@ func (s *DocumentService) PublishMajorVersion(
|
||||
err := s.svc.pg.WithTx(
|
||||
ctx,
|
||||
func(ctx context.Context, tx pg.Tx) error {
|
||||
dv := &coredata.DocumentVersion{}
|
||||
if err := dv.LoadLatestVersion(ctx, tx, s.svc.scope, documentID); err != nil {
|
||||
return fmt.Errorf("cannot load latest version: %w", err)
|
||||
}
|
||||
|
||||
if dv.Status == coredata.DocumentVersionStatusPendingApproval {
|
||||
return &ErrDocumentVersionPendingApproval{}
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
document, documentVersion, err = s.publishMajorVersionInTx(ctx, tx, documentID, changelog, false)
|
||||
@@ -479,6 +541,15 @@ func (s *DocumentService) PublishMinorVersion(
|
||||
err := s.svc.pg.WithTx(
|
||||
ctx,
|
||||
func(ctx context.Context, tx pg.Tx) error {
|
||||
dv := &coredata.DocumentVersion{}
|
||||
if err := dv.LoadLatestVersion(ctx, tx, s.svc.scope, documentID); err != nil {
|
||||
return fmt.Errorf("cannot load latest version: %w", err)
|
||||
}
|
||||
|
||||
if dv.Status == coredata.DocumentVersionStatusPendingApproval {
|
||||
return &ErrDocumentVersionPendingApproval{}
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
document, documentVersion, err = s.publishMinorVersionInTx(ctx, tx, documentID, changelog, false)
|
||||
@@ -566,6 +637,13 @@ func (s *DocumentService) Create(
|
||||
return fmt.Errorf("cannot create document version: %w", err)
|
||||
}
|
||||
|
||||
if len(req.DefaultApproverIDs) > 0 {
|
||||
approvers := &coredata.DocumentDefaultApprovers{}
|
||||
if err := approvers.MergeByDocumentID(ctx, conn, s.svc.scope, documentID, organization.ID, req.DefaultApproverIDs); err != nil {
|
||||
return fmt.Errorf("cannot set default approvers: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
@@ -916,19 +994,30 @@ func (s *DocumentService) RequestSignature(
|
||||
ctx context.Context,
|
||||
req RequestSignatureRequest,
|
||||
) (*coredata.DocumentVersionSignature, error) {
|
||||
documentVersion, err := s.GetVersion(ctx, req.DocumentVersionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot get document version: %w", err)
|
||||
}
|
||||
|
||||
if documentVersion.Status != coredata.DocumentVersionStatusPublished {
|
||||
return nil, fmt.Errorf("cannot request signature for unpublished version")
|
||||
}
|
||||
|
||||
var signature *coredata.DocumentVersionSignature
|
||||
err = s.svc.pg.WithTx(
|
||||
|
||||
err := s.svc.pg.WithTx(
|
||||
ctx,
|
||||
func(ctx context.Context, tx pg.Tx) error {
|
||||
documentVersion := &coredata.DocumentVersion{}
|
||||
if err := documentVersion.LoadByID(ctx, tx, s.svc.scope, req.DocumentVersionID); err != nil {
|
||||
return fmt.Errorf("cannot load document version: %w", err)
|
||||
}
|
||||
|
||||
document := &coredata.Document{}
|
||||
if err := document.LoadByID(ctx, tx, s.svc.scope, documentVersion.DocumentID); err != nil {
|
||||
return fmt.Errorf("cannot load document: %w", err)
|
||||
}
|
||||
|
||||
if document.ArchivedAt != nil {
|
||||
return &ErrDocumentArchived{}
|
||||
}
|
||||
|
||||
if documentVersion.Status != coredata.DocumentVersionStatusPublished {
|
||||
return fmt.Errorf("cannot request signature for unpublished version")
|
||||
}
|
||||
|
||||
var err error
|
||||
signature, err = s.createSignatureRequestInTx(ctx, tx, req.DocumentVersionID, req.Signatory, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create signature request: %w", err)
|
||||
@@ -1015,12 +1104,16 @@ func (s *DocumentService) CreateDraft(
|
||||
return fmt.Errorf("cannot load document: %w", err)
|
||||
}
|
||||
|
||||
if document.ArchivedAt != nil {
|
||||
return &ErrDocumentArchived{}
|
||||
}
|
||||
|
||||
if err := latestVersion.LoadLatestVersion(ctx, conn, s.svc.scope, documentID); err != nil {
|
||||
return fmt.Errorf("cannot load latest version: %w", err)
|
||||
}
|
||||
|
||||
if latestVersion.Status != coredata.DocumentVersionStatusPublished {
|
||||
return fmt.Errorf("cannot create draft from unpublished version")
|
||||
return &ErrDocumentVersionNotPublished{}
|
||||
}
|
||||
|
||||
draftVersion.ID = draftVersionID
|
||||
@@ -1064,6 +1157,15 @@ func (s *DocumentService) DeleteDraft(
|
||||
return fmt.Errorf("cannot load document version: %w", err)
|
||||
}
|
||||
|
||||
document := &coredata.Document{}
|
||||
if err := document.LoadByID(ctx, conn, s.svc.scope, documentVersion.DocumentID); err != nil {
|
||||
return fmt.Errorf("cannot load document: %w", err)
|
||||
}
|
||||
|
||||
if document.ArchivedAt != nil {
|
||||
return &ErrDocumentArchived{}
|
||||
}
|
||||
|
||||
if documentVersion.Status != coredata.DocumentVersionStatusDraft {
|
||||
return fmt.Errorf("cannot delete published document version")
|
||||
}
|
||||
@@ -1639,6 +1741,13 @@ func (s *DocumentService) Update(
|
||||
}
|
||||
}
|
||||
|
||||
if req.DefaultApproverIDs != nil {
|
||||
defaultApprovers := &coredata.DocumentDefaultApprovers{}
|
||||
if err := defaultApprovers.MergeByDocumentID(ctx, tx, s.svc.scope, req.DocumentID, document.OrganizationID, *req.DefaultApproverIDs); err != nil {
|
||||
return fmt.Errorf("cannot update default approvers: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
@@ -1753,6 +1862,20 @@ func (s *DocumentService) CancelSignatureRequest(
|
||||
return fmt.Errorf("cannot load document version signature: %w", err)
|
||||
}
|
||||
|
||||
documentVersion := &coredata.DocumentVersion{}
|
||||
if err := documentVersion.LoadByID(ctx, tx, s.svc.scope, documentVersionSignature.DocumentVersionID); err != nil {
|
||||
return fmt.Errorf("cannot load document version: %w", err)
|
||||
}
|
||||
|
||||
document := &coredata.Document{}
|
||||
if err := document.LoadByID(ctx, tx, s.svc.scope, documentVersion.DocumentID); err != nil {
|
||||
return fmt.Errorf("cannot load document: %w", err)
|
||||
}
|
||||
|
||||
if document.ArchivedAt != nil {
|
||||
return &ErrDocumentArchived{}
|
||||
}
|
||||
|
||||
if documentVersionSignature.State != coredata.DocumentVersionSignatureStateRequested {
|
||||
return ErrSignatureNotCancellable{
|
||||
currentState: documentVersionSignature.State,
|
||||
@@ -2269,7 +2392,7 @@ func (s *DocumentService) loadDraftForPublish(
|
||||
return document, documentVersion, nil
|
||||
}
|
||||
|
||||
if documentVersion.Status != coredata.DocumentVersionStatusDraft {
|
||||
if documentVersion.Status != coredata.DocumentVersionStatusDraft && documentVersion.Status != coredata.DocumentVersionStatusPendingApproval {
|
||||
return nil, nil, &ErrDocumentVersionNotDraft{}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,71 +20,13 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
organizationCondition = policy.Equals("principal.organization_id", "resource.organization_id")
|
||||
documentWriteActiveOnly = policy.Deny(
|
||||
ActionDocumentUpdate,
|
||||
ActionDocumentArchive,
|
||||
ActionDocumentDraftVersionCreate,
|
||||
ActionDocumentChangelogGenerate,
|
||||
ActionDocumentSendSigningNotifications,
|
||||
ActionDocumentVersionUpdate,
|
||||
ActionDocumentVersionPublish,
|
||||
ActionDocumentVersionRequestApproval,
|
||||
ActionDocumentVersionApprove,
|
||||
ActionDocumentVersionReject,
|
||||
ActionDocumentVersionAddApprover,
|
||||
ActionDocumentVersionRemoveApprover,
|
||||
ActionDocumentVersionDeleteDraft,
|
||||
ActionDocumentVersionSignatureRequest,
|
||||
ActionDocumentVersionCancelSignature,
|
||||
).WithSID("document-write-active-only").When(
|
||||
organizationCondition,
|
||||
policy.Equals("resource.document_status", "ARCHIVED"),
|
||||
)
|
||||
documentUnarchiveArchivedOnly = policy.Deny(
|
||||
ActionDocumentUnarchive,
|
||||
).WithSID("document-unarchive-archived-only").When(
|
||||
organizationCondition,
|
||||
policy.Equals("resource.document_status", "ACTIVE"),
|
||||
)
|
||||
|
||||
// Deny requesting approval when a pending quorum exists
|
||||
documentRequestApprovalNoPendingQuorum = policy.Deny(
|
||||
ActionDocumentVersionRequestApproval,
|
||||
).WithSID("document-request-approval-no-pending-quorum").When(
|
||||
organizationCondition,
|
||||
policy.Equals("resource.last_quorum_status", "PENDING"),
|
||||
)
|
||||
|
||||
// Deny requesting approval when the version is already published
|
||||
documentRequestApprovalNotPublished = policy.Deny(
|
||||
ActionDocumentVersionRequestApproval,
|
||||
).WithSID("document-request-approval-not-published").When(
|
||||
organizationCondition,
|
||||
policy.Equals("resource.version_status", "PUBLISHED"),
|
||||
)
|
||||
|
||||
// Deny adding/removing approvers when there is no pending quorum
|
||||
documentApproverRequiresPendingQuorum = policy.Deny(
|
||||
ActionDocumentVersionAddApprover,
|
||||
ActionDocumentVersionRemoveApprover,
|
||||
).WithSID("document-approver-requires-pending-quorum").When(
|
||||
organizationCondition,
|
||||
policy.NotEquals("resource.last_quorum_status", "PENDING"),
|
||||
)
|
||||
organizationCondition = policy.Equals("principal.organization_id", "resource.organization_id")
|
||||
)
|
||||
|
||||
// OwnerPolicy defines permissions for organization owners.
|
||||
var OwnerPolicy = policy.NewPolicy(
|
||||
"probo:owner",
|
||||
"Probo Owner",
|
||||
documentWriteActiveOnly,
|
||||
documentUnarchiveArchivedOnly,
|
||||
|
||||
documentRequestApprovalNoPendingQuorum,
|
||||
documentRequestApprovalNotPublished,
|
||||
|
||||
documentApproverRequiresPendingQuorum,
|
||||
policy.Allow("core:*").WithSID("full-core-access").When(organizationCondition),
|
||||
).WithDescription("Full probo access for organization owners")
|
||||
|
||||
@@ -92,13 +34,6 @@ var OwnerPolicy = policy.NewPolicy(
|
||||
var AdminPolicy = policy.NewPolicy(
|
||||
"probo:admin",
|
||||
"Probo Admin",
|
||||
documentWriteActiveOnly,
|
||||
documentUnarchiveArchivedOnly,
|
||||
|
||||
documentRequestApprovalNoPendingQuorum,
|
||||
documentRequestApprovalNotPublished,
|
||||
|
||||
documentApproverRequiresPendingQuorum,
|
||||
policy.Allow("core:*").WithSID("full-core-access").When(organizationCondition),
|
||||
).WithDescription("Probo admin access - can manage core entities")
|
||||
|
||||
@@ -106,7 +41,6 @@ var AdminPolicy = policy.NewPolicy(
|
||||
var ViewerPolicy = policy.NewPolicy(
|
||||
"probo:viewer",
|
||||
"Probo Viewer",
|
||||
documentWriteActiveOnly,
|
||||
policy.Allow(
|
||||
ActionOrganizationGet,
|
||||
ActionOrganizationGetLogoUrl,
|
||||
@@ -244,7 +178,6 @@ var AuditorPolicy = policy.NewPolicy(
|
||||
var EmployeePolicy = policy.NewPolicy(
|
||||
"probo:employee",
|
||||
"Probo Employee",
|
||||
documentWriteActiveOnly,
|
||||
policy.Allow(
|
||||
ActionOrganizationGet,
|
||||
ActionOrganizationGetLogoUrl,
|
||||
@@ -263,7 +196,6 @@ var EmployeePolicy = policy.NewPolicy(
|
||||
ActionDocumentVersionApprovalList,
|
||||
ActionDocumentVersionApprove,
|
||||
ActionDocumentVersionReject,
|
||||
ActionEmployeeDocumentVersionExportPDF,
|
||||
).WithSID("document-version-approval").When(organizationCondition),
|
||||
).WithDescription("Employee access - can sign documents, approve documents, and view internal content")
|
||||
|
||||
|
||||
@@ -104,6 +104,10 @@ enum DocumentVersionStatus
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionStatusDraft"
|
||||
)
|
||||
PENDING_APPROVAL
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionStatusPendingApproval"
|
||||
)
|
||||
PUBLISHED
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionStatusPublished"
|
||||
@@ -2583,6 +2587,8 @@ type Document implements Node {
|
||||
filter: ControlFilter
|
||||
): ControlConnection! @goField(forceResolver: true)
|
||||
|
||||
defaultApprovers: [Profile!]! @goField(forceResolver: true)
|
||||
|
||||
status: DocumentStatus!
|
||||
archivedAt: Datetime
|
||||
|
||||
@@ -3934,6 +3940,9 @@ type Mutation {
|
||||
requestDocumentVersionApproval(
|
||||
input: RequestDocumentVersionApprovalInput!
|
||||
): RequestDocumentVersionApprovalPayload!
|
||||
voidDocumentVersionApproval(
|
||||
input: VoidDocumentVersionApprovalInput!
|
||||
): VoidDocumentVersionApprovalPayload!
|
||||
bulkDeleteDocuments(
|
||||
input: BulkDeleteDocumentsInput!
|
||||
): BulkDeleteDocumentsPayload!
|
||||
@@ -3969,12 +3978,6 @@ type Mutation {
|
||||
input: CancelSignatureRequestInput!
|
||||
): CancelSignatureRequestPayload!
|
||||
signDocument(input: SignDocumentInput!): SignDocumentPayload!
|
||||
addDocumentVersionApprover(
|
||||
input: AddDocumentVersionApproverInput!
|
||||
): AddDocumentVersionApproverPayload!
|
||||
removeDocumentVersionApprover(
|
||||
input: RemoveDocumentVersionApproverInput!
|
||||
): RemoveDocumentVersionApproverPayload!
|
||||
approveDocumentVersion(
|
||||
input: ApproveDocumentVersionInput!
|
||||
): ApproveDocumentVersionPayload!
|
||||
@@ -4668,6 +4671,7 @@ input CreateDocumentInput {
|
||||
documentType: DocumentType!
|
||||
classification: DocumentClassification!
|
||||
trustCenterVisibility: TrustCenterVisibility
|
||||
defaultApproverIds: [ID!]
|
||||
}
|
||||
|
||||
input UpdateDocumentInput {
|
||||
@@ -4675,6 +4679,7 @@ input UpdateDocumentInput {
|
||||
title: String
|
||||
content: String
|
||||
trustCenterVisibility: TrustCenterVisibility
|
||||
defaultApproverIds: [ID!]
|
||||
}
|
||||
|
||||
input ExportDocumentVersionPDFInput {
|
||||
@@ -5686,6 +5691,10 @@ enum DocumentVersionApprovalDecisionState
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalDecisionStateRejected"
|
||||
)
|
||||
VOIDED
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalDecisionStateVoided"
|
||||
)
|
||||
}
|
||||
|
||||
enum DocumentVersionApprovalDecisionOrderField
|
||||
@@ -5714,6 +5723,10 @@ enum DocumentVersionApprovalQuorumStatus
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalQuorumStatusRejected"
|
||||
)
|
||||
VOIDED
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalQuorumStatusVoided"
|
||||
)
|
||||
}
|
||||
|
||||
enum DocumentVersionApprovalQuorumOrderField
|
||||
@@ -5816,23 +5829,6 @@ type RejectDocumentVersionPayload {
|
||||
approvalDecision: DocumentVersionApprovalDecision!
|
||||
}
|
||||
|
||||
input AddDocumentVersionApproverInput {
|
||||
documentVersionId: ID!
|
||||
approverId: ID!
|
||||
}
|
||||
|
||||
type AddDocumentVersionApproverPayload {
|
||||
approvalDecisionEdge: DocumentVersionApprovalDecisionEdge!
|
||||
}
|
||||
|
||||
input RemoveDocumentVersionApproverInput {
|
||||
approvalDecisionId: ID!
|
||||
}
|
||||
|
||||
type RemoveDocumentVersionApproverPayload {
|
||||
deletedApprovalDecisionId: ID!
|
||||
documentVersion: DocumentVersion!
|
||||
}
|
||||
|
||||
input RequestSignatureInput {
|
||||
documentVersionId: ID!
|
||||
@@ -5897,6 +5893,15 @@ type RequestDocumentVersionApprovalPayload {
|
||||
approvalQuorum: DocumentVersionApprovalQuorum!
|
||||
}
|
||||
|
||||
input VoidDocumentVersionApprovalInput {
|
||||
documentVersionId: ID!
|
||||
}
|
||||
|
||||
type VoidDocumentVersionApprovalPayload {
|
||||
approvalQuorum: DocumentVersionApprovalQuorum!
|
||||
documentVersion: DocumentVersion!
|
||||
}
|
||||
|
||||
input PublishMajorDocumentVersionInput {
|
||||
documentId: ID!
|
||||
changelog: String
|
||||
|
||||
@@ -1577,6 +1577,28 @@ func (r *documentResolver) Controls(ctx context.Context, obj *types.Document, fi
|
||||
return types.NewControlConnection(page, r, obj.ID, controlFilter), nil
|
||||
}
|
||||
|
||||
// DefaultApprovers is the resolver for the defaultApprovers field.
|
||||
func (r *documentResolver) DefaultApprovers(ctx context.Context, obj *types.Document) ([]*types.Profile, error) {
|
||||
if err := r.authorize(ctx, obj.ID, probo.ActionDocumentGet); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prb := r.ProboService(ctx, obj.ID.TenantID())
|
||||
|
||||
profiles, err := prb.Documents.GetDefaultApprovers(ctx, obj.ID)
|
||||
if err != nil {
|
||||
r.logger.ErrorCtx(ctx, "cannot get default approvers", log.Error(err))
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
result := make([]*types.Profile, len(profiles))
|
||||
for i, p := range profiles {
|
||||
result[i] = types.NewProfile(p)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Permission is the resolver for the permission field.
|
||||
func (r *documentResolver) Permission(ctx context.Context, obj *types.Document, action string) (bool, error) {
|
||||
return r.Resolver.Permission(ctx, obj, action)
|
||||
@@ -5247,6 +5269,7 @@ func (r *mutationResolver) CreateDocument(ctx context.Context, input types.Creat
|
||||
Classification: input.Classification,
|
||||
DocumentType: input.DocumentType,
|
||||
TrustCenterVisibility: input.TrustCenterVisibility,
|
||||
DefaultApproverIDs: input.DefaultApproverIds,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
@@ -5275,12 +5298,18 @@ func (r *mutationResolver) UpdateDocument(ctx context.Context, input types.Updat
|
||||
|
||||
prb := r.ProboService(ctx, input.ID.TenantID())
|
||||
|
||||
var defaultApproverIDs *[]gid.GID
|
||||
if input.DefaultApproverIds != nil {
|
||||
defaultApproverIDs = &input.DefaultApproverIds
|
||||
}
|
||||
|
||||
document, err := prb.Documents.Update(
|
||||
ctx,
|
||||
probo.UpdateDocumentRequest{
|
||||
DocumentID: input.ID,
|
||||
Title: input.Title,
|
||||
TrustCenterVisibility: input.TrustCenterVisibility,
|
||||
DefaultApproverIDs: defaultApproverIDs,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -5659,6 +5688,10 @@ func (r *mutationResolver) PublishMajorDocumentVersion(ctx context.Context, inpu
|
||||
return nil, gqlutils.Invalid(ctx, errNotDraft)
|
||||
}
|
||||
|
||||
if errPending, ok := errors.AsType[*probo.ErrDocumentVersionPendingApproval](err); ok {
|
||||
return nil, gqlutils.Conflict(ctx, errPending)
|
||||
}
|
||||
|
||||
r.logger.ErrorCtx(ctx, "cannot publish major document version", log.Error(err))
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
@@ -5692,6 +5725,10 @@ func (r *mutationResolver) PublishMinorDocumentVersion(ctx context.Context, inpu
|
||||
return nil, gqlutils.Invalid(ctx, errNotDraft)
|
||||
}
|
||||
|
||||
if errPending, ok := errors.AsType[*probo.ErrDocumentVersionPendingApproval](err); ok {
|
||||
return nil, gqlutils.Conflict(ctx, errPending)
|
||||
}
|
||||
|
||||
r.logger.ErrorCtx(ctx, "cannot publish minor document version", log.Error(err))
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
@@ -5719,7 +5756,7 @@ func (r *mutationResolver) BulkPublishMajorDocumentVersions(ctx context.Context,
|
||||
|
||||
prb := r.ProboService(ctx, input.DocumentIds[0].TenantID())
|
||||
|
||||
versions, documents, err := prb.Documents.BulkPublishMajorVersions(ctx, probo.BulkPublishVersionsRequest{
|
||||
versions, documents, err := prb.DocumentApprovals.BulkPublishMajorVersions(ctx, probo.BulkPublishVersionsRequest{
|
||||
DocumentIDs: input.DocumentIds,
|
||||
Changelog: input.Changelog,
|
||||
})
|
||||
@@ -5820,6 +5857,10 @@ func (r *mutationResolver) RequestDocumentVersionApproval(ctx context.Context, i
|
||||
return nil, gqlutils.Conflict(ctx, errArchived)
|
||||
}
|
||||
|
||||
if errNotDraft, ok := errors.AsType[*probo.ErrDocumentVersionNotDraft](err); ok {
|
||||
return nil, gqlutils.Conflict(ctx, errNotDraft)
|
||||
}
|
||||
|
||||
if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
|
||||
return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors)
|
||||
}
|
||||
@@ -5833,6 +5874,34 @@ func (r *mutationResolver) RequestDocumentVersionApproval(ctx context.Context, i
|
||||
}, nil
|
||||
}
|
||||
|
||||
// VoidDocumentVersionApproval is the resolver for the voidDocumentVersionApproval field.
|
||||
func (r *mutationResolver) VoidDocumentVersionApproval(ctx context.Context, input types.VoidDocumentVersionApprovalInput) (*types.VoidDocumentVersionApprovalPayload, error) {
|
||||
if err := r.authorize(ctx, input.DocumentVersionID, probo.ActionDocumentVersionVoidApproval); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prb := r.ProboService(ctx, input.DocumentVersionID.TenantID())
|
||||
|
||||
quorum, documentVersion, err := prb.DocumentApprovals.VoidApproval(ctx, input.DocumentVersionID)
|
||||
if err != nil {
|
||||
if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok {
|
||||
return nil, gqlutils.Conflict(ctx, errArchived)
|
||||
}
|
||||
|
||||
if errNotPending, ok := errors.AsType[*probo.ErrDocumentVersionNotPendingApproval](err); ok {
|
||||
return nil, gqlutils.Conflict(ctx, errNotPending)
|
||||
}
|
||||
|
||||
r.logger.ErrorCtx(ctx, "cannot void document version approval", log.Error(err))
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
return &types.VoidDocumentVersionApprovalPayload{
|
||||
ApprovalQuorum: types.NewDocumentVersionApprovalQuorum(quorum),
|
||||
DocumentVersion: types.NewDocumentVersion(documentVersion),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BulkDeleteDocuments is the resolver for the bulkDeleteDocuments field.
|
||||
func (r *mutationResolver) BulkDeleteDocuments(ctx context.Context, input types.BulkDeleteDocumentsInput) (*types.BulkDeleteDocumentsPayload, error) {
|
||||
if len(input.DocumentIds) == 0 {
|
||||
@@ -5957,6 +6026,10 @@ func (r *mutationResolver) GenerateDocumentChangelog(ctx context.Context, input
|
||||
|
||||
changelog, err := prb.Documents.GenerateChangelog(ctx, input.DocumentID)
|
||||
if err != nil {
|
||||
if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok {
|
||||
return nil, gqlutils.Conflict(ctx, errArchived)
|
||||
}
|
||||
|
||||
r.logger.ErrorCtx(ctx, "cannot generate document changelog", log.Error(err))
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
@@ -5976,6 +6049,14 @@ func (r *mutationResolver) CreateDraftDocumentVersion(ctx context.Context, input
|
||||
|
||||
documentVersion, err := prb.Documents.CreateDraft(ctx, input.DocumentID)
|
||||
if err != nil {
|
||||
if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok {
|
||||
return nil, gqlutils.Conflict(ctx, errArchived)
|
||||
}
|
||||
|
||||
if errNotPublished, ok := errors.AsType[*probo.ErrDocumentVersionNotPublished](err); ok {
|
||||
return nil, gqlutils.Conflict(ctx, errNotPublished)
|
||||
}
|
||||
|
||||
r.logger.ErrorCtx(ctx, "cannot create draft document version", log.Error(err))
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
@@ -5995,6 +6076,10 @@ func (r *mutationResolver) DeleteDraftDocumentVersion(ctx context.Context, input
|
||||
|
||||
err := prb.Documents.DeleteDraft(ctx, input.DocumentVersionID)
|
||||
if err != nil {
|
||||
if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok {
|
||||
return nil, gqlutils.Conflict(ctx, errArchived)
|
||||
}
|
||||
|
||||
r.logger.ErrorCtx(ctx, "cannot delete draft document version", log.Error(err))
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
@@ -6061,6 +6146,10 @@ func (r *mutationResolver) RequestSignature(ctx context.Context, input types.Req
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok {
|
||||
return nil, gqlutils.Conflict(ctx, errArchived)
|
||||
}
|
||||
|
||||
r.logger.ErrorCtx(ctx, "cannot request signature", log.Error(err))
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
@@ -6132,6 +6221,10 @@ func (r *mutationResolver) CancelSignatureRequest(ctx context.Context, input typ
|
||||
|
||||
err := prb.Documents.CancelSignatureRequest(ctx, input.DocumentVersionSignatureID)
|
||||
if err != nil {
|
||||
if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok {
|
||||
return nil, gqlutils.Conflict(ctx, errArchived)
|
||||
}
|
||||
|
||||
r.logger.ErrorCtx(ctx, "cannot cancel signature request", log.Error(err))
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
@@ -6165,53 +6258,6 @@ func (r *mutationResolver) SignDocument(ctx context.Context, input types.SignDoc
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AddDocumentVersionApprover is the resolver for the addDocumentVersionApprover field.
|
||||
func (r *mutationResolver) AddDocumentVersionApprover(ctx context.Context, input types.AddDocumentVersionApproverInput) (*types.AddDocumentVersionApproverPayload, error) {
|
||||
if err := r.authorize(ctx, input.DocumentVersionID, probo.ActionDocumentVersionAddApprover); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prb := r.ProboService(ctx, input.DocumentVersionID.TenantID())
|
||||
|
||||
decision, err := prb.DocumentApprovals.AddApprover(ctx, input.DocumentVersionID, input.ApproverID)
|
||||
if err != nil {
|
||||
if errNotPending, ok := errors.AsType[*probo.ErrDocumentVersionNotPendingApproval](err); ok {
|
||||
return nil, gqlutils.Invalid(ctx, errNotPending)
|
||||
}
|
||||
|
||||
r.logger.ErrorCtx(ctx, "cannot add document version approver", log.Error(err))
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
return &types.AddDocumentVersionApproverPayload{
|
||||
ApprovalDecisionEdge: types.NewDocumentVersionApprovalDecisionEdge(decision, coredata.DocumentVersionApprovalDecisionOrderFieldCreatedAt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RemoveDocumentVersionApprover is the resolver for the removeDocumentVersionApprover field.
|
||||
func (r *mutationResolver) RemoveDocumentVersionApprover(ctx context.Context, input types.RemoveDocumentVersionApproverInput) (*types.RemoveDocumentVersionApproverPayload, error) {
|
||||
if err := r.authorize(ctx, input.ApprovalDecisionID, probo.ActionDocumentVersionRemoveApprover); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prb := r.ProboService(ctx, input.ApprovalDecisionID.TenantID())
|
||||
|
||||
documentVersionID, err := prb.DocumentApprovals.RemoveApprover(ctx, input.ApprovalDecisionID)
|
||||
if err != nil {
|
||||
if errAlreadyMade, ok := errors.AsType[*probo.ErrApprovalDecisionAlreadyMade](err); ok {
|
||||
return nil, gqlutils.Conflict(ctx, errAlreadyMade)
|
||||
}
|
||||
|
||||
r.logger.ErrorCtx(ctx, "cannot remove document version approver", log.Error(err))
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
return &types.RemoveDocumentVersionApproverPayload{
|
||||
DeletedApprovalDecisionID: input.ApprovalDecisionID,
|
||||
DocumentVersion: &types.DocumentVersion{ID: documentVersionID},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ApproveDocumentVersion is the resolver for the approveDocumentVersion field.
|
||||
func (r *mutationResolver) ApproveDocumentVersion(ctx context.Context, input types.ApproveDocumentVersionInput) (*types.ApproveDocumentVersionPayload, error) {
|
||||
if err := r.authorize(ctx, input.DocumentVersionID, probo.ActionDocumentVersionApprove); err != nil {
|
||||
@@ -6238,6 +6284,10 @@ func (r *mutationResolver) ApproveDocumentVersion(ctx context.Context, input typ
|
||||
SignerUA: httpReq.UserAgent(),
|
||||
})
|
||||
if err != nil {
|
||||
if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok {
|
||||
return nil, gqlutils.Conflict(ctx, errArchived)
|
||||
}
|
||||
|
||||
if errNotPending, ok := errors.AsType[*probo.ErrDocumentVersionNotPendingApproval](err); ok {
|
||||
return nil, gqlutils.Invalid(ctx, errNotPending)
|
||||
}
|
||||
@@ -6275,6 +6325,10 @@ func (r *mutationResolver) RejectDocumentVersion(ctx context.Context, input type
|
||||
Comment: input.Comment,
|
||||
})
|
||||
if err != nil {
|
||||
if errArchived, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok {
|
||||
return nil, gqlutils.Conflict(ctx, errArchived)
|
||||
}
|
||||
|
||||
if errNotPending, ok := errors.AsType[*probo.ErrDocumentVersionNotPendingApproval](err); ok {
|
||||
return nil, gqlutils.Invalid(ctx, errNotPending)
|
||||
}
|
||||
|
||||
@@ -2095,6 +2095,7 @@ func (r *Resolver) AddDocumentTool(ctx context.Context, req *mcp.CallToolRequest
|
||||
Classification: input.Classification,
|
||||
DocumentType: input.DocumentType,
|
||||
TrustCenterVisibility: trustCenterVisibility,
|
||||
DefaultApproverIDs: input.DefaultApproverIds,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
@@ -2109,12 +2110,18 @@ func (r *Resolver) UpdateDocumentTool(ctx context.Context, req *mcp.CallToolRequ
|
||||
|
||||
svc := r.ProboService(ctx, input.ID)
|
||||
|
||||
var defaultApproverIDs *[]gid.GID
|
||||
if input.DefaultApproverIds != nil {
|
||||
defaultApproverIDs = &input.DefaultApproverIds
|
||||
}
|
||||
|
||||
document, err := svc.Documents.Update(
|
||||
ctx,
|
||||
probo.UpdateDocumentRequest{
|
||||
DocumentID: input.ID,
|
||||
Title: input.Title,
|
||||
TrustCenterVisibility: input.TrustCenterVisibility,
|
||||
DefaultApproverIDs: defaultApproverIDs,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
@@ -3928,3 +3935,18 @@ func (r *Resolver) ListMeasureDocumentsTool(ctx context.Context, req *mcp.CallTo
|
||||
|
||||
return nil, types.NewListMeasureDocumentsOutput(docPage), nil
|
||||
}
|
||||
|
||||
func (r *Resolver) VoidDocumentVersionApprovalTool(ctx context.Context, req *mcp.CallToolRequest, input *types.VoidDocumentVersionApprovalInput) (*mcp.CallToolResult, types.VoidDocumentVersionApprovalOutput, error) {
|
||||
r.MustAuthorize(ctx, input.DocumentVersionID, probo.ActionDocumentVersionVoidApproval)
|
||||
|
||||
svc := r.ProboService(ctx, input.DocumentVersionID)
|
||||
|
||||
_, documentVersion, err := svc.DocumentApprovals.VoidApproval(ctx, input.DocumentVersionID)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot void document version approval: %w", err))
|
||||
}
|
||||
|
||||
return nil, types.VoidDocumentVersionApprovalOutput{
|
||||
DocumentVersion: types.NewDocumentVersion(documentVersion),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -5159,6 +5159,7 @@ components:
|
||||
type: string
|
||||
enum:
|
||||
- DRAFT
|
||||
- PENDING_APPROVAL
|
||||
- PUBLISHED
|
||||
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.DocumentVersionStatus
|
||||
|
||||
@@ -5495,6 +5496,11 @@ components:
|
||||
trust_center_visibility:
|
||||
$ref: "#/components/schemas/TrustCenterVisibility"
|
||||
description: Trust center visibility
|
||||
default_approver_ids:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Default approver profile IDs
|
||||
|
||||
AddDocumentOutput:
|
||||
type: object
|
||||
@@ -5522,6 +5528,11 @@ components:
|
||||
trust_center_visibility:
|
||||
$ref: "#/components/schemas/TrustCenterVisibility"
|
||||
description: Trust center visibility
|
||||
default_approver_ids:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Default approver profile IDs
|
||||
|
||||
UpdateDocumentOutput:
|
||||
type: object
|
||||
@@ -5860,6 +5871,23 @@ components:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Deleted document version signature ID
|
||||
|
||||
VoidDocumentVersionApprovalInput:
|
||||
type: object
|
||||
required:
|
||||
- document_version_id
|
||||
properties:
|
||||
document_version_id:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Document version ID
|
||||
|
||||
VoidDocumentVersionApprovalOutput:
|
||||
type: object
|
||||
required:
|
||||
- document_version
|
||||
properties:
|
||||
document_version:
|
||||
$ref: "#/components/schemas/DocumentVersion"
|
||||
|
||||
MeetingOrderField:
|
||||
type: string
|
||||
enum:
|
||||
@@ -8474,6 +8502,15 @@ tools:
|
||||
$ref: "#/components/schemas/CancelSignatureRequestInput"
|
||||
outputSchema:
|
||||
$ref: "#/components/schemas/CancelSignatureRequestOutput"
|
||||
- name: voidDocumentVersionApproval
|
||||
description: Void a pending document version approval request
|
||||
hints:
|
||||
readonly: false
|
||||
destructive: true
|
||||
inputSchema:
|
||||
$ref: "#/components/schemas/VoidDocumentVersionApprovalInput"
|
||||
outputSchema:
|
||||
$ref: "#/components/schemas/VoidDocumentVersionApprovalOutput"
|
||||
- name: listMeetings
|
||||
description: List all meetings for the organization
|
||||
hints:
|
||||
|
||||
@@ -47,6 +47,36 @@ func Required() ValidatorFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// NoDuplicates validates that a slice contains no duplicate elements.
|
||||
func NoDuplicates() ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
rv := reflect.ValueOf(actualValue)
|
||||
if rv.Kind() != reflect.Slice {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a slice")
|
||||
}
|
||||
|
||||
if !rv.Type().Elem().Comparable() {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "slice elements must be comparable")
|
||||
}
|
||||
|
||||
seen := make(map[any]struct{}, rv.Len())
|
||||
for i := range rv.Len() {
|
||||
elem := rv.Index(i).Interface()
|
||||
if _, ok := seen[elem]; ok {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "must not contain duplicates")
|
||||
}
|
||||
seen[elem] = struct{}{}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// NotEmpty validates that a field is not empty.
|
||||
// Similar to Required, but can be used independently.
|
||||
func NotEmpty() ValidatorFunc {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user