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