Add document approval workflow
Introduce a complete approval system for document publishing. Document versions can now require approval from selected reviewers before being published, with automatic publishing once all approvers have approved. - Add approval quorum and decision tables with backfill migration - Implement request approval, approve, and reject flows with electronic signature support for approve decisions - Add employee approvals page with dedicated tab and pending approvals view - Add changelog field to publish and request approval flows - Pre-select previous version's approvers in the publish dialog - Show quorum approvers in document list with 100 approver hard limit - Expose approval workflow through GraphQL, MCP, and CLI - Remove legacy default approvers feature entirely - Add comprehensive e2e test coverage for approval workflows Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Avatar, Badge, Button, Field, IconCrossLargeX, Option, Select } from "@probo/ui";
|
||||
import { Badge, Button, Field, IconCrossLargeX, Option, Select } from "@probo/ui";
|
||||
import { type ComponentProps, Suspense, useState } from "react";
|
||||
import { type Control, Controller, type FieldValues, type Path } from "react-hook-form";
|
||||
|
||||
@@ -104,10 +104,6 @@ function PeopleMultiSelectWithQuery<T extends FieldValues = FieldValues>(
|
||||
>
|
||||
{availablePeople.map(person => (
|
||||
<Option key={person.id} value={person.id} className="flex gap-2">
|
||||
<Avatar
|
||||
name={person.fullName}
|
||||
size="s"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span>{person.fullName}</span>
|
||||
{person.emailAddress && (
|
||||
@@ -125,10 +121,6 @@ function PeopleMultiSelectWithQuery<T extends FieldValues = FieldValues>(
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedPeople.map(person => (
|
||||
<Badge key={person.id} variant="neutral" className="flex items-center gap-2">
|
||||
<Avatar
|
||||
name={person.fullName}
|
||||
size="s"
|
||||
/>
|
||||
<span>{person.fullName}</span>
|
||||
{!props.disabled && (
|
||||
<Button
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useFormWithSchema } from "../useFormWithSchema";
|
||||
export const documentSchema = z.object({
|
||||
title: z.string().min(1, "Title is required"),
|
||||
content: z.string().min(1, "Content is required"),
|
||||
approverIds: z.array(z.string()).min(1, "At least one approver is required"),
|
||||
documentType: z.enum(["OTHER", "GOVERNANCE", "POLICY", "PROCEDURE", "PLAN", "REGISTER", "RECORD", "REPORT", "TEMPLATE"]),
|
||||
classification: z.enum(["PUBLIC", "INTERNAL", "CONFIDENTIAL", "SECRET"]),
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ const documentFragment = graphql`
|
||||
latestPublishedVersion: versions(
|
||||
first: 1
|
||||
orderBy: { field: CREATED_AT, direction: DESC }
|
||||
filter: { status: PUBLISHED }
|
||||
filter: { statuses: [PUBLISHED] }
|
||||
) {
|
||||
edges {
|
||||
node {
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Breadcrumb, Button, IconCheckmark1, PageHeader, TabBadge, TabLink, Tabs } from "@probo/ui";
|
||||
import { Breadcrumb, Button, IconUpload, PageHeader, TabBadge, TabLink, Tabs } from "@probo/ui";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { type PreloadedQuery, usePreloadedQuery } from "react-relay";
|
||||
import { Outlet, useParams } from "react-router";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { DocumentLayoutQuery } from "#/__generated__/core/DocumentLayoutQuery.graphql";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
import { DocumentActionsDropdownn } from "./_components/DocumentActionsDropdown";
|
||||
import { DocumentLayoutDrawer } from "./_components/DocumentLayoutDrawer";
|
||||
import { DocumentTitleForm } from "./_components/DocumentTitleForm";
|
||||
import { DocumentVersionsDropdown } from "./_components/DocumentVersionsDropdown";
|
||||
import { PublishDialog, type PublishDialogRef } from "./_components/PublishDialog";
|
||||
|
||||
export const documentLayoutQuery = graphql`
|
||||
query DocumentLayoutQuery($documentId: ID! $versionId: ID! $versionSpecified: Boolean!) {
|
||||
@@ -29,6 +30,20 @@ export const documentLayoutQuery = graphql`
|
||||
signedSignatures: signatures(first: 0 filter: { states: [SIGNED], activeContract: true }) {
|
||||
totalCount
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
document: node(id: $documentId) {
|
||||
@@ -38,6 +53,7 @@ export const documentLayoutQuery = graphql`
|
||||
title
|
||||
status
|
||||
canPublish: permission(action: "core:document-version:publish")
|
||||
...PublishDialog_documentFragment
|
||||
controlInfo: controls(first: 0) {
|
||||
totalCount
|
||||
}
|
||||
@@ -58,6 +74,20 @@ export const documentLayoutQuery = graphql`
|
||||
signedSignatures: signatures(first: 0 filter: { states: [SIGNED], activeContract: true }) {
|
||||
totalCount
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,18 +96,6 @@ export const documentLayoutQuery = graphql`
|
||||
}
|
||||
`;
|
||||
|
||||
const publishDocumentVersionMutation = graphql`
|
||||
mutation DocumentLayout_publishVersionMutation(
|
||||
$input: PublishDocumentVersionInput!
|
||||
) {
|
||||
publishDocumentVersion(input: $input) {
|
||||
document {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQuery>; onRefetch: () => void }) {
|
||||
const { queryRef, onRefetch } = props;
|
||||
|
||||
@@ -86,6 +104,14 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
|
||||
|
||||
const { __ } = useTranslate();
|
||||
|
||||
const publishDialogRef = useRef<PublishDialogRef>(null);
|
||||
const [approvalRequestedAt, setApprovalRequestedAt] = useState(0);
|
||||
|
||||
const handlePublishOrApproval = useCallback(() => {
|
||||
onRefetch();
|
||||
setApprovalRequestedAt(Date.now());
|
||||
}, [onRefetch]);
|
||||
|
||||
const { document, version } = usePreloadedQuery<DocumentLayoutQuery>(documentLayoutQuery, queryRef);
|
||||
if (document.__typename !== "Document" || (version && version.__typename !== "DocumentVersion")) {
|
||||
throw new Error("invalid node type");
|
||||
@@ -99,23 +125,10 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
|
||||
// It is ok to cas as NonNullable here since we know we have either version or lastVersion
|
||||
const currentVersion = version ?? lastVersion as NonNullable<typeof version | typeof lastVersion>;
|
||||
const isDraft = currentVersion.status === "DRAFT";
|
||||
|
||||
const [publishDocumentVersion, isPublishing] = useMutationWithToasts(
|
||||
publishDocumentVersionMutation,
|
||||
{
|
||||
successMessage: __("Document published successfully."),
|
||||
errorMessage: __("Failed to publish document"),
|
||||
},
|
||||
);
|
||||
|
||||
const handlePublish = async () => {
|
||||
await publishDocumentVersion({
|
||||
variables: {
|
||||
input: { documentId: document.id },
|
||||
},
|
||||
onSuccess: onRefetch,
|
||||
});
|
||||
};
|
||||
const isPublished = currentVersion.status === "PUBLISHED";
|
||||
const lastQuorum = currentVersion.approvalQuorums?.edges?.[0]?.node ?? null;
|
||||
const hasApprovals = lastQuorum != null;
|
||||
const hasPendingApproval = lastQuorum?.status === "PENDING";
|
||||
|
||||
const urlPrefix = versionId
|
||||
? `/organizations/${organizationId}/documents/${document.id}/versions/${versionId}`
|
||||
@@ -140,9 +153,8 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
|
||||
<div className="flex gap-2">
|
||||
{isDraft && document.canPublish && (
|
||||
<Button
|
||||
onClick={() => void handlePublish()}
|
||||
icon={IconCheckmark1}
|
||||
disabled={isPublishing}
|
||||
icon={IconUpload}
|
||||
onClick={() => publishDialogRef.current?.open()}
|
||||
>
|
||||
{__("Publish")}
|
||||
</Button>
|
||||
@@ -166,7 +178,17 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
|
||||
{__("Controls")}
|
||||
<TabBadge>{document.controlInfo.totalCount}</TabBadge>
|
||||
</TabLink>
|
||||
{!isDraft && (
|
||||
{hasApprovals && (
|
||||
<TabLink to={`${urlPrefix}/approvals`}>
|
||||
{__("Approvals")}
|
||||
<TabBadge>
|
||||
{lastQuorum?.status === "REJECTED"
|
||||
? __("Rejected")
|
||||
: `${lastQuorum?.approvedDecisions.totalCount ?? 0}/${lastQuorum?.decisions.totalCount ?? 0}`}
|
||||
</TabBadge>
|
||||
</TabLink>
|
||||
)}
|
||||
{isPublished && (
|
||||
<TabLink to={`${urlPrefix}/signatures`}>
|
||||
{__("Signatures")}
|
||||
<TabBadge>
|
||||
@@ -178,10 +200,18 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
<Outlet />
|
||||
<Outlet context={{ onRefetch, approvalRequestedAt }} />
|
||||
</div>
|
||||
|
||||
<DocumentLayoutDrawer documentFragmentRef={document} versionFragmentRef={currentVersion} />
|
||||
|
||||
<PublishDialog
|
||||
ref={publishDialogRef}
|
||||
documentId={document.id}
|
||||
documentFragmentRef={document}
|
||||
hasPendingApproval={hasPendingApproval}
|
||||
onSuccess={handlePublishOrApproval}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import type { CreateDocumentDialogMutation } from "#/__generated__/core/CreateDo
|
||||
import { ControlledField } from "#/components/form/ControlledField";
|
||||
import { DocumentClassificationOptions } from "#/components/form/DocumentClassificationOptions";
|
||||
import { DocumentTypeOptions } from "#/components/form/DocumentTypeOptions";
|
||||
import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField";
|
||||
import { documentSchema, useDocumentForm } from "#/hooks/forms/useDocumentForm";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
@@ -148,18 +147,6 @@ export function CreateDocumentDialog({ trigger, connection }: Props) {
|
||||
</ControlledField>
|
||||
</PropertyRow>
|
||||
|
||||
<PropertyRow
|
||||
id="approverIds"
|
||||
label={__("Approvers")}
|
||||
error={errors.approverIds?.message}
|
||||
>
|
||||
<PeopleMultiSelectField
|
||||
name="approverIds"
|
||||
control={control}
|
||||
organizationId={organizationId}
|
||||
placeholder={__("Add approvers...")}
|
||||
/>
|
||||
</PropertyRow>
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { documentClassifications, documentTypes, formatDate, getDocumentClassificationLabel, getDocumentTypeLabel } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Avatar, Badge, Button, Drawer, IconCheckmark1, IconCrossLargeX, IconPencil, PropertyRow } from "@probo/ui";
|
||||
import { Badge, Button, Drawer, IconCheckmark1, IconCrossLargeX, IconPencil, PropertyRow } from "@probo/ui";
|
||||
import { useState } from "react";
|
||||
import { useFragment } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
@@ -12,10 +12,8 @@ import type { DocumentLayoutDrawerMutation } from "#/__generated__/core/Document
|
||||
import { ControlledField } from "#/components/form/ControlledField";
|
||||
import { DocumentClassificationOptions } from "#/components/form/DocumentClassificationOptions";
|
||||
import { DocumentTypeOptions } from "#/components/form/DocumentTypeOptions";
|
||||
import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField";
|
||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
const documentFragment = graphql`
|
||||
fragment DocumentLayoutDrawer_documentFragment on Document {
|
||||
@@ -24,14 +22,6 @@ const documentFragment = graphql`
|
||||
status
|
||||
archivedAt
|
||||
canUpdate: permission(action: "core:document:update")
|
||||
approvers(first: 100) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
fullName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -53,21 +43,12 @@ const updateDocumentMutation = graphql`
|
||||
id
|
||||
documentType
|
||||
classification
|
||||
approvers(first: 100) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
fullName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const schema = z.object({
|
||||
approverIds: z.array(z.string()).min(1, "At least one approver is required"),
|
||||
documentType: z.enum(documentTypes),
|
||||
classification: z.enum(documentClassifications),
|
||||
});
|
||||
@@ -78,10 +59,8 @@ export function DocumentLayoutDrawer(props: {
|
||||
}) {
|
||||
const { documentFragmentRef, versionFragmentRef } = props;
|
||||
|
||||
const organizationId = useOrganizationId();
|
||||
const { __ } = useTranslate();
|
||||
|
||||
const [isEditingApprover, setIsEditingApprover] = useState(false);
|
||||
const [isEditingType, setIsEditingType] = useState(false);
|
||||
const [isEditingClassification, setIsEditingClassification] = useState(false);
|
||||
|
||||
@@ -91,13 +70,10 @@ export function DocumentLayoutDrawer(props: {
|
||||
const isDraft = version.status === "DRAFT";
|
||||
const canEdit = document.canUpdate;
|
||||
|
||||
const approvers = document.approvers.edges.map(e => e.node);
|
||||
|
||||
const { control, handleSubmit, reset } = useFormWithSchema(
|
||||
schema,
|
||||
{
|
||||
defaultValues: {
|
||||
approverIds: approvers.map(a => a.id),
|
||||
documentType: document.documentType,
|
||||
classification: version.classification,
|
||||
},
|
||||
@@ -113,20 +89,6 @@ export function DocumentLayoutDrawer(props: {
|
||||
},
|
||||
);
|
||||
|
||||
const handleUpdateApprover = async (data: { approverIds: string[] }) => {
|
||||
await updateDocument({
|
||||
variables: {
|
||||
input: {
|
||||
id: document.id,
|
||||
approverIds: data.approverIds,
|
||||
},
|
||||
},
|
||||
onSuccess: () => {
|
||||
setIsEditingApprover(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateDocumentType = async (data: {
|
||||
documentType: (typeof documentTypes)[number];
|
||||
}) => {
|
||||
@@ -164,42 +126,6 @@ export function DocumentLayoutDrawer(props: {
|
||||
<div className="text-base text-txt-primary font-medium mb-4">
|
||||
{__("Properties")}
|
||||
</div>
|
||||
<PropertyRow label={__("Approvers")}>
|
||||
{isEditingApprover
|
||||
? (
|
||||
<EditablePropertyContent
|
||||
onSave={() => void handleSubmit(handleUpdateApprover)()}
|
||||
onCancel={() => {
|
||||
setIsEditingApprover(false);
|
||||
reset();
|
||||
}}
|
||||
disabled={isUpdatingDocument}
|
||||
>
|
||||
<PeopleMultiSelectField
|
||||
name="approverIds"
|
||||
control={control}
|
||||
organizationId={organizationId}
|
||||
selectedPeople={approvers}
|
||||
placeholder={__("Add approvers...")}
|
||||
/>
|
||||
</EditablePropertyContent>
|
||||
)
|
||||
: (
|
||||
<ReadOnlyPropertyContent
|
||||
onEdit={() => setIsEditingApprover(true)}
|
||||
canEdit={canEdit}
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{approvers.map(approver => (
|
||||
<Badge key={approver.id} variant="highlight" size="md" className="gap-2">
|
||||
<Avatar name={approver.fullName} />
|
||||
{approver.fullName}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</ReadOnlyPropertyContent>
|
||||
)}
|
||||
</PropertyRow>
|
||||
<PropertyRow label={__("Type")}>
|
||||
{isEditingType
|
||||
? (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { documentTypes, getDocumentTypeLabel, sprintf } from "@probo/helpers";
|
||||
import { useList } from "@probo/hooks";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Button, Card, Checkbox, IconArchive, IconArrowDown, IconCheckmark1, IconCrossLargeX, IconSignature, IconTrashCan, Option, Select, Tbody, Th, Thead, Tr, useConfirm } from "@probo/ui";
|
||||
import { Button, Card, Checkbox, IconArchive, IconArrowDown, IconCrossLargeX, IconSignature, IconTrashCan, IconUpload, Option, Select, Tbody, Th, Thead, Tr, useConfirm } from "@probo/ui";
|
||||
import { type ComponentProps, use, useEffect, useRef, useState, useTransition } from "react";
|
||||
import { usePaginationFragment } from "react-relay";
|
||||
import { ConnectionHandler, graphql } from "relay-runtime";
|
||||
@@ -295,7 +295,7 @@ export function DocumentList(props: {
|
||||
onChange={() => reset(documents.map(d => d.id))}
|
||||
/>
|
||||
</Th>
|
||||
<SortableTh field="TITLE" className="min-w-0" onOrderChange={handleOrderChange}>
|
||||
<SortableTh field="TITLE" className="min-w-0 pr-12" onOrderChange={handleOrderChange}>
|
||||
{__("Name")}
|
||||
</SortableTh>
|
||||
<Th className="w-24">{__("Status")}</Th>
|
||||
@@ -306,13 +306,14 @@ export function DocumentList(props: {
|
||||
<Th className="w-32">{__("Classification")}</Th>
|
||||
<Th className="w-60">{__("Approvers")}</Th>
|
||||
<Th className="w-60">{__("Last update")}</Th>
|
||||
<Th className="w-20">{__("Approvals")}</Th>
|
||||
<Th className="w-20">{__("Signatures")}</Th>
|
||||
{hasAnyAction && <Th className="w-18"></Th>}
|
||||
</Tr>
|
||||
)
|
||||
: (
|
||||
<Tr>
|
||||
<Th colspan={10} compact>
|
||||
<Th colspan={hasAnyAction ? 11 : 10} compact>
|
||||
<div className="flex justify-between items-center h-8">
|
||||
<div className="flex gap-2 items-center">
|
||||
{sprintf(__("%s documents selected"), selection.length)}
|
||||
@@ -357,7 +358,10 @@ export function DocumentList(props: {
|
||||
<>
|
||||
{canUpdateAny && (
|
||||
<PublishDocumentsDialog documentIds={selection} onSave={clear}>
|
||||
<Button icon={IconCheckmark1} className="py-0.5 px-2 text-xs h-6 min-h-6">
|
||||
<Button
|
||||
icon={IconUpload}
|
||||
className="py-0.5 px-2 text-xs h-6 min-h-6"
|
||||
>
|
||||
{__("Publish")}
|
||||
</Button>
|
||||
</PublishDocumentsDialog>
|
||||
|
||||
@@ -17,20 +17,32 @@ const fragment = graphql`
|
||||
classification
|
||||
updatedAt
|
||||
canDelete: permission(action: "core:document:delete")
|
||||
approvers(first: 100) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
fullName
|
||||
}
|
||||
}
|
||||
}
|
||||
lastVersion: versions(first: 1 orderBy: { field: CREATED_AT direction: DESC }) {
|
||||
recentVersions: versions(first: 2 orderBy: { field: CREATED_AT direction: DESC }) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
status
|
||||
version
|
||||
approvalQuorums(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
|
||||
edges {
|
||||
node {
|
||||
status
|
||||
decisions(first: 20) {
|
||||
totalCount
|
||||
edges {
|
||||
node {
|
||||
approver {
|
||||
fullName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
approvedDecisions: decisions(first: 0 filter: { states: [APPROVED] }) {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
signatures(first: 0 filter: { activeContract: true }) {
|
||||
totalCount
|
||||
}
|
||||
@@ -74,11 +86,22 @@ export function DocumentListItem(props: {
|
||||
fragment,
|
||||
fragmentRef,
|
||||
);
|
||||
const lastVersion = document.lastVersion.edges[0].node;
|
||||
const lastVersion = document.recentVersions.edges[0].node;
|
||||
const approverQuorum = lastVersion.approvalQuorums?.edges?.[0]?.node
|
||||
?? document.recentVersions.edges[1]?.node.approvalQuorums?.edges?.[0]?.node;
|
||||
|
||||
const isDraft = lastVersion.status === "DRAFT";
|
||||
const { __ } = useTranslate();
|
||||
|
||||
const statusVariant = {
|
||||
DRAFT: "neutral",
|
||||
PUBLISHED: "success",
|
||||
} as const;
|
||||
|
||||
const statusLabel = {
|
||||
DRAFT: __("Draft"),
|
||||
PUBLISHED: __("Published"),
|
||||
} as const;
|
||||
|
||||
const [deleteDocument] = useMutationWithToasts<DocumentListItem_deleteMutation>(
|
||||
deleteDocumentMutation,
|
||||
{
|
||||
@@ -119,8 +142,8 @@ export function DocumentListItem(props: {
|
||||
<div className="flex gap-4 items-center">{document.title}</div>
|
||||
</Td>
|
||||
<Td className="w-24">
|
||||
<Badge variant={isDraft ? "neutral" : "success"}>
|
||||
{isDraft ? __("Draft") : __("Published")}
|
||||
<Badge variant={statusVariant[lastVersion.status]}>
|
||||
{statusLabel[lastVersion.status]}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td className="w-20">
|
||||
@@ -134,9 +157,24 @@ export function DocumentListItem(props: {
|
||||
{getDocumentClassificationLabel(__, document.classification)}
|
||||
</Td>
|
||||
<Td className="w-60">
|
||||
{document.approvers.edges.map(({ node }) => node.fullName).join(", ")}
|
||||
{(() => {
|
||||
const decisions = approverQuorum?.decisions;
|
||||
if (!decisions?.edges.length) return "—";
|
||||
const names = decisions.edges.map(e => e.node.approver.fullName).join(", ");
|
||||
return decisions.totalCount > 20 ? `${names}...` : names;
|
||||
})()}
|
||||
</Td>
|
||||
<Td className="w-60">{formatDate(document.updatedAt)}</Td>
|
||||
<Td className="w-20">
|
||||
{(() => {
|
||||
const lastQuorum = lastVersion.approvalQuorums?.edges?.[0]?.node;
|
||||
return lastQuorum
|
||||
? lastQuorum.status === "REJECTED"
|
||||
? __("Rejected")
|
||||
: `${lastQuorum.approvedDecisions.totalCount}/${lastQuorum.decisions.totalCount}`
|
||||
: "—";
|
||||
})()}
|
||||
</Td>
|
||||
<Td className="w-20">
|
||||
{lastVersion.signedSignatures.totalCount}
|
||||
/
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import { formatError } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
IconSend,
|
||||
IconUpload,
|
||||
IconWarning,
|
||||
Textarea,
|
||||
useDialogRef,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { type Ref, useImperativeHandle, useRef } from "react";
|
||||
import { useFragment, useMutation } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { PublishDialog_documentFragment$key } from "#/__generated__/core/PublishDialog_documentFragment.graphql";
|
||||
import type { PublishDialog_publishMutation } from "#/__generated__/core/PublishDialog_publishMutation.graphql";
|
||||
import type { PublishDialog_requestApprovalMutation } from "#/__generated__/core/PublishDialog_requestApprovalMutation.graphql";
|
||||
import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField";
|
||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
export type PublishDialogRef = {
|
||||
open: () => void;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
ref: Ref<PublishDialogRef>;
|
||||
documentId: string;
|
||||
documentFragmentRef: PublishDialog_documentFragment$key;
|
||||
hasPendingApproval: boolean;
|
||||
onSuccess: () => void;
|
||||
};
|
||||
|
||||
const documentFragment = graphql`
|
||||
fragment PublishDialog_documentFragment on Document {
|
||||
lastPublishedVersion: versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }, filter: { statuses: [PUBLISHED] }) {
|
||||
edges {
|
||||
node {
|
||||
approvalQuorums(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
|
||||
edges {
|
||||
node {
|
||||
decisions(first: 100) {
|
||||
edges {
|
||||
node {
|
||||
approver {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const publishMutation = graphql`
|
||||
mutation PublishDialog_publishMutation($input: PublishDocumentVersionInput!) {
|
||||
publishDocumentVersion(input: $input) {
|
||||
document {
|
||||
id
|
||||
status
|
||||
}
|
||||
documentVersion {
|
||||
id
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const requestApprovalMutation = graphql`
|
||||
mutation PublishDialog_requestApprovalMutation(
|
||||
$input: RequestDocumentVersionApprovalInput!
|
||||
) {
|
||||
requestDocumentVersionApproval(input: $input) {
|
||||
approvalQuorum {
|
||||
id
|
||||
status
|
||||
decisions(first: 0) {
|
||||
totalCount
|
||||
}
|
||||
approvedDecisions: decisions(first: 0 filter: { states: [APPROVED] }) {
|
||||
totalCount
|
||||
}
|
||||
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 PublishDialog({
|
||||
ref,
|
||||
documentId,
|
||||
documentFragmentRef,
|
||||
hasPendingApproval,
|
||||
onSuccess,
|
||||
}: Props) {
|
||||
const document = useFragment(documentFragment, documentFragmentRef);
|
||||
const { __ } = useTranslate();
|
||||
const { toast } = useToast();
|
||||
const organizationId = useOrganizationId();
|
||||
const dialogRef = useDialogRef();
|
||||
|
||||
const previousApproverIds = document.lastPublishedVersion.edges[0]
|
||||
?.node.approvalQuorums.edges[0]
|
||||
?.node.decisions?.edges.map(e => e.node.approver.id)
|
||||
?? [];
|
||||
|
||||
const schema = z.object({
|
||||
changelog: z.string().min(1, __("Changelog is required")),
|
||||
approverIds: z.array(z.string()),
|
||||
});
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
register,
|
||||
reset,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useFormWithSchema(schema, {
|
||||
defaultValues: {
|
||||
changelog: "",
|
||||
approverIds: [],
|
||||
},
|
||||
});
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
open: () => {
|
||||
reset({
|
||||
changelog: "",
|
||||
approverIds: previousApproverIds,
|
||||
});
|
||||
dialogRef.current?.open();
|
||||
},
|
||||
}));
|
||||
|
||||
const [publishVersion, isPublishing] = useMutation<PublishDialog_publishMutation>(publishMutation);
|
||||
const [requestApproval, isRequesting] = useMutation<PublishDialog_requestApprovalMutation>(requestApprovalMutation);
|
||||
|
||||
const isBusy = isPublishing || isRequesting;
|
||||
const approverIds = watch("approverIds");
|
||||
const actionRef = useRef<"publish" | "request-approval">("publish");
|
||||
|
||||
const handlePublish = (data: z.infer<typeof schema>) => {
|
||||
publishVersion({
|
||||
variables: { input: { documentId, changelog: data.changelog } },
|
||||
onCompleted(_, errors) {
|
||||
if (errors?.length) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(__("Failed to publish document"), errors),
|
||||
variant: "error",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("Document published successfully."),
|
||||
variant: "success",
|
||||
});
|
||||
dialogRef.current?.close();
|
||||
onSuccess();
|
||||
}
|
||||
},
|
||||
onError(error) {
|
||||
toast({ title: __("Error"), description: error.message, variant: "error" });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onRequestApproval = (data: z.infer<typeof schema>) => {
|
||||
requestApproval({
|
||||
variables: {
|
||||
input: {
|
||||
documentId,
|
||||
approverIds: data.approverIds,
|
||||
changelog: data.changelog,
|
||||
},
|
||||
},
|
||||
onCompleted(_, errors) {
|
||||
if (errors?.length) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(__("Failed to request approval"), errors),
|
||||
variant: "error",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("Approval requested successfully."),
|
||||
variant: "success",
|
||||
});
|
||||
dialogRef.current?.close();
|
||||
reset();
|
||||
onSuccess();
|
||||
}
|
||||
},
|
||||
onError(error) {
|
||||
toast({ title: __("Error"), description: error.message, variant: "error" });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog className="max-w-xl" ref={dialogRef} title={__("Publish document")}>
|
||||
<form
|
||||
onSubmit={e => void handleSubmit((data) => {
|
||||
if (actionRef.current === "publish") {
|
||||
handlePublish(data);
|
||||
} else {
|
||||
onRequestApproval(data);
|
||||
}
|
||||
})(e)}
|
||||
>
|
||||
<DialogContent padded>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="changelog" className="text-sm font-medium text-txt-primary mb-1 block">
|
||||
{__("Changelog")}
|
||||
</label>
|
||||
<Textarea
|
||||
id="changelog"
|
||||
aria-label={__("Changelog")}
|
||||
required
|
||||
autogrow
|
||||
placeholder={__("Describe what changed in this version...")}
|
||||
{...register("changelog")}
|
||||
/>
|
||||
{errors.changelog?.message && (
|
||||
<p className="text-xs text-txt-danger mt-1">{errors.changelog.message}</p>
|
||||
)}
|
||||
</div>
|
||||
{hasPendingApproval
|
||||
? (
|
||||
<div className="flex items-start gap-2 rounded-lg bg-bg-warning/10 border border-border-warning p-3">
|
||||
<IconWarning size={16} className="text-txt-warning shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-txt-warning">
|
||||
{__("An approval review is currently in progress. Publishing now will bypass the pending approval.")}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div>
|
||||
<div className="text-sm font-medium text-txt-primary mb-1">
|
||||
{__("Request approval before publishing")}
|
||||
</div>
|
||||
<p className="text-xs text-txt-secondary mb-3">
|
||||
{__("Select approvers to review this document. The document will be published once all approvers have approved it. You can also publish directly without requiring approval.")}
|
||||
</p>
|
||||
<PeopleMultiSelectField
|
||||
name="approverIds"
|
||||
label={__("Approvers")}
|
||||
control={control}
|
||||
organizationId={organizationId}
|
||||
placeholder={__("Add approvers...")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
{hasPendingApproval
|
||||
? (
|
||||
<Button
|
||||
type="submit"
|
||||
icon={IconUpload}
|
||||
onClick={() => { actionRef.current = "publish"; }}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{__("Publish now")}
|
||||
</Button>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
icon={IconUpload}
|
||||
onClick={() => { actionRef.current = "publish"; }}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{__("Publish now")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
icon={IconSend}
|
||||
onClick={() => { actionRef.current = "request-approval"; }}
|
||||
disabled={isBusy || approverIds.length === 0}
|
||||
>
|
||||
{__("Request approval")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,22 @@
|
||||
import { sprintf } from "@probo/helpers";
|
||||
import { formatError, sprintf } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Breadcrumb,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
Field,
|
||||
IconWarning,
|
||||
Textarea,
|
||||
useDialogRef,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { type ReactNode } from "react";
|
||||
import { useMutation } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { PublishDocumentsDialogMutation } from "#/__generated__/core/PublishDocumentsDialogMutation.graphql";
|
||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
|
||||
type Props = {
|
||||
documentIds: string[];
|
||||
@@ -28,11 +29,12 @@ const documentsPublishMutation = graphql`
|
||||
$input: BulkPublishDocumentVersionsInput!
|
||||
) {
|
||||
bulkPublishDocumentVersions(input: $input) {
|
||||
documentEdges {
|
||||
node {
|
||||
id
|
||||
...DocumentListItemFragment
|
||||
}
|
||||
documentVersions {
|
||||
id
|
||||
}
|
||||
documents {
|
||||
id
|
||||
...DocumentListItemFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,49 +46,56 @@ export function PublishDocumentsDialog({
|
||||
onSave,
|
||||
}: Props) {
|
||||
const { __ } = useTranslate();
|
||||
const { toast } = useToast();
|
||||
const dialogRef = useDialogRef();
|
||||
|
||||
const schema = z.object({
|
||||
changelog: z.string().min(1, __("Changelog is required")),
|
||||
});
|
||||
|
||||
const [publishMutation]
|
||||
= useMutationWithToasts<PublishDocumentsDialogMutation>(
|
||||
documentsPublishMutation,
|
||||
{
|
||||
successMessage: (response) => {
|
||||
const actualPublishedCount
|
||||
= response.bulkPublishDocumentVersions.documentEdges.length;
|
||||
return sprintf(__("%s documents published"), actualPublishedCount);
|
||||
},
|
||||
errorMessage: sprintf(
|
||||
__("Failed to publish %s documents"),
|
||||
documentIds.length,
|
||||
),
|
||||
},
|
||||
);
|
||||
const [publishMutation, isPublishing] = useMutation<PublishDocumentsDialogMutation>(documentsPublishMutation);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
formState: { isSubmitting, errors },
|
||||
formState: { errors },
|
||||
} = useFormWithSchema(schema, {
|
||||
defaultValues: {
|
||||
changelog: "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: z.infer<typeof schema>) => {
|
||||
await publishMutation({
|
||||
const onSubmit = (data: z.infer<typeof schema>) => {
|
||||
publishMutation({
|
||||
variables: {
|
||||
input: {
|
||||
documentIds,
|
||||
changelog: data.changelog,
|
||||
},
|
||||
},
|
||||
onSuccess: () => {
|
||||
dialogRef.current?.close();
|
||||
onSave();
|
||||
onCompleted(_, errors) {
|
||||
if (errors?.length) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(__("Failed to publish documents"), errors),
|
||||
variant: "error",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: sprintf(__("%s documents published"), documentIds.length),
|
||||
variant: "success",
|
||||
});
|
||||
dialogRef.current?.close();
|
||||
onSave();
|
||||
}
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: error.message,
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -96,22 +105,37 @@ export function PublishDocumentsDialog({
|
||||
className="max-w-xl"
|
||||
ref={dialogRef}
|
||||
trigger={children}
|
||||
title={<Breadcrumb items={[__("Documents"), __("Publish documents")]} />}
|
||||
title={__("Publish documents")}
|
||||
>
|
||||
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
|
||||
<DialogContent padded>
|
||||
<Field
|
||||
id="changelog"
|
||||
aria-label={__("Changelog")}
|
||||
required
|
||||
variant="title"
|
||||
placeholder={__("Changelog")}
|
||||
{...register("changelog")}
|
||||
error={errors.changelog?.message}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-2 rounded-lg bg-bg-warning/10 border border-border-warning p-3">
|
||||
<IconWarning size={16} className="text-txt-warning shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-txt-warning">
|
||||
{__("This will publish the selected documents directly without requiring approval.")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="changelog" className="text-sm font-medium text-txt-primary mb-1 block">
|
||||
{__("Changelog")}
|
||||
</label>
|
||||
<Textarea
|
||||
id="changelog"
|
||||
aria-label={__("Changelog")}
|
||||
required
|
||||
autogrow
|
||||
placeholder={__("Describe what changed in this version...")}
|
||||
{...register("changelog")}
|
||||
/>
|
||||
{errors.changelog?.message && (
|
||||
<p className="text-xs text-txt-danger mt-1">{errors.changelog.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
<Button type="submit" disabled={isPublishing}>
|
||||
{sprintf(__("Publish %s documents"), documentIds.length)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Badge, Spinner } from "@probo/ui";
|
||||
import { Suspense } from "react";
|
||||
import { type PreloadedQuery, useFragment, usePreloadedQuery } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { DocumentApprovalsPage_versionFragment$key } from "#/__generated__/core/DocumentApprovalsPage_versionFragment.graphql";
|
||||
import type { DocumentApprovalsPageQuery } from "#/__generated__/core/DocumentApprovalsPageQuery.graphql";
|
||||
|
||||
import { DocumentApprovalList } from "./_components/DocumentApprovalList";
|
||||
|
||||
export const documentApprovalsPageQuery = graphql`
|
||||
query DocumentApprovalsPageQuery($documentId: ID! $versionId: ID! $versionSpecified: Boolean!) {
|
||||
# We use this on /documents/:documentId
|
||||
document: node(id: $documentId) @skip(if: $versionSpecified) {
|
||||
__typename
|
||||
... on Document {
|
||||
lastVersion: versions(
|
||||
first: 1
|
||||
orderBy: { field: CREATED_AT, direction: DESC }
|
||||
) {
|
||||
edges {
|
||||
node {
|
||||
...DocumentApprovalList_versionFragment
|
||||
...DocumentApprovalsPage_versionFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
# We use this on /documents/:documentId/versions/:versionId
|
||||
version: node(id: $versionId) @include(if: $versionSpecified) {
|
||||
__typename
|
||||
...DocumentApprovalList_versionFragment
|
||||
...DocumentApprovalsPage_versionFragment
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const versionFragment = graphql`
|
||||
fragment DocumentApprovalsPage_versionFragment on DocumentVersion {
|
||||
approvalQuorums(first: 100, orderBy: { field: CREATED_AT, direction: DESC }) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
status
|
||||
createdAt
|
||||
decisions(first: 100, orderBy: { field: CREATED_AT, direction: ASC }) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
approver {
|
||||
fullName
|
||||
}
|
||||
state
|
||||
comment
|
||||
decidedAt
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function DocumentApprovalsPage(props: {
|
||||
queryRef: PreloadedQuery<DocumentApprovalsPageQuery>;
|
||||
onRefetch: () => void;
|
||||
}) {
|
||||
const { queryRef, onRefetch } = props;
|
||||
|
||||
const { document, version } = usePreloadedQuery<DocumentApprovalsPageQuery>(
|
||||
documentApprovalsPageQuery,
|
||||
queryRef,
|
||||
);
|
||||
|
||||
if ((version && version.__typename !== "DocumentVersion") || (document && document.__typename !== "Document")) {
|
||||
throw new Error("invalid type for node");
|
||||
}
|
||||
if (!document && !version) {
|
||||
throw new Error("no document or version specified");
|
||||
}
|
||||
|
||||
const lastVersionNode = document?.lastVersion.edges[0]?.node;
|
||||
const approvalListRef = version ?? lastVersionNode;
|
||||
const versionFragmentRef = (version ?? lastVersionNode) as DocumentApprovalsPage_versionFragment$key | null;
|
||||
if (!approvalListRef || !versionFragmentRef) {
|
||||
throw new Error("no version found");
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<Spinner centered />}>
|
||||
<DocumentApprovalsPageContent
|
||||
approvalListRef={approvalListRef}
|
||||
versionFragmentRef={versionFragmentRef}
|
||||
onRefetch={onRefetch}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function DocumentApprovalsPageContent(props: {
|
||||
approvalListRef: Parameters<typeof DocumentApprovalList>[0]["versionFragmentRef"];
|
||||
versionFragmentRef: DocumentApprovalsPage_versionFragment$key;
|
||||
onRefetch: () => void;
|
||||
}) {
|
||||
const { approvalListRef, versionFragmentRef, onRefetch } = props;
|
||||
const { __, dateTimeFormat } = useTranslate();
|
||||
|
||||
const versionData = useFragment(versionFragment, versionFragmentRef);
|
||||
const quorumEdges = versionData.approvalQuorums?.edges ?? [];
|
||||
const pastQuorums = quorumEdges.slice(1);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<DocumentApprovalList versionFragmentRef={approvalListRef} onRefetch={onRefetch} />
|
||||
|
||||
{pastQuorums.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-txt-secondary">{__("Previous approval requests")}</h3>
|
||||
{pastQuorums.map(({ node: quorum }) => (
|
||||
<div key={quorum.id} className="border border-border-solid rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Badge variant={quorum.status === "APPROVED" ? "success" : "danger"}>
|
||||
{quorum.status === "APPROVED" ? __("Approved") : __("Rejected")}
|
||||
</Badge>
|
||||
<span className="text-xs text-txt-secondary">
|
||||
{dateTimeFormat(quorum.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border-solid">
|
||||
{quorum.decisions.edges.map(({ node: decision }) => (
|
||||
<div key={decision.id} className="flex items-center gap-3 py-2">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-sm text-txt-primary font-medium">
|
||||
{decision.approver.fullName}
|
||||
</div>
|
||||
<div className="text-xs text-txt-secondary">
|
||||
{decision.decidedAt && (decision.state === "APPROVED" || decision.state === "REJECTED") && dateTimeFormat(decision.decidedAt)}
|
||||
</div>
|
||||
{decision.comment && (
|
||||
<div className="text-xs text-txt-secondary italic">{decision.comment}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<Badge variant={decision.state === "APPROVED" ? "success" : decision.state === "REJECTED" ? "danger" : "warning"}>
|
||||
{decision.state === "APPROVED" ? __("Approved") : decision.state === "REJECTED" ? __("Rejected") : __("Pending")}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Suspense, useCallback, useEffect } from "react";
|
||||
import { useQueryLoader } from "react-relay";
|
||||
import { useOutletContext, useParams } from "react-router";
|
||||
|
||||
import type { DocumentApprovalsPageQuery } from "#/__generated__/core/DocumentApprovalsPageQuery.graphql";
|
||||
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
|
||||
|
||||
import { DocumentApprovalsPage, documentApprovalsPageQuery } from "./DocumentApprovalsPage";
|
||||
|
||||
function DocumentApprovalsPageQueryLoader() {
|
||||
const { documentId, versionId } = useParams();
|
||||
if (!documentId) {
|
||||
throw new Error(":documentId missing in route params");
|
||||
}
|
||||
|
||||
const { onRefetch: parentRefetch, approvalRequestedAt }
|
||||
= useOutletContext<{
|
||||
onRefetch: () => void;
|
||||
approvalRequestedAt?: number;
|
||||
}>();
|
||||
|
||||
const [queryRef, loadQuery] = useQueryLoader<DocumentApprovalsPageQuery>(documentApprovalsPageQuery);
|
||||
|
||||
const loadQueryParams = useCallback(() => {
|
||||
loadQuery(
|
||||
{
|
||||
documentId: documentId,
|
||||
versionId: versionId ?? "",
|
||||
versionSpecified: !!versionId,
|
||||
},
|
||||
{ fetchPolicy: "network-only" },
|
||||
);
|
||||
}, [documentId, versionId, loadQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!queryRef) {
|
||||
loadQuery(
|
||||
{
|
||||
documentId: documentId,
|
||||
versionId: versionId ?? "",
|
||||
versionSpecified: !!versionId,
|
||||
},
|
||||
{ fetchPolicy: "network-only" },
|
||||
);
|
||||
}
|
||||
}, [queryRef, documentId, versionId, loadQuery]);
|
||||
|
||||
// Reload approvals data whenever a new approval round is requested from the layout
|
||||
useEffect(() => {
|
||||
if (approvalRequestedAt) {
|
||||
loadQueryParams();
|
||||
}
|
||||
}, [approvalRequestedAt, loadQueryParams]);
|
||||
|
||||
const onRefetch = useCallback(() => {
|
||||
parentRefetch();
|
||||
loadQueryParams();
|
||||
}, [parentRefetch, loadQueryParams]);
|
||||
|
||||
if (!queryRef) {
|
||||
return <LinkCardSkeleton />;
|
||||
}
|
||||
|
||||
return <DocumentApprovalsPage queryRef={queryRef} onRefetch={onRefetch} />;
|
||||
}
|
||||
|
||||
export default function DocumentApprovalsPageLoader() {
|
||||
return (
|
||||
<Suspense fallback={<LinkCardSkeleton />}>
|
||||
<DocumentApprovalsPageQueryLoader />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
IconPlusSmall,
|
||||
useDialogRef,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { Suspense, useState } from "react";
|
||||
import { useFragment, useMutation } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { DocumentApprovalList_addApproverMutation } from "#/__generated__/core/DocumentApprovalList_addApproverMutation.graphql";
|
||||
import type { DocumentApprovalList_versionFragment$key } from "#/__generated__/core/DocumentApprovalList_versionFragment.graphql";
|
||||
import { usePeople } from "#/hooks/graph/PeopleGraph";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
import { DocumentApprovalListItem } from "./DocumentApprovalListItem";
|
||||
|
||||
const versionFragment = graphql`
|
||||
fragment DocumentApprovalList_versionFragment on DocumentVersion {
|
||||
id
|
||||
canAddApprover: permission(action: "core:document-version:add-approver")
|
||||
approvalQuorums(first: 100, orderBy: { field: CREATED_AT, direction: DESC }) {
|
||||
edges {
|
||||
node {
|
||||
decisions(first: 100, orderBy: { field: CREATED_AT, direction: ASC })
|
||||
@connection(key: "DocumentApprovalList_decisions") {
|
||||
__id
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
approver {
|
||||
id
|
||||
}
|
||||
...DocumentApprovalListItemFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const addApproverMutation = graphql`
|
||||
mutation DocumentApprovalList_addApproverMutation(
|
||||
$input: AddDocumentVersionApproverInput!
|
||||
$connections: [ID!]!
|
||||
) {
|
||||
addDocumentVersionApprover(input: $input) {
|
||||
approvalDecisionEdge @appendEdge(connections: $connections) {
|
||||
node {
|
||||
id
|
||||
approver {
|
||||
id
|
||||
}
|
||||
...DocumentApprovalListItemFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function DocumentApprovalList(props: {
|
||||
versionFragmentRef: DocumentApprovalList_versionFragment$key;
|
||||
onRefetch: () => void;
|
||||
}) {
|
||||
const { versionFragmentRef, onRefetch } = props;
|
||||
const { __ } = useTranslate();
|
||||
|
||||
const version = useFragment(versionFragment, versionFragmentRef);
|
||||
const dialogRef = useDialogRef();
|
||||
const canManage = version.canAddApprover;
|
||||
|
||||
const lastQuorum = version.approvalQuorums?.edges?.[0]?.node ?? null;
|
||||
const decisions = lastQuorum?.decisions;
|
||||
const edges = decisions?.edges ?? [];
|
||||
const existingApproverIds = edges.map(({ node }) => node.approver.id);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{canManage && (
|
||||
<div className="flex justify-end pb-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={IconPlusSmall}
|
||||
onClick={() => dialogRef.current?.open()}
|
||||
>
|
||||
{__("Add approver")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{edges.length === 0
|
||||
? (
|
||||
<div className="text-sm text-txt-secondary text-center py-8">
|
||||
{__("No approval decisions yet.")}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="divide-y divide-border-solid">
|
||||
{edges.map(({ node }) => (
|
||||
<DocumentApprovalListItem
|
||||
key={node.id}
|
||||
fragmentRef={node}
|
||||
canManage={canManage}
|
||||
connectionId={decisions?.__id ?? ""}
|
||||
onRefetch={onRefetch}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Dialog ref={dialogRef} title={__("Add approver")}>
|
||||
<Suspense fallback={<DialogContent padded>{__("Loading...")}</DialogContent>}>
|
||||
<AddApproverDialogContent
|
||||
documentVersionId={version.id}
|
||||
existingApproverIds={existingApproverIds}
|
||||
connectionId={decisions?.__id ?? ""}
|
||||
onSuccess={() => {
|
||||
dialogRef.current?.close();
|
||||
onRefetch();
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddApproverDialogContent(props: {
|
||||
documentVersionId: string;
|
||||
existingApproverIds: string[];
|
||||
connectionId: string;
|
||||
onSuccess: () => void;
|
||||
}) {
|
||||
const { documentVersionId, existingApproverIds, connectionId, onSuccess } = props;
|
||||
const { __ } = useTranslate();
|
||||
const { toast } = useToast();
|
||||
const organizationId = useOrganizationId();
|
||||
const allPeople = usePeople(organizationId, { excludeContractEnded: true });
|
||||
const people = allPeople.filter(p => !existingApproverIds.includes(p.id));
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
|
||||
const [addApprover, isAdding] = useMutation<DocumentApprovalList_addApproverMutation>(
|
||||
addApproverMutation,
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!selectedId) return;
|
||||
void addApprover({
|
||||
variables: {
|
||||
input: {
|
||||
documentVersionId,
|
||||
approverId: selectedId,
|
||||
},
|
||||
connections: [connectionId],
|
||||
},
|
||||
onCompleted: (_data, errors) => {
|
||||
if (errors?.length) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: errors[0].message,
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: __("Approver added"),
|
||||
description: __("The approver has been added successfully."),
|
||||
variant: "success",
|
||||
});
|
||||
onSuccess();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: error.message,
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DialogContent padded>
|
||||
<label htmlFor="add-approver-select" className="block text-sm font-medium mb-1">{__("Approver")}</label>
|
||||
<select
|
||||
id="add-approver-select"
|
||||
className="w-full rounded-md border border-border-solid bg-bg-primary px-3 py-2 text-sm"
|
||||
value={selectedId}
|
||||
onChange={e => setSelectedId(e.target.value)}
|
||||
>
|
||||
<option value="">{__("Select a person...")}</option>
|
||||
{people.map(p => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.fullName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={!selectedId || isAdding}>
|
||||
{__("Add approver")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { sprintf } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
IconCircleCheck,
|
||||
IconCircleX,
|
||||
IconClock,
|
||||
IconTrashCan,
|
||||
Spinner,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { useFragment, useMutation } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { DocumentApprovalListItem_removeApproverMutation } from "#/__generated__/core/DocumentApprovalListItem_removeApproverMutation.graphql";
|
||||
import type { DocumentApprovalListItemFragment$key } from "#/__generated__/core/DocumentApprovalListItemFragment.graphql";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
const fragment = graphql`
|
||||
fragment DocumentApprovalListItemFragment on DocumentVersionApprovalDecision {
|
||||
id
|
||||
approver {
|
||||
fullName
|
||||
}
|
||||
state
|
||||
comment
|
||||
decidedAt
|
||||
createdAt
|
||||
canApprove: permission(action: "core:document-version:approve")
|
||||
canReject: permission(action: "core:document-version:reject")
|
||||
documentVersion {
|
||||
id
|
||||
document {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const removeApproverMutation = graphql`
|
||||
mutation DocumentApprovalListItem_removeApproverMutation(
|
||||
$input: RemoveDocumentVersionApproverInput!
|
||||
$connections: [ID!]!
|
||||
) {
|
||||
removeDocumentVersionApprover(input: $input) {
|
||||
deletedApprovalDecisionId @deleteEdge(connections: $connections)
|
||||
documentVersion {
|
||||
id
|
||||
approvalQuorums(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
status
|
||||
decisions(first: 0) {
|
||||
totalCount
|
||||
}
|
||||
approvedDecisions: decisions(first: 0 filter: { states: [APPROVED] }) {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function DocumentApprovalListItem(props: {
|
||||
fragmentRef: DocumentApprovalListItemFragment$key;
|
||||
canManage: boolean;
|
||||
connectionId: string;
|
||||
onRefetch: () => void;
|
||||
}) {
|
||||
const { fragmentRef, canManage, connectionId, onRefetch } = props;
|
||||
const { __, dateTimeFormat } = useTranslate();
|
||||
const { toast } = useToast();
|
||||
const organizationId = useOrganizationId();
|
||||
|
||||
const decision = useFragment(fragment, fragmentRef);
|
||||
|
||||
const isPending = decision.state === "PENDING";
|
||||
const isApproved = decision.state === "APPROVED";
|
||||
const isRejected = decision.state === "REJECTED";
|
||||
|
||||
const [removeApprover, isRemoving] = useMutation<DocumentApprovalListItem_removeApproverMutation>(
|
||||
removeApproverMutation,
|
||||
);
|
||||
|
||||
const reviewUrl = `/organizations/${organizationId}/employee/approvals/${decision.documentVersion.document.id}`;
|
||||
|
||||
return (
|
||||
<div className="flex gap-3 items-center py-3">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-txt-primary font-medium">
|
||||
{decision.approver.fullName}
|
||||
</div>
|
||||
<div className="text-xs text-txt-secondary flex items-center gap-1">
|
||||
{isApproved && <IconCircleCheck size={16} className="text-txt-accent" />}
|
||||
{isRejected && <IconCircleX size={16} className="text-txt-danger" />}
|
||||
{isPending && <IconClock size={16} />}
|
||||
<span>
|
||||
{isPending && sprintf(__("Requested on %s"), dateTimeFormat(decision.createdAt))}
|
||||
{isApproved && sprintf(__("Approved on %s"), dateTimeFormat(decision.decidedAt))}
|
||||
{isRejected && sprintf(__("Rejected on %s"), dateTimeFormat(decision.decidedAt))}
|
||||
</span>
|
||||
</div>
|
||||
{decision.comment && (
|
||||
<div className="text-xs text-txt-secondary italic">
|
||||
{decision.comment}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{isApproved && (
|
||||
<Badge variant="success">{__("Approved")}</Badge>
|
||||
)}
|
||||
{isRejected && (
|
||||
<Badge variant="danger">{__("Rejected")}</Badge>
|
||||
)}
|
||||
{isPending && (decision.canApprove || decision.canReject) && (
|
||||
<Button variant="secondary" to={reviewUrl} target="_blank">
|
||||
{__("Review")}
|
||||
</Button>
|
||||
)}
|
||||
{canManage && (
|
||||
<Button
|
||||
variant="quaternary"
|
||||
icon={isRemoving ? Spinner : IconTrashCan}
|
||||
disabled={isRemoving}
|
||||
onClick={() => {
|
||||
void removeApprover({
|
||||
variables: {
|
||||
input: {
|
||||
approvalDecisionId: decision.id,
|
||||
},
|
||||
connections: [connectionId],
|
||||
},
|
||||
onCompleted: (_data, errors) => {
|
||||
if (errors?.length) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: errors[0].message,
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: __("Approver removed"),
|
||||
description: __("The approver has been removed successfully."),
|
||||
variant: "success",
|
||||
});
|
||||
onRefetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: error.message,
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isPending && !decision.canApprove && !decision.canReject && !canManage && (
|
||||
<Badge variant="warning">{__("Pending")}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
import { formatError, type GraphQLError } from "@probo/helpers";
|
||||
import { usePageTitle } from "@probo/hooks";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
IconCircleCheck,
|
||||
IconCircleX,
|
||||
IconRadioUnchecked,
|
||||
Spinner,
|
||||
Textarea,
|
||||
useDialogRef,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { clsx } from "clsx";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
type PreloadedQuery,
|
||||
useFragment,
|
||||
useMutation,
|
||||
usePreloadedQuery,
|
||||
} from "react-relay";
|
||||
import { useNavigate } from "react-router";
|
||||
import { graphql } from "relay-runtime";
|
||||
import { useWindowSize } from "usehooks-ts";
|
||||
|
||||
import type { DocumentApprovePage_approveMutation } from "#/__generated__/core/DocumentApprovePage_approveMutation.graphql";
|
||||
import type { DocumentApprovePage_rejectMutation } from "#/__generated__/core/DocumentApprovePage_rejectMutation.graphql";
|
||||
import type { DocumentApprovePageDecisionFragment$key } from "#/__generated__/core/DocumentApprovePageDecisionFragment.graphql";
|
||||
import type { DocumentApprovePageDocumentFragment$key } from "#/__generated__/core/DocumentApprovePageDocumentFragment.graphql";
|
||||
import type { DocumentApprovePageExportPDFMutation } from "#/__generated__/core/DocumentApprovePageExportPDFMutation.graphql";
|
||||
import type { DocumentApprovePageQuery } from "#/__generated__/core/DocumentApprovePageQuery.graphql";
|
||||
import type { DocumentApprovePageVersionRowFragment$key } from "#/__generated__/core/DocumentApprovePageVersionRowFragment.graphql";
|
||||
import { PDFPreview } from "#/components/documents/PDFPreview";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
export const documentApprovePageQuery = graphql`
|
||||
query DocumentApprovePageQuery($documentId: ID!) {
|
||||
viewer @required(action: THROW) {
|
||||
approvableDocument(id: $documentId) {
|
||||
...DocumentApprovePageDocumentFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const documentFragment = graphql`
|
||||
fragment DocumentApprovePageDocumentFragment on EmployeeDocument {
|
||||
id
|
||||
title
|
||||
versions(first: 100, orderBy: { field: CREATED_AT, direction: DESC })
|
||||
@required(action: THROW) {
|
||||
edges @required(action: THROW) {
|
||||
node @required(action: THROW) {
|
||||
id
|
||||
...DocumentApprovePageVersionRowFragment
|
||||
approvalDecision {
|
||||
...DocumentApprovePageDecisionFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const versionRowFragment = graphql`
|
||||
fragment DocumentApprovePageVersionRowFragment on EmployeeDocumentVersion {
|
||||
id
|
||||
version
|
||||
publishedAt
|
||||
approvalDecision {
|
||||
state
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const decisionFragment = graphql`
|
||||
fragment DocumentApprovePageDecisionFragment on DocumentVersionApprovalDecision {
|
||||
id
|
||||
state
|
||||
canApprove: permission(action: "core:document-version:approve")
|
||||
canReject: permission(action: "core:document-version:reject")
|
||||
documentVersion {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const approveDocumentVersionMutation = graphql`
|
||||
mutation DocumentApprovePage_approveMutation(
|
||||
$input: ApproveDocumentVersionInput!
|
||||
) {
|
||||
approveDocumentVersion(input: $input) {
|
||||
approvalDecision {
|
||||
id
|
||||
state
|
||||
decidedAt
|
||||
comment
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const rejectDocumentVersionMutation = graphql`
|
||||
mutation DocumentApprovePage_rejectMutation(
|
||||
$input: RejectDocumentVersionInput!
|
||||
) {
|
||||
rejectDocumentVersion(input: $input) {
|
||||
approvalDecision {
|
||||
id
|
||||
state
|
||||
decidedAt
|
||||
comment
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const exportPDFMutation = graphql`
|
||||
mutation DocumentApprovePageExportPDFMutation(
|
||||
$input: ExportDocumentVersionPDFInput!
|
||||
) {
|
||||
exportDocumentVersionPDF(input: $input) {
|
||||
data
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function DocumentApprovePage(props: {
|
||||
queryRef: PreloadedQuery<DocumentApprovePageQuery>;
|
||||
}) {
|
||||
const { queryRef } = props;
|
||||
const data = usePreloadedQuery<DocumentApprovePageQuery>(
|
||||
documentApprovePageQuery,
|
||||
queryRef,
|
||||
);
|
||||
|
||||
const document = data.viewer.approvableDocument;
|
||||
if (!document) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <DocumentApproveContent fKey={document} />;
|
||||
}
|
||||
|
||||
function VersionRow({
|
||||
fKey,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
fKey: DocumentApprovePageVersionRowFragment$key;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const { __ } = useTranslate();
|
||||
const versionData = useFragment(versionRowFragment, fKey);
|
||||
const approvalDecision = versionData.approvalDecision;
|
||||
const state = approvalDecision?.state;
|
||||
const isApproved = state === "APPROVED";
|
||||
const isRejected = state === "REJECTED";
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onSelect}
|
||||
className={clsx(
|
||||
"flex items-center gap-3 py-3 px-4 transition-colors cursor-pointer",
|
||||
isSelected
|
||||
? "bg-blue-50 border-l-4 border-blue-500"
|
||||
: "bg-transparent hover:bg-level-1",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-level-2 flex-shrink-0">
|
||||
{isApproved
|
||||
? <IconCircleCheck size={20} className="text-txt-success" />
|
||||
: isRejected
|
||||
? <IconCircleX size={20} className="text-txt-danger" />
|
||||
: <IconRadioUnchecked size={20} className="text-txt-tertiary" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className={clsx(
|
||||
"text-sm font-medium truncate",
|
||||
(isApproved || isRejected) ? "text-txt-tertiary" : "text-txt-primary",
|
||||
)}
|
||||
>
|
||||
{versionData.publishedAt
|
||||
? `v${versionData.version} - ${(() => {
|
||||
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.version}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
{isApproved
|
||||
? <Badge variant="success">{__("Approved")}</Badge>
|
||||
: isRejected
|
||||
? <Badge variant="danger">{__("Rejected")}</Badge>
|
||||
: isSelected
|
||||
? <Badge variant="info">{__("In review")}</Badge>
|
||||
: <Badge variant="warning">{__("Pending")}</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewerDecision(props: {
|
||||
fragmentRef: DocumentApprovePageDecisionFragment$key;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const { fragmentRef, onBack } = props;
|
||||
const { __ } = useTranslate();
|
||||
const decision = useFragment(decisionFragment, fragmentRef);
|
||||
const rejectDialogRef = useDialogRef();
|
||||
const [rejectComment, setRejectComment] = useState("");
|
||||
const { toast } = useToast();
|
||||
|
||||
const [approveVersion, isApproving] = useMutation<DocumentApprovePage_approveMutation>(
|
||||
approveDocumentVersionMutation,
|
||||
);
|
||||
|
||||
const [rejectVersion, isRejecting] = useMutation<DocumentApprovePage_rejectMutation>(
|
||||
rejectDocumentVersionMutation,
|
||||
);
|
||||
|
||||
const isPending = decision.state === "PENDING";
|
||||
const isApproved = decision.state === "APPROVED";
|
||||
const isRejected = decision.state === "REJECTED";
|
||||
|
||||
if (!decision.canApprove && !decision.canReject) {
|
||||
return (
|
||||
<Button onClick={onBack} className="h-10 w-full" variant="secondary">
|
||||
{__("Back to Documents")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (isApproved) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-sm text-txt-accent mb-4">
|
||||
<IconCircleCheck size={20} />
|
||||
<span>{__("You have approved this document.")}</span>
|
||||
</div>
|
||||
<Button onClick={onBack} className="h-10 w-full" variant="secondary">
|
||||
{__("Back to Documents")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (isRejected) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-sm text-txt-danger mb-4">
|
||||
<IconCircleX size={20} />
|
||||
<span>{__("You have rejected this document.")}</span>
|
||||
</div>
|
||||
<Button onClick={onBack} className="h-10 w-full" variant="secondary">
|
||||
{__("Back to Documents")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isPending) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-3">
|
||||
{decision.canReject && (
|
||||
<Button
|
||||
variant="danger"
|
||||
className="flex-1"
|
||||
disabled={isApproving || isRejecting}
|
||||
onClick={() => rejectDialogRef.current?.open()}
|
||||
>
|
||||
{__("Reject")}
|
||||
</Button>
|
||||
)}
|
||||
{decision.canApprove && (
|
||||
<Button
|
||||
className="flex-1"
|
||||
disabled={isApproving || isRejecting}
|
||||
icon={isApproving ? Spinner : undefined}
|
||||
onClick={() => {
|
||||
approveVersion({
|
||||
variables: {
|
||||
input: {
|
||||
documentVersionId: decision.documentVersion.id,
|
||||
},
|
||||
},
|
||||
onCompleted(_, errors) {
|
||||
if (errors?.length) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(__("Failed to approve document"), errors),
|
||||
variant: "error",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("Document approved successfully"),
|
||||
variant: "success",
|
||||
});
|
||||
}
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: error.message,
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
{__("Approve")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-txt-tertiary">
|
||||
{__("By clicking Approve, I consent to approve this document electronically and agree that my electronic signature has the same legal validity as a handwritten signature.")}
|
||||
</p>
|
||||
<Button onClick={onBack} className="w-full" variant="secondary">
|
||||
{__("Back to Documents")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Dialog ref={rejectDialogRef} title={__("Reject Document")}>
|
||||
<DialogContent padded>
|
||||
<p className="text-sm text-txt-secondary mb-4">
|
||||
{__("Please provide a reason for rejecting this document. The document will be sent back to draft status.")}
|
||||
</p>
|
||||
<Textarea
|
||||
placeholder={__("Reason for rejection...")}
|
||||
value={rejectComment}
|
||||
onChange={e => setRejectComment(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="danger"
|
||||
disabled={isRejecting}
|
||||
icon={isRejecting ? Spinner : undefined}
|
||||
onClick={() => {
|
||||
rejectVersion({
|
||||
variables: {
|
||||
input: {
|
||||
documentVersionId: decision.documentVersion.id,
|
||||
comment: rejectComment || undefined,
|
||||
},
|
||||
},
|
||||
onCompleted(_, errors) {
|
||||
if (errors?.length) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(__("Failed to reject document"), errors),
|
||||
variant: "error",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("Document rejected"),
|
||||
variant: "success",
|
||||
});
|
||||
rejectDialogRef.current?.close();
|
||||
}
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: error.message,
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
{__("Reject Document")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DocumentApproveContent({
|
||||
fKey,
|
||||
}: {
|
||||
fKey: DocumentApprovePageDocumentFragment$key;
|
||||
}) {
|
||||
const { __ } = useTranslate();
|
||||
const navigate = useNavigate();
|
||||
const { width } = useWindowSize();
|
||||
const isMobile = width < 1100;
|
||||
const isDesktop = !isMobile;
|
||||
const organizationId = useOrganizationId();
|
||||
const { toast } = useToast();
|
||||
|
||||
const documentData = useFragment(documentFragment, fKey);
|
||||
const versions = documentData.versions.edges.map(({ node }) => node);
|
||||
|
||||
const [selectedVersionId, setSelectedVersionId] = useState<
|
||||
string | undefined
|
||||
>(() => versions[0]?.id);
|
||||
|
||||
const selectedVersion = versions.find(v => v?.id === selectedVersionId);
|
||||
|
||||
usePageTitle(__("Review and Approve Document"));
|
||||
|
||||
const [exportPDF] = useMutation<DocumentApprovePageExportPDFMutation>(
|
||||
exportPDFMutation,
|
||||
);
|
||||
|
||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||
const pdfUrlRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedVersion?.id) return;
|
||||
|
||||
exportPDF({
|
||||
variables: {
|
||||
input: {
|
||||
documentVersionId: selectedVersion.id,
|
||||
withWatermark: true,
|
||||
withSignatures: false,
|
||||
},
|
||||
},
|
||||
onCompleted: (data, errors): void => {
|
||||
if (errors) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to load PDF"),
|
||||
errors as GraphQLError[],
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (data.exportDocumentVersionPDF?.data) {
|
||||
const dataUrl = data.exportDocumentVersionPDF.data;
|
||||
pdfUrlRef.current = dataUrl;
|
||||
setPdfUrl(dataUrl);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to load PDF"),
|
||||
error as GraphQLError,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
pdfUrlRef.current = null;
|
||||
};
|
||||
}, [selectedVersion?.id, exportPDF, toast, __]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed bg-level-2 flex flex-col"
|
||||
style={{ top: "3rem", left: 0, right: 0, bottom: 0 }}
|
||||
>
|
||||
<div className="grid lg:grid-cols-2 min-h-0 h-full">
|
||||
<div className="w-full lg:w-[440px] mx-auto py-20 overflow-y-auto scrollbar-hide">
|
||||
<h1 className="text-2xl font-semibold mb-6">
|
||||
{documentData.title || ""}
|
||||
</h1>
|
||||
|
||||
<Card className="mb-6 overflow-hidden">
|
||||
<div className="divide-y divide-border-solid">
|
||||
{versions.map(version => (
|
||||
<VersionRow
|
||||
key={version.id}
|
||||
fKey={version}
|
||||
isSelected={version.id === selectedVersionId}
|
||||
onSelect={() => setSelectedVersionId(version.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<p className="text-txt-secondary text-sm mb-6">
|
||||
{__("Please review the document carefully before making your decision.")}
|
||||
</p>
|
||||
|
||||
<div className="min-h-[60px]">
|
||||
{(() => {
|
||||
const decision = selectedVersion?.approvalDecision;
|
||||
return decision
|
||||
? (
|
||||
<ViewerDecision
|
||||
fragmentRef={decision}
|
||||
onBack={() =>
|
||||
void navigate(`/organizations/${organizationId}/employee/approvals`)}
|
||||
/>
|
||||
)
|
||||
: null;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isDesktop && (
|
||||
<div className="bg-subtle h-full border-l border-border-solid min-h-0">
|
||||
{pdfUrl && (
|
||||
<PDFPreview src={pdfUrl} name={documentData.title || ""} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Spinner } from "@probo/ui";
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useQueryLoader } from "react-relay";
|
||||
import { useParams } from "react-router";
|
||||
|
||||
import type { DocumentApprovePageQuery } from "#/__generated__/core/DocumentApprovePageQuery.graphql";
|
||||
|
||||
import {
|
||||
DocumentApprovePage,
|
||||
documentApprovePageQuery,
|
||||
} from "./DocumentApprovePage";
|
||||
|
||||
function DocumentApprovePageQueryLoader() {
|
||||
const { documentId } = useParams();
|
||||
if (!documentId) {
|
||||
throw new Error(":documentId missing in route params");
|
||||
}
|
||||
|
||||
const [queryRef, loadQuery]
|
||||
= useQueryLoader<DocumentApprovePageQuery>(documentApprovePageQuery);
|
||||
|
||||
useEffect(() => {
|
||||
loadQuery({ documentId });
|
||||
}, [loadQuery, documentId]);
|
||||
|
||||
if (!queryRef) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<DocumentApprovePage queryRef={queryRef} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DocumentApprovePageLoader() {
|
||||
return (
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<DocumentApprovePageQueryLoader />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { sprintf } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Avatar, Badge, Button, IconCircleCheck, IconClock } from "@probo/ui";
|
||||
import { Badge, Button, IconCircleCheck, IconClock } from "@probo/ui";
|
||||
import { useFragment } from "react-relay";
|
||||
import { type DataID, graphql } from "relay-runtime";
|
||||
|
||||
@@ -53,7 +53,6 @@ export function DocumentSignatureListItem(props: {
|
||||
|
||||
return (
|
||||
<div className="flex gap-3 items-center py-3">
|
||||
<Avatar size="l" name={signature.signedBy.fullName} />
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-txt-primary font-medium">
|
||||
{signature.signedBy.fullName}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Avatar, Button } from "@probo/ui";
|
||||
import { Button } from "@probo/ui";
|
||||
import { useFragment } from "react-relay";
|
||||
import { type DataID, graphql } from "relay-runtime";
|
||||
|
||||
@@ -77,7 +77,6 @@ export function DocumentSignaturePlaceholder(props: {
|
||||
|
||||
return (
|
||||
<div className="flex gap-3 items-center py-3">
|
||||
<Avatar size="l" name={person.fullName} />
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-txt-primary font-medium">
|
||||
{person.fullName}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { usePageTitle } from "@probo/hooks";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Card, Tbody, Th, Thead, Tr } from "@probo/ui";
|
||||
import { graphql, type PreloadedQuery, usePreloadedQuery } from "react-relay";
|
||||
|
||||
import type { EmployeeApprovalsPageQuery } from "#/__generated__/core/EmployeeApprovalsPageQuery.graphql";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
import { ApprovableDocumentRow } from "./_components/ApprovableDocumentRow";
|
||||
|
||||
export const employeeApprovalsPageQuery = graphql`
|
||||
query EmployeeApprovalsPageQuery($organizationId: ID!) {
|
||||
viewer @required(action: THROW) {
|
||||
approvableDocuments(
|
||||
organizationId: $organizationId
|
||||
first: 1000
|
||||
orderBy: { field: CREATED_AT, direction: DESC }
|
||||
) @required(action: THROW) {
|
||||
edges @required(action: THROW) {
|
||||
node @required(action: THROW) {
|
||||
id
|
||||
...ApprovableDocumentRowFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function EmployeeApprovalsPage(props: {
|
||||
queryRef: PreloadedQuery<EmployeeApprovalsPageQuery>;
|
||||
}) {
|
||||
const { queryRef } = props;
|
||||
const { __ } = useTranslate();
|
||||
const organizationId = useOrganizationId();
|
||||
|
||||
const {
|
||||
viewer: { approvableDocuments },
|
||||
} = usePreloadedQuery<EmployeeApprovalsPageQuery>(
|
||||
employeeApprovalsPageQuery,
|
||||
queryRef,
|
||||
);
|
||||
|
||||
const documents = approvableDocuments.edges.map(edge => edge.node);
|
||||
|
||||
usePageTitle(__("Documents"));
|
||||
|
||||
return (
|
||||
<>
|
||||
{documents.length > 0
|
||||
? (
|
||||
<Card>
|
||||
<table className="w-full table-fixed">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th className="text-left">{__("Name")}</Th>
|
||||
<Th className="w-48 text-left">{__("Type")}</Th>
|
||||
<Th className="w-36 text-left">{__("Classification")}</Th>
|
||||
<Th className="w-40 text-left">{__("Last update")}</Th>
|
||||
<Th className="w-32 text-left">{__("Approved")}</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{documents.map(document => (
|
||||
<ApprovableDocumentRow
|
||||
key={document.id}
|
||||
fKey={document}
|
||||
organizationId={organizationId}
|
||||
/>
|
||||
))}
|
||||
</Tbody>
|
||||
</table>
|
||||
</Card>
|
||||
)
|
||||
: (
|
||||
<Card padded>
|
||||
<div className="text-center py-12">
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
{__("No documents yet")}
|
||||
</h3>
|
||||
<p className="text-txt-tertiary mb-4">
|
||||
{__("No documents have been requested for your approval.")}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Skeleton } from "@probo/ui";
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useQueryLoader } from "react-relay";
|
||||
|
||||
import type { EmployeeApprovalsPageQuery } from "#/__generated__/core/EmployeeApprovalsPageQuery.graphql";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
import {
|
||||
EmployeeApprovalsPage,
|
||||
employeeApprovalsPageQuery,
|
||||
} from "./EmployeeApprovalsPage";
|
||||
|
||||
function EmployeeApprovalsPageQueryLoader() {
|
||||
const organizationId = useOrganizationId();
|
||||
const [queryRef, loadQuery] = useQueryLoader<EmployeeApprovalsPageQuery>(
|
||||
employeeApprovalsPageQuery,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadQuery({
|
||||
organizationId,
|
||||
});
|
||||
}, [loadQuery, organizationId]);
|
||||
|
||||
if (!queryRef) {
|
||||
return <Skeleton className="w-full h-64" />;
|
||||
}
|
||||
|
||||
return <EmployeeApprovalsPage queryRef={queryRef} />;
|
||||
}
|
||||
|
||||
export default function EmployeeApprovalsPageLoader() {
|
||||
return (
|
||||
<Suspense fallback={<Skeleton className="w-full h-64" />}>
|
||||
<EmployeeApprovalsPageQueryLoader />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export const employeeDocumentSignaturePageQuery = graphql`
|
||||
`;
|
||||
|
||||
const documentFragment = graphql`
|
||||
fragment EmployeeDocumentSignaturePageDocumentFragment on SignableDocument {
|
||||
fragment EmployeeDocumentSignaturePageDocumentFragment on EmployeeDocument {
|
||||
id
|
||||
title
|
||||
# eslint-disable-next-line relay/unused-fields
|
||||
@@ -159,7 +159,7 @@ function DocumentSignatureContent({
|
||||
description: __("Document signed successfully"),
|
||||
variant: "success",
|
||||
});
|
||||
void navigate(`/organizations/${organizationId}/employee`);
|
||||
void navigate(`/organizations/${organizationId}/employee/signatures`);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
@@ -256,7 +256,7 @@ function DocumentSignatureContent({
|
||||
isSigning={isSigning}
|
||||
onSign={handleSign}
|
||||
onBack={() =>
|
||||
void navigate(`/organizations/${organizationId}/employee`)}
|
||||
void navigate(`/organizations/${organizationId}/employee/signatures`)}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useParams } from "react-router";
|
||||
|
||||
import type { EmployeeDocumentSignaturePageQuery } from "#/__generated__/core/EmployeeDocumentSignaturePageQuery.graphql";
|
||||
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
|
||||
import { CoreRelayProvider } from "#/providers/CoreRelayProvider";
|
||||
|
||||
import {
|
||||
EmployeeDocumentSignaturePage,
|
||||
@@ -39,8 +38,6 @@ function EmployeeDocumentSignaturePageQueryLoader() {
|
||||
|
||||
export default function EmployeeDocumentSignaturePageLoader() {
|
||||
return (
|
||||
<CoreRelayProvider>
|
||||
<EmployeeDocumentSignaturePageQueryLoader />
|
||||
</CoreRelayProvider>
|
||||
<EmployeeDocumentSignaturePageQueryLoader />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { usePageTitle } from "@probo/hooks";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Card, PageHeader, Tbody, Th, Thead, Tr } from "@probo/ui";
|
||||
import { Card, Tbody, Th, Thead, Tr } from "@probo/ui";
|
||||
import { graphql, type PreloadedQuery, usePreloadedQuery } from "react-relay";
|
||||
|
||||
import type { EmployeeDocumentsPageQuery } from "#/__generated__/core/EmployeeDocumentsPageQuery.graphql";
|
||||
@@ -46,19 +46,18 @@ export function EmployeeDocumentsPage(props: {
|
||||
usePageTitle(__("Documents"));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title={__("Documents")} />
|
||||
<>
|
||||
{documents.length > 0
|
||||
? (
|
||||
<Card>
|
||||
<table className="w-full">
|
||||
<table className="w-full table-fixed">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th className="min-w-0 pr-12">{__("Name")}</Th>
|
||||
<Th className="w-48">{__("Type")}</Th>
|
||||
<Th className="w-36">{__("Classification")}</Th>
|
||||
<Th className="w-40">{__("Last update")}</Th>
|
||||
<Th className="w-32">{__("Signed")}</Th>
|
||||
<Th className="text-left">{__("Name")}</Th>
|
||||
<Th className="w-48 text-left">{__("Type")}</Th>
|
||||
<Th className="w-36 text-left">{__("Classification")}</Th>
|
||||
<Th className="w-40 text-left">{__("Last update")}</Th>
|
||||
<Th className="w-32 text-left">{__("Signed")}</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
@@ -85,6 +84,6 @@ export function EmployeeDocumentsPage(props: {
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useQueryLoader } from "react-relay";
|
||||
import type { EmployeeDocumentsPageQuery } from "#/__generated__/core/EmployeeDocumentsPageQuery.graphql";
|
||||
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
import { CoreRelayProvider } from "#/providers/CoreRelayProvider";
|
||||
|
||||
import {
|
||||
EmployeeDocumentsPage,
|
||||
@@ -32,10 +31,8 @@ function EmployeeDocumentsPageQueryLoader() {
|
||||
|
||||
export default function EmployeeDocumentsPageLoader() {
|
||||
return (
|
||||
<CoreRelayProvider>
|
||||
<Suspense fallback={<PageSkeleton />}>
|
||||
<EmployeeDocumentsPageQueryLoader />
|
||||
</Suspense>
|
||||
</CoreRelayProvider>
|
||||
<Suspense fallback={<PageSkeleton />}>
|
||||
<EmployeeDocumentsPageQueryLoader />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { PageHeader, TabLink, Tabs } from "@probo/ui";
|
||||
import { Outlet } from "react-router";
|
||||
|
||||
export default function EmployeeTabsLayout() {
|
||||
const { __ } = useTranslate();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title={__("Documents")} />
|
||||
<Tabs>
|
||||
<TabLink to="signatures" end>
|
||||
{__("Signatures")}
|
||||
</TabLink>
|
||||
<TabLink to="approvals" end>
|
||||
{__("Approvals")}
|
||||
</TabLink>
|
||||
</Tabs>
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
formatDate,
|
||||
getDocumentClassificationLabel,
|
||||
getDocumentTypeLabel,
|
||||
} from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Badge, Td, Tr } from "@probo/ui";
|
||||
import { graphql, useFragment } from "react-relay";
|
||||
|
||||
import type { ApprovableDocumentRowFragment$key } from "#/__generated__/core/ApprovableDocumentRowFragment.graphql";
|
||||
|
||||
const fragment = graphql`
|
||||
fragment ApprovableDocumentRowFragment on EmployeeDocument {
|
||||
id
|
||||
title
|
||||
documentType
|
||||
classification
|
||||
approvalState
|
||||
updatedAt
|
||||
}
|
||||
`;
|
||||
|
||||
export function ApprovableDocumentRow({
|
||||
fKey,
|
||||
organizationId,
|
||||
}: {
|
||||
fKey: ApprovableDocumentRowFragment$key;
|
||||
organizationId: string;
|
||||
}) {
|
||||
const document = useFragment<ApprovableDocumentRowFragment$key>(fragment, fKey);
|
||||
const { __ } = useTranslate();
|
||||
|
||||
const stateVariant = document.approvalState === "APPROVED"
|
||||
? "success"
|
||||
: document.approvalState === "REJECTED"
|
||||
? "danger"
|
||||
: "warning";
|
||||
|
||||
const stateLabel = document.approvalState === "APPROVED"
|
||||
? __("Approved")
|
||||
: document.approvalState === "REJECTED"
|
||||
? __("Rejected")
|
||||
: __("Pending");
|
||||
|
||||
return (
|
||||
<Tr to={`/organizations/${organizationId}/employee/approvals/${document.id}`}>
|
||||
<Td>{document.title}</Td>
|
||||
<Td className="w-48">
|
||||
{getDocumentTypeLabel(__, document.documentType)}
|
||||
</Td>
|
||||
<Td className="w-36">
|
||||
<Badge variant="neutral">
|
||||
{getDocumentClassificationLabel(__, document.classification)}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td className="w-40">{formatDate(document.updatedAt)}</Td>
|
||||
<Td className="w-32">
|
||||
<Badge variant={stateVariant}>
|
||||
{stateLabel}
|
||||
</Badge>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { graphql, useFragment } from "react-relay";
|
||||
import type { DocumentRowFragment$key } from "#/__generated__/core/DocumentRowFragment.graphql";
|
||||
|
||||
const fragment = graphql`
|
||||
fragment DocumentRowFragment on SignableDocument {
|
||||
fragment DocumentRowFragment on EmployeeDocument {
|
||||
id
|
||||
title
|
||||
documentType
|
||||
@@ -31,8 +31,8 @@ export function DocumentRow({
|
||||
const { __ } = useTranslate();
|
||||
|
||||
return (
|
||||
<Tr to={`/organizations/${organizationId}/employee/${document.id}`}>
|
||||
<Td className="min-w-0 pr-12">{document.title}</Td>
|
||||
<Tr to={`/organizations/${organizationId}/employee/signatures/${document.id}`}>
|
||||
<Td>{document.title}</Td>
|
||||
<Td className="w-48">
|
||||
{getDocumentTypeLabel(__, document.documentType)}
|
||||
</Td>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { graphql, useFragment } from "react-relay";
|
||||
import type { VersionActionsFragment$key } from "#/__generated__/core/VersionActionsFragment.graphql";
|
||||
|
||||
const fragment = graphql`
|
||||
fragment VersionActionsFragment on DocumentVersion {
|
||||
fragment VersionActionsFragment on EmployeeDocumentVersion {
|
||||
id
|
||||
signed
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { graphql, useFragment } from "react-relay";
|
||||
import type { VersionRowFragment$key } from "#/__generated__/core/VersionRowFragment.graphql";
|
||||
|
||||
const fragment = graphql`
|
||||
fragment VersionRowFragment on DocumentVersion {
|
||||
fragment VersionRowFragment on EmployeeDocumentVersion {
|
||||
# eslint-disable-next-line relay/unused-fields
|
||||
id
|
||||
version
|
||||
|
||||
@@ -136,18 +136,55 @@ const routes = [
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
loader: ({ params: { organizationId } }) => {
|
||||
// eslint-disable-next-line
|
||||
throw redirect(`/organizations/${organizationId}/employee/signatures`);
|
||||
},
|
||||
Component: () => null,
|
||||
},
|
||||
{
|
||||
Component: lazy(
|
||||
() =>
|
||||
import("./pages/organizations/employee/EmployeeDocumentsPageLoader"),
|
||||
() => import("./pages/organizations/employee/EmployeeTabsLayout"),
|
||||
),
|
||||
children: [
|
||||
{
|
||||
path: "signatures",
|
||||
Component: lazy(
|
||||
() =>
|
||||
import("./pages/organizations/employee/EmployeeDocumentsPageLoader"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "approvals",
|
||||
Component: lazy(
|
||||
() =>
|
||||
import("./pages/organizations/employee/EmployeeApprovalsPageLoader"),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: ":documentId",
|
||||
loader: ({ params: { organizationId, documentId } }) => {
|
||||
// eslint-disable-next-line
|
||||
throw redirect(`/organizations/${organizationId}/employee/signatures/${documentId}`);
|
||||
},
|
||||
Component: () => null,
|
||||
},
|
||||
{
|
||||
path: "signatures/:documentId",
|
||||
Component: lazy(
|
||||
() =>
|
||||
import("./pages/organizations/employee/EmployeeDocumentSignaturePageLoader"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "approvals/:documentId",
|
||||
Component: lazy(
|
||||
() =>
|
||||
import("./pages/organizations/documents/approve/DocumentApprovePageLoader"),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -40,6 +40,14 @@ const documentTabs = (prefix: string) => {
|
||||
import("#/pages/organizations/documents/controls/DocumentControlsPageLoader"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: `${prefix}approvals`,
|
||||
Fallback: LinkCardSkeleton,
|
||||
Component: lazy(
|
||||
() =>
|
||||
import("#/pages/organizations/documents/approvals/DocumentApprovalsPageLoader"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: `${prefix}signatures`,
|
||||
Fallback: LinkCardSkeleton,
|
||||
@@ -61,6 +69,9 @@ export const documentsRoutes = [
|
||||
path: "documents/:documentId",
|
||||
Fallback: PageSkeleton,
|
||||
Component: lazy(() => import("#/pages/organizations/documents/DocumentLayoutLoader")),
|
||||
children: [...documentTabs(""), ...documentTabs("versions/:versionId/")],
|
||||
children: [
|
||||
...documentTabs(""),
|
||||
...documentTabs("versions/:versionId/"),
|
||||
],
|
||||
},
|
||||
] satisfies AppRoute[];
|
||||
|
||||
@@ -30,7 +30,6 @@ import (
|
||||
func TestDocument_Create(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
approverProfileID := factory.CreateUser(owner)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -114,7 +113,6 @@ func TestDocument_Create(t *testing.T) {
|
||||
|
||||
input := map[string]any{
|
||||
"organizationId": owner.GetOrganizationID().String(),
|
||||
"approverIds": []string{approverProfileID},
|
||||
}
|
||||
maps.Copy(input, tt.input)
|
||||
|
||||
@@ -150,13 +148,11 @@ func TestDocument_Create(t *testing.T) {
|
||||
func TestDocument_Create_Validation(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
approverProfileID := factory.CreateUser(owner)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input map[string]any
|
||||
skipOrganization bool
|
||||
skipApprover bool
|
||||
wantErrorContains string
|
||||
}{
|
||||
{
|
||||
@@ -170,17 +166,6 @@ func TestDocument_Create_Validation(t *testing.T) {
|
||||
skipOrganization: true,
|
||||
wantErrorContains: "organizationId",
|
||||
},
|
||||
{
|
||||
name: "missing approverIds",
|
||||
input: map[string]any{
|
||||
"title": "Test Document",
|
||||
"content": "Test content",
|
||||
"documentType": "POLICY",
|
||||
"classification": "INTERNAL",
|
||||
},
|
||||
skipApprover: true,
|
||||
wantErrorContains: "approverIds",
|
||||
},
|
||||
{
|
||||
name: "title with HTML tags",
|
||||
input: map[string]any{
|
||||
@@ -281,9 +266,6 @@ func TestDocument_Create_Validation(t *testing.T) {
|
||||
if !tt.skipOrganization {
|
||||
input["organizationId"] = owner.GetOrganizationID().String()
|
||||
}
|
||||
if !tt.skipApprover {
|
||||
input["approverIds"] = []string{approverProfileID}
|
||||
}
|
||||
maps.Copy(input, tt.input)
|
||||
|
||||
_, err := owner.Do(query, map[string]any{"input": input})
|
||||
@@ -296,7 +278,6 @@ func TestDocument_Create_Validation(t *testing.T) {
|
||||
func TestDocument_Update(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
approverProfileID := factory.CreateUser(owner)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -308,7 +289,7 @@ func TestDocument_Update(t *testing.T) {
|
||||
{
|
||||
name: "update title",
|
||||
setup: func() string {
|
||||
return factory.NewDocument(owner, approverProfileID).
|
||||
return factory.NewDocument(owner).
|
||||
WithTitle("Document to Update").
|
||||
Create()
|
||||
},
|
||||
@@ -324,7 +305,7 @@ func TestDocument_Update(t *testing.T) {
|
||||
{
|
||||
name: "update document type",
|
||||
setup: func() string {
|
||||
return factory.NewDocument(owner, approverProfileID).
|
||||
return factory.NewDocument(owner).
|
||||
WithTitle("Type Test").
|
||||
WithDocumentType("POLICY").
|
||||
Create()
|
||||
@@ -380,8 +361,8 @@ func TestDocument_Update(t *testing.T) {
|
||||
func TestDocument_Update_Validation(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
approverProfileID := factory.CreateUser(owner)
|
||||
baseDocumentID := factory.NewDocument(owner, approverProfileID).WithTitle("Validation Test Document").Create()
|
||||
|
||||
baseDocumentID := factory.NewDocument(owner).WithTitle("Validation Test Document").Create()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -463,10 +444,9 @@ func TestDocument_Update_Validation(t *testing.T) {
|
||||
func TestDocument_Delete(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
approverProfileID := factory.CreateUser(owner)
|
||||
|
||||
t.Run("delete existing document", func(t *testing.T) {
|
||||
documentID := factory.NewDocument(owner, approverProfileID).WithTitle("Document to Delete").Create()
|
||||
documentID := factory.NewDocument(owner).WithTitle("Document to Delete").Create()
|
||||
|
||||
query := `
|
||||
mutation DeleteDocument($input: DeleteDocumentInput!) {
|
||||
@@ -528,11 +508,10 @@ func TestDocument_Delete_Validation(t *testing.T) {
|
||||
func TestDocument_List(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
approverProfileID := factory.CreateUser(owner)
|
||||
|
||||
documentTitles := []string{"Document A", "Document B", "Document C"}
|
||||
for _, title := range documentTitles {
|
||||
factory.NewDocument(owner, approverProfileID).WithTitle(title).Create()
|
||||
factory.NewDocument(owner).WithTitle(title).Create()
|
||||
}
|
||||
|
||||
query := `
|
||||
@@ -600,7 +579,6 @@ func TestDocument_Query(t *testing.T) {
|
||||
func TestDocument_Timestamps(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
approverProfileID := factory.CreateUser(owner)
|
||||
|
||||
t.Run("createdAt and updatedAt are set on create", func(t *testing.T) {
|
||||
beforeCreate := time.Now().Add(-time.Second)
|
||||
@@ -634,7 +612,6 @@ func TestDocument_Timestamps(t *testing.T) {
|
||||
err := owner.Execute(query, map[string]any{
|
||||
"input": map[string]any{
|
||||
"organizationId": owner.GetOrganizationID().String(),
|
||||
"approverIds": []string{approverProfileID},
|
||||
"title": "Timestamp Test Document",
|
||||
"content": "Test content",
|
||||
"documentType": "POLICY",
|
||||
@@ -648,7 +625,7 @@ func TestDocument_Timestamps(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("updatedAt changes on update", func(t *testing.T) {
|
||||
documentID := factory.NewDocument(owner, approverProfileID).WithTitle("Timestamp Update Test").Create()
|
||||
documentID := factory.NewDocument(owner).WithTitle("Timestamp Update Test").Create()
|
||||
|
||||
getQuery := `
|
||||
query($id: ID!) {
|
||||
@@ -713,50 +690,8 @@ func TestDocument_Timestamps(t *testing.T) {
|
||||
func TestDocument_SubResolvers(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
approverProfileID := factory.CreateUser(owner)
|
||||
documentID := factory.NewDocument(owner, approverProfileID).WithTitle("SubResolver Test Document").Create()
|
||||
|
||||
t.Run("approvers sub-resolver", func(t *testing.T) {
|
||||
query := `
|
||||
query($id: ID!) {
|
||||
node(id: $id) {
|
||||
... on Document {
|
||||
id
|
||||
approvers {
|
||||
totalCount
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
fullName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
var result struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
Approvers struct {
|
||||
TotalCount int `json:"totalCount"`
|
||||
Edges []struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
FullName string `json:"fullName"`
|
||||
} `json:"node"`
|
||||
} `json:"edges"`
|
||||
} `json:"approvers"`
|
||||
} `json:"node"`
|
||||
}
|
||||
|
||||
err := owner.Execute(query, map[string]any{"id": documentID}, &result)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, result.Node.Approvers.TotalCount)
|
||||
require.Len(t, result.Node.Approvers.Edges, 1)
|
||||
assert.Equal(t, approverProfileID, result.Node.Approvers.Edges[0].Node.ID)
|
||||
})
|
||||
documentID := factory.NewDocument(owner).WithTitle("SubResolver Test Document").Create()
|
||||
|
||||
t.Run("organization sub-resolver", func(t *testing.T) {
|
||||
query := `
|
||||
@@ -796,7 +731,6 @@ func TestDocument_RBAC(t *testing.T) {
|
||||
t.Run("create", func(t *testing.T) {
|
||||
t.Run("owner can create", func(t *testing.T) {
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
approverProfileID := factory.CreateUser(owner)
|
||||
|
||||
_, err := owner.Do(`
|
||||
mutation CreateDocument($input: CreateDocumentInput!) {
|
||||
@@ -807,7 +741,6 @@ func TestDocument_RBAC(t *testing.T) {
|
||||
`, map[string]any{
|
||||
"input": map[string]any{
|
||||
"organizationId": owner.GetOrganizationID().String(),
|
||||
"approverIds": []string{approverProfileID},
|
||||
"title": "RBAC Test Document",
|
||||
"content": "Test content",
|
||||
"documentType": "POLICY",
|
||||
@@ -820,7 +753,6 @@ func TestDocument_RBAC(t *testing.T) {
|
||||
t.Run("admin can create", func(t *testing.T) {
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
admin := testutil.NewClientInOrg(t, testutil.RoleAdmin, owner)
|
||||
approverProfileID := factory.CreateUser(owner)
|
||||
|
||||
_, err := admin.Do(`
|
||||
mutation CreateDocument($input: CreateDocumentInput!) {
|
||||
@@ -831,7 +763,6 @@ func TestDocument_RBAC(t *testing.T) {
|
||||
`, map[string]any{
|
||||
"input": map[string]any{
|
||||
"organizationId": admin.GetOrganizationID().String(),
|
||||
"approverIds": []string{approverProfileID},
|
||||
"title": "RBAC Test Document",
|
||||
"content": "Test content",
|
||||
"documentType": "POLICY",
|
||||
@@ -844,7 +775,6 @@ func TestDocument_RBAC(t *testing.T) {
|
||||
t.Run("viewer cannot create", func(t *testing.T) {
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
|
||||
approverProfileID := factory.CreateUser(owner)
|
||||
|
||||
_, err := viewer.Do(`
|
||||
mutation CreateDocument($input: CreateDocumentInput!) {
|
||||
@@ -855,7 +785,6 @@ func TestDocument_RBAC(t *testing.T) {
|
||||
`, map[string]any{
|
||||
"input": map[string]any{
|
||||
"organizationId": viewer.GetOrganizationID().String(),
|
||||
"approverIds": []string{approverProfileID},
|
||||
"title": "RBAC Test Document",
|
||||
"content": "Test content",
|
||||
"documentType": "POLICY",
|
||||
@@ -869,8 +798,8 @@ func TestDocument_RBAC(t *testing.T) {
|
||||
t.Run("update", func(t *testing.T) {
|
||||
t.Run("owner can update", func(t *testing.T) {
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
approverProfileID := factory.CreateUser(owner)
|
||||
documentID := factory.NewDocument(owner, approverProfileID).WithTitle("RBAC Update Test").Create()
|
||||
|
||||
documentID := factory.NewDocument(owner).WithTitle("RBAC Update Test").Create()
|
||||
|
||||
_, err := owner.Do(`
|
||||
mutation UpdateDocument($input: UpdateDocumentInput!) {
|
||||
@@ -890,8 +819,8 @@ func TestDocument_RBAC(t *testing.T) {
|
||||
t.Run("admin can update", func(t *testing.T) {
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
admin := testutil.NewClientInOrg(t, testutil.RoleAdmin, owner)
|
||||
approverProfileID := factory.CreateUser(owner)
|
||||
documentID := factory.NewDocument(owner, approverProfileID).WithTitle("RBAC Update Test").Create()
|
||||
|
||||
documentID := factory.NewDocument(owner).WithTitle("RBAC Update Test").Create()
|
||||
|
||||
_, err := admin.Do(`
|
||||
mutation UpdateDocument($input: UpdateDocumentInput!) {
|
||||
@@ -911,8 +840,8 @@ func TestDocument_RBAC(t *testing.T) {
|
||||
t.Run("viewer cannot update", func(t *testing.T) {
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
|
||||
approverProfileID := factory.CreateUser(owner)
|
||||
documentID := factory.NewDocument(owner, approverProfileID).WithTitle("RBAC Update Test").Create()
|
||||
|
||||
documentID := factory.NewDocument(owner).WithTitle("RBAC Update Test").Create()
|
||||
|
||||
_, err := viewer.Do(`
|
||||
mutation UpdateDocument($input: UpdateDocumentInput!) {
|
||||
@@ -933,8 +862,8 @@ func TestDocument_RBAC(t *testing.T) {
|
||||
t.Run("delete", func(t *testing.T) {
|
||||
t.Run("owner can delete", func(t *testing.T) {
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
approverProfileID := factory.CreateUser(owner)
|
||||
documentID := factory.NewDocument(owner, approverProfileID).WithTitle("RBAC Delete Test").Create()
|
||||
|
||||
documentID := factory.NewDocument(owner).WithTitle("RBAC Delete Test").Create()
|
||||
|
||||
_, err := owner.Do(`
|
||||
mutation DeleteDocument($input: DeleteDocumentInput!) {
|
||||
@@ -951,8 +880,8 @@ func TestDocument_RBAC(t *testing.T) {
|
||||
t.Run("admin can delete", func(t *testing.T) {
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
admin := testutil.NewClientInOrg(t, testutil.RoleAdmin, owner)
|
||||
approverProfileID := factory.CreateUser(owner)
|
||||
documentID := factory.NewDocument(owner, approverProfileID).WithTitle("RBAC Delete Test").Create()
|
||||
|
||||
documentID := factory.NewDocument(owner).WithTitle("RBAC Delete Test").Create()
|
||||
|
||||
_, err := admin.Do(`
|
||||
mutation DeleteDocument($input: DeleteDocumentInput!) {
|
||||
@@ -969,8 +898,8 @@ func TestDocument_RBAC(t *testing.T) {
|
||||
t.Run("viewer cannot delete", func(t *testing.T) {
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
|
||||
approverProfileID := factory.CreateUser(owner)
|
||||
documentID := factory.NewDocument(owner, approverProfileID).WithTitle("RBAC Delete Test").Create()
|
||||
|
||||
documentID := factory.NewDocument(owner).WithTitle("RBAC Delete Test").Create()
|
||||
|
||||
_, err := viewer.Do(`
|
||||
mutation DeleteDocument($input: DeleteDocumentInput!) {
|
||||
@@ -988,8 +917,8 @@ func TestDocument_RBAC(t *testing.T) {
|
||||
t.Run("read", func(t *testing.T) {
|
||||
t.Run("owner can read", func(t *testing.T) {
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
approverProfileID := factory.CreateUser(owner)
|
||||
documentID := factory.NewDocument(owner, approverProfileID).WithTitle("RBAC Read Test").Create()
|
||||
|
||||
documentID := factory.NewDocument(owner).WithTitle("RBAC Read Test").Create()
|
||||
|
||||
var result struct {
|
||||
Node *struct {
|
||||
@@ -1012,8 +941,8 @@ func TestDocument_RBAC(t *testing.T) {
|
||||
t.Run("admin can read", func(t *testing.T) {
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
admin := testutil.NewClientInOrg(t, testutil.RoleAdmin, owner)
|
||||
approverProfileID := factory.CreateUser(owner)
|
||||
documentID := factory.NewDocument(owner, approverProfileID).WithTitle("RBAC Read Test").Create()
|
||||
|
||||
documentID := factory.NewDocument(owner).WithTitle("RBAC Read Test").Create()
|
||||
|
||||
var result struct {
|
||||
Node *struct {
|
||||
@@ -1036,8 +965,8 @@ func TestDocument_RBAC(t *testing.T) {
|
||||
t.Run("viewer can read", func(t *testing.T) {
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
|
||||
approverProfileID := factory.CreateUser(owner)
|
||||
documentID := factory.NewDocument(owner, approverProfileID).WithTitle("RBAC Read Test").Create()
|
||||
|
||||
documentID := factory.NewDocument(owner).WithTitle("RBAC Read Test").Create()
|
||||
|
||||
var result struct {
|
||||
Node *struct {
|
||||
@@ -1062,7 +991,6 @@ func TestDocument_RBAC(t *testing.T) {
|
||||
func TestDocument_MaxLength_Validation(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
approverProfileID := factory.CreateUser(owner)
|
||||
|
||||
longTitle := strings.Repeat("a", 1001)
|
||||
|
||||
@@ -1080,7 +1008,6 @@ func TestDocument_MaxLength_Validation(t *testing.T) {
|
||||
_, err := owner.Do(query, map[string]any{
|
||||
"input": map[string]any{
|
||||
"organizationId": owner.GetOrganizationID().String(),
|
||||
"approverIds": []string{approverProfileID},
|
||||
"title": longTitle,
|
||||
"content": "Test content",
|
||||
"documentType": "POLICY",
|
||||
@@ -1092,7 +1019,7 @@ func TestDocument_MaxLength_Validation(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("update", func(t *testing.T) {
|
||||
documentID := factory.NewDocument(owner, approverProfileID).WithTitle("Max Length Test").Create()
|
||||
documentID := factory.NewDocument(owner).WithTitle("Max Length Test").Create()
|
||||
|
||||
query := `
|
||||
mutation UpdateDocument($input: UpdateDocumentInput!) {
|
||||
@@ -1116,10 +1043,9 @@ func TestDocument_MaxLength_Validation(t *testing.T) {
|
||||
func TestDocument_Pagination(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
approverProfileID := factory.CreateUser(owner)
|
||||
|
||||
for i := range 5 {
|
||||
factory.NewDocument(owner, approverProfileID).
|
||||
factory.NewDocument(owner).
|
||||
WithTitle(fmt.Sprintf("Pagination Document %d", i)).
|
||||
Create()
|
||||
}
|
||||
@@ -1265,8 +1191,7 @@ func TestDocument_TenantIsolation(t *testing.T) {
|
||||
org1Owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
org2Owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
approverProfileID := factory.CreateUser(org1Owner)
|
||||
documentID := factory.NewDocument(org1Owner, approverProfileID).WithTitle("Org1 Document").Create()
|
||||
documentID := factory.NewDocument(org1Owner).WithTitle("Org1 Document").Create()
|
||||
|
||||
t.Run("cannot read document from another organization", func(t *testing.T) {
|
||||
query := `
|
||||
@@ -1372,10 +1297,9 @@ func TestDocument_TenantIsolation(t *testing.T) {
|
||||
func TestDocument_Ordering(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
approverProfileID := factory.CreateUser(owner)
|
||||
|
||||
factory.NewDocument(owner, approverProfileID).WithTitle("AAA Order Test").Create()
|
||||
factory.NewDocument(owner, approverProfileID).WithTitle("ZZZ Order Test").Create()
|
||||
factory.NewDocument(owner).WithTitle("AAA Order Test").Create()
|
||||
factory.NewDocument(owner).WithTitle("ZZZ Order Test").Create()
|
||||
|
||||
t.Run("order by created_at descending", func(t *testing.T) {
|
||||
query := `
|
||||
|
||||
@@ -23,10 +23,52 @@ import (
|
||||
"go.probo.inc/probo/e2e/internal/testutil"
|
||||
)
|
||||
|
||||
// getOwnerProfileID queries the organization profiles and returns the first one (the owner's).
|
||||
func getOwnerProfileID(t *testing.T, owner *testutil.Client) string {
|
||||
t.Helper()
|
||||
|
||||
query := `
|
||||
query GetProfiles($orgId: ID!) {
|
||||
node(id: $orgId) {
|
||||
... on Organization {
|
||||
profiles(first: 1) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
var result struct {
|
||||
Node struct {
|
||||
Profiles struct {
|
||||
Edges []struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"node"`
|
||||
} `json:"edges"`
|
||||
} `json:"profiles"`
|
||||
} `json:"node"`
|
||||
}
|
||||
|
||||
err := owner.Execute(
|
||||
query,
|
||||
map[string]any{"orgId": owner.GetOrganizationID().String()},
|
||||
&result,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, result.Node.Profiles.Edges)
|
||||
|
||||
return result.Node.Profiles.Edges[0].Node.ID
|
||||
}
|
||||
|
||||
// createTestDocument creates a document and returns its ID and the document version ID
|
||||
func createTestDocument(t *testing.T, owner *testutil.Client) (docID string, docVersionID string) {
|
||||
t.Helper()
|
||||
profileID := factory.CreateUser(owner)
|
||||
|
||||
query := `
|
||||
mutation CreateDocument($input: CreateDocumentInput!) {
|
||||
@@ -69,7 +111,6 @@ func createTestDocument(t *testing.T, owner *testutil.Client) (docID string, doc
|
||||
"organizationId": owner.GetOrganizationID().String(),
|
||||
"title": "Test Document",
|
||||
"content": "Initial content",
|
||||
"approverIds": []string{profileID},
|
||||
"documentType": "POLICY",
|
||||
"classification": "INTERNAL",
|
||||
},
|
||||
@@ -83,79 +124,143 @@ func createTestDocument(t *testing.T, owner *testutil.Client) (docID string, doc
|
||||
return docID, docVersionID
|
||||
}
|
||||
|
||||
// approveTestDocument requests approval and approves the document so it can be published.
|
||||
func approveTestDocument(t *testing.T, owner *testutil.Client, docID string) {
|
||||
t.Helper()
|
||||
|
||||
requestQuery := `
|
||||
mutation RequestApproval($input: RequestDocumentVersionApprovalInput!) {
|
||||
requestDocumentVersionApproval(input: $input) {
|
||||
approvalQuorum {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// Use the owner's profile as the approver
|
||||
approverID := getOwnerProfileID(t, owner)
|
||||
|
||||
_, err := owner.Do(requestQuery, map[string]any{
|
||||
"input": map[string]any{
|
||||
"documentId": docID,
|
||||
"approverIds": []string{approverID},
|
||||
"changelog": "Test changelog",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Approve for each approver
|
||||
approveQuery := `
|
||||
mutation ApproveDocumentVersion($input: ApproveDocumentVersionInput!) {
|
||||
approveDocumentVersion(input: $input) {
|
||||
approvalDecision {
|
||||
id
|
||||
state
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// Get the latest version ID
|
||||
versionQuery := `
|
||||
query GetVersions($id: ID!) {
|
||||
node(id: $id) {
|
||||
... on Document {
|
||||
versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
var versionResult struct {
|
||||
Node struct {
|
||||
Versions struct {
|
||||
Edges []struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"node"`
|
||||
} `json:"edges"`
|
||||
} `json:"versions"`
|
||||
} `json:"node"`
|
||||
}
|
||||
|
||||
err = owner.Execute(versionQuery, map[string]any{"id": docID}, &versionResult)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, versionResult.Node.Versions.Edges)
|
||||
|
||||
versionID := versionResult.Node.Versions.Edges[0].Node.ID
|
||||
|
||||
_, err = owner.Do(approveQuery, map[string]any{
|
||||
"input": map[string]any{
|
||||
"documentVersionId": versionID,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDocumentVersion_PublishVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
docID, _ := createTestDocument(t, owner)
|
||||
approveTestDocument(t, owner, docID)
|
||||
|
||||
// After approval, the version is auto-published.
|
||||
// Verify the version status by querying.
|
||||
query := `
|
||||
mutation PublishDocumentVersion($input: PublishDocumentVersionInput!) {
|
||||
publishDocumentVersion(input: $input) {
|
||||
documentVersion {
|
||||
id
|
||||
status
|
||||
version
|
||||
changelog
|
||||
}
|
||||
document {
|
||||
id
|
||||
query GetDocument($id: ID!) {
|
||||
node(id: $id) {
|
||||
... on Document {
|
||||
versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
status
|
||||
version
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
var result struct {
|
||||
PublishDocumentVersion struct {
|
||||
DocumentVersion struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Version int `json:"version"`
|
||||
Changelog string `json:"changelog"`
|
||||
} `json:"documentVersion"`
|
||||
Document struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"document"`
|
||||
} `json:"publishDocumentVersion"`
|
||||
Node struct {
|
||||
Versions struct {
|
||||
Edges []struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Version int `json:"version"`
|
||||
} `json:"node"`
|
||||
} `json:"edges"`
|
||||
} `json:"versions"`
|
||||
} `json:"node"`
|
||||
}
|
||||
|
||||
err := owner.Execute(query, map[string]any{
|
||||
"input": map[string]any{
|
||||
"documentId": docID,
|
||||
"changelog": "Initial release",
|
||||
},
|
||||
}, &result)
|
||||
err := owner.Execute(query, map[string]any{"id": docID}, &result)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, result.Node.Versions.Edges)
|
||||
|
||||
assert.Equal(t, "PUBLISHED", result.PublishDocumentVersion.DocumentVersion.Status)
|
||||
assert.Equal(t, 1, result.PublishDocumentVersion.DocumentVersion.Version)
|
||||
assert.Equal(t, "Initial release", result.PublishDocumentVersion.DocumentVersion.Changelog)
|
||||
assert.Equal(t, "PUBLISHED", result.Node.Versions.Edges[0].Node.Status)
|
||||
assert.Equal(t, 1, result.Node.Versions.Edges[0].Node.Version)
|
||||
}
|
||||
|
||||
func TestDocumentVersion_CreateDraft(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
// Create and publish a document first
|
||||
// Create and approve a document (auto-publishes on approval)
|
||||
docID, _ := createTestDocument(t, owner)
|
||||
|
||||
publishQuery := `
|
||||
mutation PublishDocumentVersion($input: PublishDocumentVersionInput!) {
|
||||
publishDocumentVersion(input: $input) {
|
||||
documentVersion {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
_, err := owner.Do(publishQuery, map[string]any{
|
||||
"input": map[string]any{
|
||||
"documentId": docID,
|
||||
"changelog": "Initial release",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
approveTestDocument(t, owner, docID)
|
||||
|
||||
query := `
|
||||
mutation CreateDraftDocumentVersion($input: CreateDraftDocumentVersionInput!) {
|
||||
@@ -181,7 +286,7 @@ func TestDocumentVersion_CreateDraft(t *testing.T) {
|
||||
} `json:"createDraftDocumentVersion"`
|
||||
}
|
||||
|
||||
err = owner.Execute(query, map[string]any{
|
||||
err := owner.Execute(query, map[string]any{
|
||||
"input": map[string]any{
|
||||
"documentID": docID,
|
||||
},
|
||||
@@ -232,36 +337,44 @@ func TestDocumentVersion_RequestSignature(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
// Create and publish a document
|
||||
// Create and approve a document (auto-publishes on approval)
|
||||
docID, _ := createTestDocument(t, owner)
|
||||
approveTestDocument(t, owner, docID)
|
||||
|
||||
publishQuery := `
|
||||
mutation PublishDocumentVersion($input: PublishDocumentVersionInput!) {
|
||||
publishDocumentVersion(input: $input) {
|
||||
documentVersion {
|
||||
id
|
||||
// Get the published version ID
|
||||
versionQuery := `
|
||||
query GetVersions($id: ID!) {
|
||||
node(id: $id) {
|
||||
... on Document {
|
||||
versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
var publishResult struct {
|
||||
PublishDocumentVersion struct {
|
||||
DocumentVersion struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"documentVersion"`
|
||||
} `json:"publishDocumentVersion"`
|
||||
var versionResult struct {
|
||||
Node struct {
|
||||
Versions struct {
|
||||
Edges []struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"node"`
|
||||
} `json:"edges"`
|
||||
} `json:"versions"`
|
||||
} `json:"node"`
|
||||
}
|
||||
|
||||
err := owner.Execute(publishQuery, map[string]any{
|
||||
"input": map[string]any{
|
||||
"documentId": docID,
|
||||
"changelog": "Initial release",
|
||||
},
|
||||
}, &publishResult)
|
||||
err := owner.Execute(versionQuery, map[string]any{"id": docID}, &versionResult)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, versionResult.Node.Versions.Edges)
|
||||
|
||||
publishedVersionID := publishResult.PublishDocumentVersion.DocumentVersion.ID
|
||||
publishedVersionID := versionResult.Node.Versions.Edges[0].Node.ID
|
||||
|
||||
// Create a person to sign
|
||||
signerProfileID := factory.CreateUser(owner)
|
||||
@@ -318,15 +431,15 @@ func TestDocumentVersion_BulkPublish(t *testing.T) {
|
||||
// Create multiple documents
|
||||
docID1, _ := createTestDocument(t, owner)
|
||||
docID2, _ := createTestDocument(t, owner)
|
||||
approveTestDocument(t, owner, docID1)
|
||||
approveTestDocument(t, owner, docID2)
|
||||
|
||||
query := `
|
||||
mutation BulkPublishDocumentVersions($input: BulkPublishDocumentVersionsInput!) {
|
||||
bulkPublishDocumentVersions(input: $input) {
|
||||
documentVersionEdges {
|
||||
node {
|
||||
id
|
||||
status
|
||||
}
|
||||
documentVersions {
|
||||
id
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -334,12 +447,10 @@ func TestDocumentVersion_BulkPublish(t *testing.T) {
|
||||
|
||||
var result struct {
|
||||
BulkPublishDocumentVersions struct {
|
||||
DocumentVersionEdges []struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
} `json:"node"`
|
||||
} `json:"documentVersionEdges"`
|
||||
DocumentVersions []struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
} `json:"documentVersions"`
|
||||
} `json:"bulkPublishDocumentVersions"`
|
||||
}
|
||||
|
||||
@@ -351,9 +462,9 @@ func TestDocumentVersion_BulkPublish(t *testing.T) {
|
||||
}, &result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 2, len(result.BulkPublishDocumentVersions.DocumentVersionEdges))
|
||||
for _, edge := range result.BulkPublishDocumentVersions.DocumentVersionEdges {
|
||||
assert.Equal(t, "PUBLISHED", edge.Node.Status)
|
||||
assert.Equal(t, 2, len(result.BulkPublishDocumentVersions.DocumentVersions))
|
||||
for _, dv := range result.BulkPublishDocumentVersions.DocumentVersions {
|
||||
assert.Equal(t, "PUBLISHED", dv.Status)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,26 +472,9 @@ func TestDocumentVersion_BulkRequestSignatures(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
// Create and publish a document
|
||||
// Create and approve a document (auto-publishes on approval)
|
||||
docID, _ := createTestDocument(t, owner)
|
||||
|
||||
publishQuery := `
|
||||
mutation PublishDocumentVersion($input: PublishDocumentVersionInput!) {
|
||||
publishDocumentVersion(input: $input) {
|
||||
documentVersion {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
_, err := owner.Do(publishQuery, map[string]any{
|
||||
"input": map[string]any{
|
||||
"documentId": docID,
|
||||
"changelog": "Initial release",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
approveTestDocument(t, owner, docID)
|
||||
|
||||
// Create multiple signers
|
||||
signer1ProfileID := factory.CreateUser(owner)
|
||||
@@ -410,7 +504,7 @@ func TestDocumentVersion_BulkRequestSignatures(t *testing.T) {
|
||||
} `json:"bulkRequestSignatures"`
|
||||
}
|
||||
|
||||
err = owner.Execute(query, map[string]any{
|
||||
err := owner.Execute(query, map[string]any{
|
||||
"input": map[string]any{
|
||||
"documentIds": []string{docID},
|
||||
"signatoryIds": []string{signer1ProfileID, signer2ProfileID},
|
||||
|
||||
@@ -370,7 +370,6 @@ func TestControlDocumentMapping_CreateDelete(t *testing.T) {
|
||||
controlID := createControlResult.CreateControl.ControlEdge.Node.ID
|
||||
|
||||
// Create a document
|
||||
profileID := factory.CreateUser(owner)
|
||||
var createDocumentResult struct {
|
||||
CreateDocument struct {
|
||||
DocumentEdge struct {
|
||||
@@ -395,7 +394,6 @@ func TestControlDocumentMapping_CreateDelete(t *testing.T) {
|
||||
"organizationId": owner.GetOrganizationID().String(),
|
||||
"title": "Document for Control Mapping",
|
||||
"content": "Document content",
|
||||
"approverIds": []string{profileID},
|
||||
"documentType": "POLICY",
|
||||
"classification": "INTERNAL",
|
||||
},
|
||||
@@ -760,7 +758,6 @@ func TestRiskDocumentMapping_CreateDelete(t *testing.T) {
|
||||
riskID := createRiskResult.CreateRisk.RiskEdge.Node.ID
|
||||
|
||||
// Create a document
|
||||
profileID := factory.CreateUser(owner)
|
||||
var createDocumentResult struct {
|
||||
CreateDocument struct {
|
||||
DocumentEdge struct {
|
||||
@@ -785,7 +782,6 @@ func TestRiskDocumentMapping_CreateDelete(t *testing.T) {
|
||||
"organizationId": owner.GetOrganizationID().String(),
|
||||
"title": "Document for Risk Mapping",
|
||||
"content": "Document content",
|
||||
"approverIds": []string{profileID},
|
||||
"documentType": "POLICY",
|
||||
"classification": "INTERNAL",
|
||||
},
|
||||
|
||||
@@ -824,7 +824,7 @@ func (b *MeetingBuilder) Create() string {
|
||||
return CreateMeeting(b.client, b.attrs)
|
||||
}
|
||||
|
||||
func CreateDocument(c *testutil.Client, approverID string, attrs ...Attrs) string {
|
||||
func CreateDocument(c *testutil.Client, attrs ...Attrs) string {
|
||||
c.T.Helper()
|
||||
|
||||
var a Attrs
|
||||
@@ -844,7 +844,6 @@ func CreateDocument(c *testutil.Client, approverID string, attrs ...Attrs) strin
|
||||
|
||||
input := map[string]any{
|
||||
"organizationId": c.GetOrganizationID().String(),
|
||||
"approverIds": []string{approverID},
|
||||
"title": a.getString("title", SafeName("Document")),
|
||||
"content": a.getString("content", "Document content"),
|
||||
"documentType": a.getString("documentType", "POLICY"),
|
||||
@@ -868,13 +867,12 @@ func CreateDocument(c *testutil.Client, approverID string, attrs ...Attrs) strin
|
||||
}
|
||||
|
||||
type DocumentBuilder struct {
|
||||
client *testutil.Client
|
||||
approverID string
|
||||
attrs Attrs
|
||||
client *testutil.Client
|
||||
attrs Attrs
|
||||
}
|
||||
|
||||
func NewDocument(c *testutil.Client, approverID string) *DocumentBuilder {
|
||||
return &DocumentBuilder{client: c, approverID: approverID, attrs: Attrs{}}
|
||||
func NewDocument(c *testutil.Client) *DocumentBuilder {
|
||||
return &DocumentBuilder{client: c, attrs: Attrs{}}
|
||||
}
|
||||
|
||||
func (b *DocumentBuilder) WithTitle(title string) *DocumentBuilder {
|
||||
@@ -898,7 +896,7 @@ func (b *DocumentBuilder) WithClassification(classification string) *DocumentBui
|
||||
}
|
||||
|
||||
func (b *DocumentBuilder) Create() string {
|
||||
return CreateDocument(b.client, b.approverID, b.attrs)
|
||||
return CreateDocument(b.client, b.attrs)
|
||||
}
|
||||
|
||||
func CreateProcessingActivity(c *testutil.Client, attrs ...Attrs) string {
|
||||
|
||||
@@ -212,6 +212,7 @@ const (
|
||||
subjectConfirmEmail = "Confirm your email address"
|
||||
subjectPasswordReset = "Reset your password"
|
||||
subjectInvitation = "Invitation to join %s on Probo"
|
||||
subjectDocumentApproval = "Action Required – Please review and approve %s"
|
||||
subjectDocumentSigning = "Action Required – Please review and sign %s compliance documents"
|
||||
subjectDocumentExport = "Your document export is ready"
|
||||
subjectFrameworkExport = "Your framework export is ready"
|
||||
@@ -231,6 +232,8 @@ var (
|
||||
passwordResetTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/password-reset.txt.tmpl"))
|
||||
invitationHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/invitation.html.tmpl"))
|
||||
invitationTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/invitation.txt.tmpl"))
|
||||
documentApprovalHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/document-approval.html.tmpl"))
|
||||
documentApprovalTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/document-approval.txt.tmpl"))
|
||||
documentSigningHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/document-signing.html.tmpl"))
|
||||
documentSigningTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/document-signing.txt.tmpl"))
|
||||
documentExportHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/document-export.html.tmpl"))
|
||||
@@ -348,6 +351,39 @@ func (p *Presenter) RenderInvitation(ctx context.Context, invitationURLPath stri
|
||||
return fmt.Sprintf(subjectInvitation, organizationName), textBody, htmlBody, err
|
||||
}
|
||||
|
||||
func (p *Presenter) RenderDocumentApproval(
|
||||
ctx context.Context,
|
||||
approvalURLPath string,
|
||||
approvalURLQuery url.Values,
|
||||
organizationName string,
|
||||
documentName string,
|
||||
) (subject string, textBody string, htmlBody *string, err error) {
|
||||
vars, err := p.getCommonVariables(ctx)
|
||||
if err != nil {
|
||||
return "", "", nil, fmt.Errorf("cannot get common variables: %w", err)
|
||||
}
|
||||
|
||||
approvalURL := baseurl.MustParse(vars.BaseURL).
|
||||
AppendPath(approvalURLPath).
|
||||
WithQueryValues(approvalURLQuery).
|
||||
MustString()
|
||||
|
||||
data := struct {
|
||||
*CommonVariables
|
||||
ApprovalUrl string
|
||||
OrganizationName string
|
||||
DocumentName string
|
||||
}{
|
||||
CommonVariables: vars,
|
||||
ApprovalUrl: approvalURL,
|
||||
OrganizationName: organizationName,
|
||||
DocumentName: documentName,
|
||||
}
|
||||
|
||||
textBody, htmlBody, err = renderEmail(documentApprovalTextTemplate, documentApprovalHTMLTemplate, data)
|
||||
return fmt.Sprintf(subjectDocumentApproval, documentName), textBody, htmlBody, err
|
||||
}
|
||||
|
||||
func (p *Presenter) RenderDocumentSigning(
|
||||
ctx context.Context,
|
||||
signingURLPath string,
|
||||
@@ -478,7 +514,7 @@ func (p *Presenter) RenderMagicLink(ctx context.Context, magicLinkUrlPath string
|
||||
return fmt.Sprintf(subjectMagicLink, organizationName), textBody, htmlBody, err
|
||||
}
|
||||
|
||||
func (p *Presenter) RenderElectronicSignatureCertificate(ctx context.Context, signerName string, documentType string) (subject string, textBody string, htmlBody *string, err error) {
|
||||
func (p *Presenter) RenderElectronicSignatureCertificate(ctx context.Context, signerName string, documentName string) (subject string, textBody string, htmlBody *string, err error) {
|
||||
vars, err := p.getCommonVariables(ctx)
|
||||
if err != nil {
|
||||
return "", "", nil, fmt.Errorf("cannot get common variables: %w", err)
|
||||
@@ -487,15 +523,15 @@ func (p *Presenter) RenderElectronicSignatureCertificate(ctx context.Context, si
|
||||
data := struct {
|
||||
*CommonVariables
|
||||
SignerName string
|
||||
DocumentType string
|
||||
DocumentName string
|
||||
}{
|
||||
CommonVariables: vars,
|
||||
SignerName: signerName,
|
||||
DocumentType: documentType,
|
||||
DocumentName: documentName,
|
||||
}
|
||||
|
||||
textBody, htmlBody, err = renderEmail(electronicSignatureCertificateTextTemplate, electronicSignatureCertificateHTMLTemplate, data)
|
||||
return fmt.Sprintf(subjectElectronicSignatureCertificate, documentType), textBody, htmlBody, err
|
||||
return fmt.Sprintf(subjectElectronicSignatureCertificate, documentName), textBody, htmlBody, err
|
||||
}
|
||||
|
||||
func (p *Presenter) RenderMailingListSubscription(ctx context.Context, organizationName string, confirmURL string, unsubscribeURL string) (subject string, textBody string, htmlBody *string, err error) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as React from "react";
|
||||
import MailingListUpdates from "../src/MailingListUpdates";
|
||||
import ConfirmEmail from "../src/ConfirmEmail";
|
||||
import DocumentExport from "../src/DocumentExport";
|
||||
import DocumentApproval from "../src/DocumentApproval";
|
||||
import DocumentSigning from "../src/DocumentSigning";
|
||||
import FrameworkExport from "../src/FrameworkExport";
|
||||
import Invitation from "../src/Invitation";
|
||||
@@ -39,6 +40,10 @@ const templates: TemplateConfig[] = [
|
||||
name: "invitation",
|
||||
render: () => Invitation(),
|
||||
},
|
||||
{
|
||||
name: "document-approval",
|
||||
render: () => DocumentApproval(),
|
||||
},
|
||||
{
|
||||
name: "document-signing",
|
||||
render: () => DocumentSigning(),
|
||||
|
||||
36
packages/emails/src/DocumentApproval.tsx
Normal file
36
packages/emails/src/DocumentApproval.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Button, Section, Text } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
import EmailLayout, { bodyText, button, buttonContainer, footerText } from './components/EmailLayout';
|
||||
|
||||
export const DocumentApproval = () => {
|
||||
return (
|
||||
<EmailLayout subject={'Action Required – Please review and approve {{.DocumentName}}'}>
|
||||
<Text style={bodyText}>
|
||||
You're receiving this message because <strong>{'{{.OrganizationName}}'}</strong> has requested your approval on the document <strong>{'{{.DocumentName}}'}</strong>.
|
||||
</Text>
|
||||
<Text style={bodyText}>
|
||||
Please take a moment to review the document and approve or reject it by clicking the button below:
|
||||
</Text>
|
||||
|
||||
<Section style={buttonContainer}>
|
||||
<Button style={button} href={'{{.ApprovalUrl}}'}>
|
||||
Review and Approve
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Text style={bodyText}>
|
||||
If you have any questions, please contact your security team.
|
||||
</Text>
|
||||
|
||||
<Text style={footerText}>
|
||||
This process is managed securely by Probo, acting as the compliance partner on behalf of <strong>{'{{.OrganizationName}}'}</strong>.
|
||||
Thank you for your prompt attention to this matter.
|
||||
</Text>
|
||||
<Text style={footerText}>
|
||||
Best regards,
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentApproval;
|
||||
@@ -8,10 +8,10 @@ import EmailLayout, {
|
||||
export const ElectronicSignatureCertificate = () => {
|
||||
return (
|
||||
<EmailLayout
|
||||
subject={`Your signed ${"{{.DocumentType}}"} — Certificate of Completion`}
|
||||
subject={`Your signed ${"{{.DocumentName}}"} — Certificate of Completion`}
|
||||
>
|
||||
<Text style={bodyText}>
|
||||
Your <strong>{"{{.DocumentType}}"}</strong> has been signed
|
||||
Your <strong>{"{{.DocumentName}}"}</strong> has been signed
|
||||
electronically. A Certificate of Completion is attached to this email
|
||||
as a PDF document.
|
||||
</Text>
|
||||
|
||||
20
packages/emails/templates/document-approval.txt
Normal file
20
packages/emails/templates/document-approval.txt
Normal file
@@ -0,0 +1,20 @@
|
||||
Probo
|
||||
|
||||
Hi {{.RecipientFullName}},
|
||||
|
||||
You're receiving this message because {{.OrganizationName}} has requested your approval on the document {{.DocumentName}}.
|
||||
|
||||
Please take a moment to review the document and approve or reject it by clicking the link below:
|
||||
|
||||
{{.ApprovalUrl}}
|
||||
|
||||
If you have any questions, please contact your security team.
|
||||
|
||||
This process is managed securely by Probo, acting as the compliance partner on behalf of {{.OrganizationName}}.
|
||||
|
||||
Thank you for your prompt attention to this matter.
|
||||
|
||||
Best regards,
|
||||
|
||||
{{.SenderCompanyHeadquarterAddress}}
|
||||
Powered By Probo
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Hi {{.RecipientFullName}},
|
||||
|
||||
Your {{.DocumentType}} has been signed electronically. A Certificate of Completion is attached to this email as a PDF document.
|
||||
Your {{.DocumentName}} has been signed electronically. A Certificate of Completion is attached to this email as a PDF document.
|
||||
|
||||
The certificate contains a complete record of the signing event, including the integrity seal, timestamp, and audit trail.
|
||||
|
||||
|
||||
@@ -61,11 +61,46 @@ func (p Document) CursorKey(orderBy DocumentOrderField) page.CursorKey {
|
||||
|
||||
// AuthorizationAttributes returns the authorization attributes for policy evaluation.
|
||||
func (d *Document) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (map[string]string, error) {
|
||||
q := `SELECT organization_id, status FROM documents WHERE id = $1 LIMIT 1;`
|
||||
q := `
|
||||
WITH document AS (
|
||||
SELECT id, organization_id, status
|
||||
FROM documents
|
||||
WHERE id = $1
|
||||
LIMIT 1
|
||||
),
|
||||
latest_version AS (
|
||||
SELECT dv.id, dv.document_id, dv.status AS version_status
|
||||
FROM document_versions dv
|
||||
INNER JOIN document ON dv.document_id = document.id
|
||||
ORDER BY dv.created_at DESC
|
||||
LIMIT 1
|
||||
),
|
||||
last_quorum AS (
|
||||
SELECT
|
||||
lv.document_id,
|
||||
q.status::text AS status
|
||||
FROM document_version_approval_quorums q
|
||||
INNER JOIN latest_version lv ON lv.id = q.version_id
|
||||
ORDER BY q.created_at DESC
|
||||
LIMIT 1
|
||||
)
|
||||
SELECT
|
||||
document.organization_id,
|
||||
document.status,
|
||||
COALESCE(lv.version_status::text, ''),
|
||||
COALESCE(lq.status, '')
|
||||
FROM document
|
||||
LEFT JOIN latest_version lv ON lv.document_id = document.id
|
||||
LEFT JOIN last_quorum lq ON lq.document_id = document.id;
|
||||
`
|
||||
|
||||
var organizationID gid.GID
|
||||
var documentStatus DocumentStatus
|
||||
if err := conn.QueryRow(ctx, q, d.ID).Scan(&organizationID, &documentStatus); err != nil {
|
||||
var (
|
||||
organizationID gid.GID
|
||||
documentStatus DocumentStatus
|
||||
latestVersionStatus string
|
||||
lastQuorumStatus string
|
||||
)
|
||||
if err := conn.QueryRow(ctx, q, d.ID).Scan(&organizationID, &documentStatus, &latestVersionStatus, &lastQuorumStatus); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrResourceNotFound
|
||||
}
|
||||
@@ -73,8 +108,10 @@ func (d *Document) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (m
|
||||
}
|
||||
|
||||
return map[string]string{
|
||||
"organization_id": organizationID.String(),
|
||||
"document_status": documentStatus.String(),
|
||||
"organization_id": organizationID.String(),
|
||||
"document_status": documentStatus.String(),
|
||||
"version_status": latestVersionStatus,
|
||||
"last_quorum_status": lastQuorumStatus,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -888,3 +925,56 @@ SELECT EXISTS (
|
||||
|
||||
return signed, nil
|
||||
}
|
||||
|
||||
func (p *Document) GetViewerApprovalStateForLastVersion(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
documentID gid.GID,
|
||||
identityID gid.GID,
|
||||
) (DocumentVersionApprovalDecisionState, error) {
|
||||
q := `
|
||||
WITH viewer_decision AS (
|
||||
SELECT
|
||||
dvad.tenant_id,
|
||||
dvad.state,
|
||||
dv.version_number,
|
||||
dvaq.created_at AS quorum_created_at
|
||||
FROM documents d
|
||||
INNER JOIN document_versions dv ON dv.document_id = d.id
|
||||
INNER JOIN document_version_approval_quorums dvaq ON dvaq.version_id = dv.id
|
||||
INNER JOIN document_version_approval_decisions dvad ON dvad.quorum_id = dvaq.id
|
||||
INNER JOIN iam_membership_profiles p ON dvad.approver_id = p.id
|
||||
WHERE d.id = @document_id
|
||||
AND p.identity_id = @identity_id
|
||||
)
|
||||
SELECT state
|
||||
FROM viewer_decision
|
||||
WHERE %s
|
||||
ORDER BY version_number DESC, quorum_created_at DESC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"document_id": documentID,
|
||||
"identity_id": identityID,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot query document approval state: %w", err)
|
||||
}
|
||||
|
||||
state, err := pgx.CollectOneRow(rows, pgx.RowTo[DocumentVersionApprovalDecisionState])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return DocumentVersionApprovalDecisionStatePending, nil
|
||||
}
|
||||
return "", fmt.Errorf("cannot collect approval state: %w", err)
|
||||
}
|
||||
|
||||
return state, nil
|
||||
}
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
// Copyright (c) 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 (
|
||||
DocumentApprover struct {
|
||||
DocumentID gid.GID `db:"document_id"`
|
||||
ApproverProfileID gid.GID `db:"approver_profile_id"`
|
||||
OrganizationID gid.GID `db:"organization_id"`
|
||||
TenantID gid.TenantID `db:"tenant_id"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
}
|
||||
|
||||
DocumentApprovers []*DocumentApprover
|
||||
)
|
||||
|
||||
func (da DocumentApprover) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
INSERT INTO
|
||||
document_approvers (
|
||||
document_id,
|
||||
approver_profile_id,
|
||||
organization_id,
|
||||
tenant_id,
|
||||
created_at
|
||||
)
|
||||
VALUES (
|
||||
@document_id,
|
||||
@approver_profile_id,
|
||||
@organization_id,
|
||||
@tenant_id,
|
||||
@created_at
|
||||
)
|
||||
ON CONFLICT (document_id, approver_profile_id) DO NOTHING;
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"document_id": da.DocumentID,
|
||||
"approver_profile_id": da.ApproverProfileID,
|
||||
"organization_id": da.OrganizationID,
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"created_at": da.CreatedAt,
|
||||
}
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot insert document approver: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (da *DocumentApprovers) LoadByDocumentID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
documentID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
document_id,
|
||||
approver_profile_id,
|
||||
organization_id,
|
||||
tenant_id,
|
||||
created_at
|
||||
FROM
|
||||
document_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 approvers: %w", err)
|
||||
}
|
||||
|
||||
approvers, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[DocumentApprover])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect document approvers: %w", err)
|
||||
}
|
||||
|
||||
*da = approvers
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (da *DocumentApprovers) DeleteByDocumentID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
documentID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
DELETE FROM
|
||||
document_approvers
|
||||
WHERE
|
||||
%s
|
||||
AND document_id = @document_id
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"document_id": documentID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot delete document approvers: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (da *DocumentApprovers) ApproverProfileIDs() []gid.GID {
|
||||
ids := make([]gid.GID, len(*da))
|
||||
for i, a := range *da {
|
||||
ids[i] = a.ApproverProfileID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -16,6 +16,7 @@ package coredata
|
||||
|
||||
import (
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/mail"
|
||||
)
|
||||
|
||||
@@ -25,6 +26,7 @@ type (
|
||||
trustCenterVisibilities []TrustCenterVisibility
|
||||
published *bool
|
||||
userEmail *mail.Addr
|
||||
approverIdentityID *gid.GID
|
||||
documentTypes []DocumentType
|
||||
status []DocumentStatus
|
||||
}
|
||||
@@ -58,6 +60,11 @@ func (f *DocumentFilter) WithUserEmail(userEmail *mail.Addr) *DocumentFilter {
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *DocumentFilter) WithApproverIdentityID(identityID *gid.GID) *DocumentFilter {
|
||||
f.approverIdentityID = identityID
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *DocumentFilter) WithDocumentTypes(documentTypes []DocumentType) *DocumentFilter {
|
||||
f.documentTypes = documentTypes
|
||||
return f
|
||||
@@ -98,6 +105,7 @@ func (f *DocumentFilter) SQLArguments() pgx.NamedArgs {
|
||||
"trust_center_visibilities": visibilities,
|
||||
"published": f.published,
|
||||
"user_email": f.userEmail,
|
||||
"approver_identity_id": f.approverIdentityID,
|
||||
"document_types": documentTypes,
|
||||
"document_status": status,
|
||||
}
|
||||
@@ -142,6 +150,19 @@ func (f *DocumentFilter) SQLFragment() string {
|
||||
)
|
||||
END
|
||||
AND
|
||||
CASE
|
||||
WHEN @approver_identity_id::text IS NULL THEN TRUE
|
||||
ELSE EXISTS (
|
||||
SELECT 1
|
||||
FROM document_versions dv
|
||||
INNER JOIN document_version_approval_quorums dvaq ON dvaq.version_id = dv.id
|
||||
INNER JOIN document_version_approval_decisions dvad ON dvad.quorum_id = dvaq.id
|
||||
INNER JOIN iam_membership_profiles p ON dvad.approver_id = p.id
|
||||
WHERE dv.document_id = documents.id
|
||||
AND p.identity_id = @approver_identity_id::text
|
||||
)
|
||||
END
|
||||
AND
|
||||
CASE
|
||||
WHEN @document_types::document_type[] IS NOT NULL THEN
|
||||
document_type = ANY(@document_types::document_type[])
|
||||
|
||||
@@ -50,18 +50,44 @@ type (
|
||||
// AuthorizationAttributes returns the authorization attributes for policy evaluation.
|
||||
func (dv *DocumentVersion) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (map[string]string, error) {
|
||||
q := `
|
||||
WITH document_version AS (
|
||||
SELECT id, document_id, organization_id, status AS version_status
|
||||
FROM document_versions
|
||||
WHERE id = $1
|
||||
LIMIT 1
|
||||
),
|
||||
document AS (
|
||||
SELECT d.id, d.status
|
||||
FROM documents d
|
||||
INNER JOIN document_version ON d.id = document_version.document_id
|
||||
),
|
||||
last_quorum AS (
|
||||
SELECT
|
||||
q.version_id,
|
||||
q.status::text AS status
|
||||
FROM document_version_approval_quorums q
|
||||
INNER JOIN document_version ON q.version_id = document_version.id
|
||||
ORDER BY q.created_at DESC
|
||||
LIMIT 1
|
||||
)
|
||||
SELECT
|
||||
dv.organization_id,
|
||||
d.status
|
||||
FROM document_versions dv
|
||||
INNER JOIN documents d ON d.id = dv.document_id
|
||||
WHERE dv.id = $1
|
||||
LIMIT 1;
|
||||
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 organizationID gid.GID
|
||||
var documentStatus DocumentStatus
|
||||
if err := conn.QueryRow(ctx, q, dv.ID).Scan(&organizationID, &documentStatus); err != nil {
|
||||
var (
|
||||
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 errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrResourceNotFound
|
||||
}
|
||||
@@ -69,8 +95,10 @@ LIMIT 1;
|
||||
}
|
||||
|
||||
return map[string]string{
|
||||
"organization_id": organizationID.String(),
|
||||
"document_status": documentStatus.String(),
|
||||
"organization_id": organizationID.String(),
|
||||
"document_status": documentStatus.String(),
|
||||
"version_status": documentVersionStatus.String(),
|
||||
"last_quorum_status": lastQuorumStatus,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
458
pkg/coredata/document_version_approval_decision.go
Normal file
458
pkg/coredata/document_version_approval_decision.go
Normal file
@@ -0,0 +1,458 @@
|
||||
// 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"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
)
|
||||
|
||||
type (
|
||||
DocumentVersionApprovalDecision struct {
|
||||
ID gid.GID `db:"id"`
|
||||
OrganizationID gid.GID `db:"organization_id"`
|
||||
QuorumID gid.GID `db:"quorum_id"`
|
||||
ApproverID gid.GID `db:"approver_id"`
|
||||
State DocumentVersionApprovalDecisionState `db:"state"`
|
||||
Comment *string `db:"comment"`
|
||||
ElectronicSignatureID *gid.GID `db:"electronic_signature_id"`
|
||||
DecidedAt *time.Time `db:"decided_at"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
DocumentVersionApprovalDecisions []*DocumentVersionApprovalDecision
|
||||
)
|
||||
|
||||
func (d DocumentVersionApprovalDecision) CursorKey(orderBy DocumentVersionApprovalDecisionOrderField) page.CursorKey {
|
||||
switch orderBy {
|
||||
case DocumentVersionApprovalDecisionOrderFieldCreatedAt:
|
||||
return page.NewCursorKey(d.ID, d.CreatedAt)
|
||||
}
|
||||
|
||||
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
|
||||
}
|
||||
|
||||
func (d *DocumentVersionApprovalDecision) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (map[string]string, error) {
|
||||
q := `SELECT organization_id FROM document_version_approval_decisions WHERE id = $1 LIMIT 1;`
|
||||
|
||||
var organizationID gid.GID
|
||||
if err := conn.QueryRow(ctx, q, d.ID).Scan(&organizationID); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrResourceNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("cannot query document version approval decision authorization attributes: %w", err)
|
||||
}
|
||||
|
||||
return map[string]string{"organization_id": organizationID.String()}, nil
|
||||
}
|
||||
|
||||
func (d *DocumentVersionApprovalDecision) LoadByID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
id gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
quorum_id,
|
||||
approver_id,
|
||||
state,
|
||||
comment,
|
||||
electronic_signature_id,
|
||||
decided_at,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
document_version_approval_decisions
|
||||
WHERE
|
||||
id = @id
|
||||
AND %s
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"id": id}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query document version approval decision: %w", err)
|
||||
}
|
||||
|
||||
decision, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[DocumentVersionApprovalDecision])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
return fmt.Errorf("cannot collect document version approval decision: %w", err)
|
||||
}
|
||||
|
||||
*d = decision
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DocumentVersionApprovalDecision) LoadByQuorumIDAndApproverID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
quorumID gid.GID,
|
||||
approverID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
quorum_id,
|
||||
approver_id,
|
||||
state,
|
||||
comment,
|
||||
electronic_signature_id,
|
||||
decided_at,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
document_version_approval_decisions
|
||||
WHERE
|
||||
%s
|
||||
AND quorum_id = @quorum_id
|
||||
AND approver_id = @approver_id
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"quorum_id": quorumID,
|
||||
"approver_id": approverID,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query document version approval decision: %w", err)
|
||||
}
|
||||
|
||||
decision, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[DocumentVersionApprovalDecision])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
return fmt.Errorf("cannot collect document version approval decision: %w", err)
|
||||
}
|
||||
|
||||
*d = decision
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DocumentVersionApprovalDecisions) CountApprovedByQuorumID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
quorumID gid.GID,
|
||||
) (int, error) {
|
||||
q := `
|
||||
SELECT
|
||||
COUNT(id)
|
||||
FROM
|
||||
document_version_approval_decisions
|
||||
WHERE
|
||||
%s
|
||||
AND quorum_id = @quorum_id
|
||||
AND state = 'APPROVED'
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"quorum_id": quorumID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
row := conn.QueryRow(ctx, q, args)
|
||||
var count int
|
||||
if err := row.Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("cannot scan count: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (d *DocumentVersionApprovalDecisions) LoadByQuorumID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
quorumID gid.GID,
|
||||
cursor *page.Cursor[DocumentVersionApprovalDecisionOrderField],
|
||||
filter *DocumentVersionApprovalDecisionFilter,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
quorum_id,
|
||||
approver_id,
|
||||
state,
|
||||
comment,
|
||||
electronic_signature_id,
|
||||
decided_at,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
document_version_approval_decisions
|
||||
WHERE
|
||||
%s
|
||||
AND quorum_id = @quorum_id
|
||||
AND %s
|
||||
AND %s
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"quorum_id": quorumID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
maps.Copy(args, filter.SQLArguments())
|
||||
maps.Copy(args, cursor.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query document version approval decisions: %w", err)
|
||||
}
|
||||
|
||||
decisions, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[DocumentVersionApprovalDecision])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect document version approval decisions: %w", err)
|
||||
}
|
||||
|
||||
*d = decisions
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DocumentVersionApprovalDecision) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
INSERT INTO document_version_approval_decisions (
|
||||
id,
|
||||
tenant_id,
|
||||
organization_id,
|
||||
quorum_id,
|
||||
approver_id,
|
||||
state,
|
||||
comment,
|
||||
electronic_signature_id,
|
||||
decided_at,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
@id,
|
||||
@tenant_id,
|
||||
@organization_id,
|
||||
@quorum_id,
|
||||
@approver_id,
|
||||
@state,
|
||||
@comment,
|
||||
@electronic_signature_id,
|
||||
@decided_at,
|
||||
@created_at,
|
||||
@updated_at
|
||||
)
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": d.ID,
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"organization_id": d.OrganizationID,
|
||||
"quorum_id": d.QuorumID,
|
||||
"approver_id": d.ApproverID,
|
||||
"state": d.State,
|
||||
"comment": d.Comment,
|
||||
"electronic_signature_id": d.ElectronicSignatureID,
|
||||
"decided_at": d.DecidedAt,
|
||||
"created_at": d.CreatedAt,
|
||||
"updated_at": d.UpdatedAt,
|
||||
}
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) {
|
||||
if pgErr.Code == "23505" {
|
||||
return ErrResourceAlreadyExists
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("cannot insert document version approval decision: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ds DocumentVersionApprovalDecisions) BulkInsert(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
) error {
|
||||
if len(ds) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
rows := make([][]any, 0, len(ds))
|
||||
for _, d := range ds {
|
||||
rows = append(rows, []any{
|
||||
d.ID,
|
||||
scope.GetTenantID(),
|
||||
d.OrganizationID,
|
||||
d.QuorumID,
|
||||
d.ApproverID,
|
||||
d.State,
|
||||
d.Comment,
|
||||
d.ElectronicSignatureID,
|
||||
d.DecidedAt,
|
||||
d.CreatedAt,
|
||||
d.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
_, err := conn.CopyFrom(
|
||||
ctx,
|
||||
pgx.Identifier{"document_version_approval_decisions"},
|
||||
[]string{
|
||||
"id",
|
||||
"tenant_id",
|
||||
"organization_id",
|
||||
"quorum_id",
|
||||
"approver_id",
|
||||
"state",
|
||||
"comment",
|
||||
"electronic_signature_id",
|
||||
"decided_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
},
|
||||
pgx.CopyFromRows(rows),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DocumentVersionApprovalDecision) Update(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
UPDATE document_version_approval_decisions
|
||||
SET
|
||||
state = @state,
|
||||
comment = @comment,
|
||||
electronic_signature_id = @electronic_signature_id,
|
||||
decided_at = @decided_at,
|
||||
updated_at = @updated_at
|
||||
WHERE
|
||||
%s
|
||||
AND id = @id
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": d.ID,
|
||||
"state": d.State,
|
||||
"comment": d.Comment,
|
||||
"electronic_signature_id": d.ElectronicSignatureID,
|
||||
"decided_at": d.DecidedAt,
|
||||
"updated_at": d.UpdatedAt,
|
||||
}
|
||||
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot update document version approval decision: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DocumentVersionApprovalDecision) Delete(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
DELETE FROM document_version_approval_decisions
|
||||
WHERE
|
||||
%s
|
||||
AND id = @id
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"id": d.ID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot delete document version approval decision: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DocumentVersionApprovalDecisions) CountByQuorumID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
quorumID gid.GID,
|
||||
filter *DocumentVersionApprovalDecisionFilter,
|
||||
) (int, error) {
|
||||
q := `
|
||||
SELECT
|
||||
COUNT(id)
|
||||
FROM
|
||||
document_version_approval_decisions
|
||||
WHERE
|
||||
%s
|
||||
AND quorum_id = @quorum_id
|
||||
AND %s
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"quorum_id": quorumID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
maps.Copy(args, filter.SQLArguments())
|
||||
|
||||
row := conn.QueryRow(ctx, q, args)
|
||||
var count int
|
||||
if err := row.Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("cannot scan count: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
51
pkg/coredata/document_version_approval_decision_filter.go
Normal file
51
pkg/coredata/document_version_approval_decision_filter.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// 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 (
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type (
|
||||
DocumentVersionApprovalDecisionFilter struct {
|
||||
states DocumentVersionApprovalDecisionStates
|
||||
}
|
||||
)
|
||||
|
||||
func NewDocumentVersionApprovalDecisionFilter(states []DocumentVersionApprovalDecisionState) *DocumentVersionApprovalDecisionFilter {
|
||||
if len(states) == 0 {
|
||||
states = nil
|
||||
}
|
||||
return &DocumentVersionApprovalDecisionFilter{
|
||||
states: DocumentVersionApprovalDecisionStates(states),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *DocumentVersionApprovalDecisionFilter) SQLArguments() pgx.StrictNamedArgs {
|
||||
return pgx.StrictNamedArgs{
|
||||
"filter_states": f.states,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *DocumentVersionApprovalDecisionFilter) SQLFragment() string {
|
||||
return `
|
||||
(
|
||||
CASE
|
||||
WHEN @filter_states::document_version_approval_decision_state[] IS NOT NULL THEN
|
||||
state = ANY(@filter_states::document_version_approval_decision_state[])
|
||||
ELSE TRUE
|
||||
END
|
||||
)`
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// 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 "fmt"
|
||||
|
||||
type (
|
||||
DocumentVersionApprovalDecisionOrderField string
|
||||
)
|
||||
|
||||
const (
|
||||
DocumentVersionApprovalDecisionOrderFieldCreatedAt DocumentVersionApprovalDecisionOrderField = "CREATED_AT"
|
||||
)
|
||||
|
||||
func (e DocumentVersionApprovalDecisionOrderField) Column() string {
|
||||
switch e {
|
||||
case DocumentVersionApprovalDecisionOrderFieldCreatedAt:
|
||||
return "created_at"
|
||||
}
|
||||
panic(fmt.Sprintf("unsupported order by: %s", e))
|
||||
}
|
||||
|
||||
func (e DocumentVersionApprovalDecisionOrderField) IsValid() bool {
|
||||
switch e {
|
||||
case DocumentVersionApprovalDecisionOrderFieldCreatedAt:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (e DocumentVersionApprovalDecisionOrderField) String() string { return string(e) }
|
||||
|
||||
func (e *DocumentVersionApprovalDecisionOrderField) UnmarshalText(text []byte) error {
|
||||
*e = DocumentVersionApprovalDecisionOrderField(text)
|
||||
if !e.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid DocumentVersionApprovalDecisionOrderField", string(text))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e DocumentVersionApprovalDecisionOrderField) MarshalText() ([]byte, error) {
|
||||
return []byte(e.String()), nil
|
||||
}
|
||||
100
pkg/coredata/document_version_approval_decision_state.go
Normal file
100
pkg/coredata/document_version_approval_decision_state.go
Normal file
@@ -0,0 +1,100 @@
|
||||
// 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 (
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type (
|
||||
DocumentVersionApprovalDecisionState string
|
||||
DocumentVersionApprovalDecisionStates []DocumentVersionApprovalDecisionState
|
||||
)
|
||||
|
||||
const (
|
||||
DocumentVersionApprovalDecisionStatePending DocumentVersionApprovalDecisionState = "PENDING"
|
||||
DocumentVersionApprovalDecisionStateApproved DocumentVersionApprovalDecisionState = "APPROVED"
|
||||
DocumentVersionApprovalDecisionStateRejected DocumentVersionApprovalDecisionState = "REJECTED"
|
||||
)
|
||||
|
||||
func (s DocumentVersionApprovalDecisionState) MarshalText() ([]byte, error) {
|
||||
return []byte(s.String()), nil
|
||||
}
|
||||
|
||||
func (s *DocumentVersionApprovalDecisionState) UnmarshalText(data []byte) error {
|
||||
val := string(data)
|
||||
|
||||
switch val {
|
||||
case DocumentVersionApprovalDecisionStatePending.String():
|
||||
*s = DocumentVersionApprovalDecisionStatePending
|
||||
case DocumentVersionApprovalDecisionStateApproved.String():
|
||||
*s = DocumentVersionApprovalDecisionStateApproved
|
||||
case DocumentVersionApprovalDecisionStateRejected.String():
|
||||
*s = DocumentVersionApprovalDecisionStateRejected
|
||||
default:
|
||||
return fmt.Errorf("invalid DocumentVersionApprovalDecisionState value: %q", val)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s DocumentVersionApprovalDecisionState) String() string {
|
||||
var val string
|
||||
|
||||
switch s {
|
||||
case DocumentVersionApprovalDecisionStatePending:
|
||||
val = "PENDING"
|
||||
case DocumentVersionApprovalDecisionStateApproved:
|
||||
val = "APPROVED"
|
||||
case DocumentVersionApprovalDecisionStateRejected:
|
||||
val = "REJECTED"
|
||||
default:
|
||||
panic(fmt.Errorf("invalid DocumentVersionApprovalDecisionState value: %q", string(s)))
|
||||
}
|
||||
|
||||
return val
|
||||
}
|
||||
|
||||
func (s *DocumentVersionApprovalDecisionState) Scan(value any) error {
|
||||
val, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid scan source for DocumentVersionApprovalDecisionState, expected string got %T", value)
|
||||
}
|
||||
|
||||
return s.UnmarshalText([]byte(val))
|
||||
}
|
||||
|
||||
func (s DocumentVersionApprovalDecisionState) Value() (driver.Value, error) {
|
||||
return s.String(), nil
|
||||
}
|
||||
|
||||
func (states DocumentVersionApprovalDecisionStates) Value() (driver.Value, error) {
|
||||
if len(states) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
result.WriteString("{")
|
||||
for i, state := range states {
|
||||
if i > 0 {
|
||||
result.WriteString(",")
|
||||
}
|
||||
fmt.Fprintf(&result, "%q", state.String())
|
||||
}
|
||||
result.WriteString("}")
|
||||
return result.String(), nil
|
||||
}
|
||||
336
pkg/coredata/document_version_approval_quorum.go
Normal file
336
pkg/coredata/document_version_approval_quorum.go
Normal file
@@ -0,0 +1,336 @@
|
||||
// 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"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
)
|
||||
|
||||
type (
|
||||
DocumentVersionApprovalQuorum struct {
|
||||
ID gid.GID `db:"id"`
|
||||
OrganizationID gid.GID `db:"organization_id"`
|
||||
VersionID gid.GID `db:"version_id"`
|
||||
Status DocumentVersionApprovalQuorumStatus `db:"status"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
DocumentVersionApprovalQuorums []*DocumentVersionApprovalQuorum
|
||||
)
|
||||
|
||||
func (q DocumentVersionApprovalQuorum) CursorKey(orderBy DocumentVersionApprovalQuorumOrderField) page.CursorKey {
|
||||
switch orderBy {
|
||||
case DocumentVersionApprovalQuorumOrderFieldCreatedAt:
|
||||
return page.NewCursorKey(q.ID, q.CreatedAt)
|
||||
}
|
||||
|
||||
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
|
||||
}
|
||||
|
||||
func (q *DocumentVersionApprovalQuorum) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (map[string]string, error) {
|
||||
query := `SELECT organization_id FROM document_version_approval_quorums WHERE id = $1 LIMIT 1;`
|
||||
|
||||
var organizationID gid.GID
|
||||
if err := conn.QueryRow(ctx, query, q.ID).Scan(&organizationID); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrResourceNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("cannot query approval quorum authorization attributes: %w", err)
|
||||
}
|
||||
|
||||
return map[string]string{"organization_id": organizationID.String()}, nil
|
||||
}
|
||||
|
||||
func (q *DocumentVersionApprovalQuorum) LoadByID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
id gid.GID,
|
||||
) error {
|
||||
query := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
version_id,
|
||||
status,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
document_version_approval_quorums
|
||||
WHERE
|
||||
id = @id
|
||||
AND %s
|
||||
`
|
||||
|
||||
query = fmt.Sprintf(query, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"id": id}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, query, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query approval quorum: %w", err)
|
||||
}
|
||||
|
||||
quorum, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[DocumentVersionApprovalQuorum])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
return fmt.Errorf("cannot collect approval quorum: %w", err)
|
||||
}
|
||||
|
||||
*q = quorum
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *DocumentVersionApprovalQuorum) LoadLastByDocumentVersionID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
documentVersionID gid.GID,
|
||||
) error {
|
||||
query := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
version_id,
|
||||
status,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
document_version_approval_quorums
|
||||
WHERE
|
||||
%s
|
||||
AND version_id = @version_id
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
query = fmt.Sprintf(query, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"version_id": documentVersionID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, query, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query last approval quorum: %w", err)
|
||||
}
|
||||
|
||||
quorum, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[DocumentVersionApprovalQuorum])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
return fmt.Errorf("cannot collect last approval quorum: %w", err)
|
||||
}
|
||||
|
||||
*q = quorum
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *DocumentVersionApprovalQuorums) LoadAllByDocumentVersionID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
documentVersionID gid.GID,
|
||||
cursor *page.Cursor[DocumentVersionApprovalQuorumOrderField],
|
||||
) error {
|
||||
query := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
version_id,
|
||||
status,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
document_version_approval_quorums
|
||||
WHERE
|
||||
%s
|
||||
AND version_id = @version_id
|
||||
AND %s
|
||||
`
|
||||
|
||||
query = fmt.Sprintf(query, scope.SQLFragment(), cursor.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"version_id": documentVersionID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
maps.Copy(args, cursor.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, query, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query approval quorums: %w", err)
|
||||
}
|
||||
|
||||
quorums, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[DocumentVersionApprovalQuorum])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect approval quorums: %w", err)
|
||||
}
|
||||
|
||||
*q = quorums
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *DocumentVersionApprovalQuorums) CountByDocumentVersionID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
documentVersionID gid.GID,
|
||||
) (int, error) {
|
||||
query := `
|
||||
SELECT
|
||||
COUNT(id)
|
||||
FROM
|
||||
document_version_approval_quorums
|
||||
WHERE
|
||||
%s
|
||||
AND version_id = @version_id
|
||||
`
|
||||
|
||||
query = fmt.Sprintf(query, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"version_id": documentVersionID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
row := conn.QueryRow(ctx, query, args)
|
||||
var count int
|
||||
if err := row.Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("cannot scan count: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (q *DocumentVersionApprovalQuorum) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
) error {
|
||||
query := `
|
||||
INSERT INTO document_version_approval_quorums (
|
||||
id,
|
||||
tenant_id,
|
||||
organization_id,
|
||||
version_id,
|
||||
status,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
@id,
|
||||
@tenant_id,
|
||||
@organization_id,
|
||||
@version_id,
|
||||
@status,
|
||||
@created_at,
|
||||
@updated_at
|
||||
)
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": q.ID,
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"organization_id": q.OrganizationID,
|
||||
"version_id": q.VersionID,
|
||||
"status": q.Status,
|
||||
"created_at": q.CreatedAt,
|
||||
"updated_at": q.UpdatedAt,
|
||||
}
|
||||
|
||||
_, err := conn.Exec(ctx, query, args)
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) {
|
||||
if pgErr.Code == "23505" {
|
||||
return ErrResourceAlreadyExists
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("cannot insert approval quorum: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *DocumentVersionApprovalQuorum) Delete(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
) error {
|
||||
query := `
|
||||
DELETE FROM document_version_approval_quorums
|
||||
WHERE
|
||||
%s
|
||||
AND id = @id
|
||||
`
|
||||
|
||||
query = fmt.Sprintf(query, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"id": q.ID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
_, err := conn.Exec(ctx, query, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot delete approval quorum: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *DocumentVersionApprovalQuorum) Update(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
) error {
|
||||
query := `
|
||||
UPDATE document_version_approval_quorums
|
||||
SET
|
||||
status = @status,
|
||||
updated_at = @updated_at
|
||||
WHERE
|
||||
%s
|
||||
AND id = @id
|
||||
`
|
||||
|
||||
query = fmt.Sprintf(query, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": q.ID,
|
||||
"status": q.Status,
|
||||
"updated_at": q.UpdatedAt,
|
||||
}
|
||||
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
_, err := conn.Exec(ctx, query, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot update approval quorum: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
55
pkg/coredata/document_version_approval_quorum_order_field.go
Normal file
55
pkg/coredata/document_version_approval_quorum_order_field.go
Normal file
@@ -0,0 +1,55 @@
|
||||
// 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 "fmt"
|
||||
|
||||
type (
|
||||
DocumentVersionApprovalQuorumOrderField string
|
||||
)
|
||||
|
||||
const (
|
||||
DocumentVersionApprovalQuorumOrderFieldCreatedAt DocumentVersionApprovalQuorumOrderField = "CREATED_AT"
|
||||
)
|
||||
|
||||
func (e DocumentVersionApprovalQuorumOrderField) Column() string {
|
||||
switch e {
|
||||
case DocumentVersionApprovalQuorumOrderFieldCreatedAt:
|
||||
return "created_at"
|
||||
}
|
||||
panic(fmt.Sprintf("unsupported order by: %s", e))
|
||||
}
|
||||
|
||||
func (e DocumentVersionApprovalQuorumOrderField) IsValid() bool {
|
||||
switch e {
|
||||
case DocumentVersionApprovalQuorumOrderFieldCreatedAt:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (e DocumentVersionApprovalQuorumOrderField) String() string { return string(e) }
|
||||
|
||||
func (e *DocumentVersionApprovalQuorumOrderField) UnmarshalText(text []byte) error {
|
||||
*e = DocumentVersionApprovalQuorumOrderField(text)
|
||||
if !e.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid DocumentVersionApprovalQuorumOrderField", string(text))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e DocumentVersionApprovalQuorumOrderField) MarshalText() ([]byte, error) {
|
||||
return []byte(e.String()), nil
|
||||
}
|
||||
79
pkg/coredata/document_version_approval_quorum_status.go
Normal file
79
pkg/coredata/document_version_approval_quorum_status.go
Normal file
@@ -0,0 +1,79 @@
|
||||
// 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 (
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type DocumentVersionApprovalQuorumStatus string
|
||||
|
||||
const (
|
||||
DocumentVersionApprovalQuorumStatusPending DocumentVersionApprovalQuorumStatus = "PENDING"
|
||||
DocumentVersionApprovalQuorumStatusApproved DocumentVersionApprovalQuorumStatus = "APPROVED"
|
||||
DocumentVersionApprovalQuorumStatusRejected DocumentVersionApprovalQuorumStatus = "REJECTED"
|
||||
)
|
||||
|
||||
func (s DocumentVersionApprovalQuorumStatus) MarshalText() ([]byte, error) {
|
||||
return []byte(s.String()), nil
|
||||
}
|
||||
|
||||
func (s *DocumentVersionApprovalQuorumStatus) UnmarshalText(data []byte) error {
|
||||
val := string(data)
|
||||
|
||||
switch val {
|
||||
case DocumentVersionApprovalQuorumStatusPending.String():
|
||||
*s = DocumentVersionApprovalQuorumStatusPending
|
||||
case DocumentVersionApprovalQuorumStatusApproved.String():
|
||||
*s = DocumentVersionApprovalQuorumStatusApproved
|
||||
case DocumentVersionApprovalQuorumStatusRejected.String():
|
||||
*s = DocumentVersionApprovalQuorumStatusRejected
|
||||
default:
|
||||
return fmt.Errorf("invalid DocumentVersionApprovalQuorumStatus value: %q", val)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s DocumentVersionApprovalQuorumStatus) String() string {
|
||||
var val string
|
||||
|
||||
switch s {
|
||||
case DocumentVersionApprovalQuorumStatusPending:
|
||||
val = "PENDING"
|
||||
case DocumentVersionApprovalQuorumStatusApproved:
|
||||
val = "APPROVED"
|
||||
case DocumentVersionApprovalQuorumStatusRejected:
|
||||
val = "REJECTED"
|
||||
default:
|
||||
panic(fmt.Errorf("invalid DocumentVersionApprovalQuorumStatus value: %q", string(s)))
|
||||
}
|
||||
|
||||
return val
|
||||
}
|
||||
|
||||
func (s *DocumentVersionApprovalQuorumStatus) Scan(value any) error {
|
||||
val, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid scan source for DocumentVersionApprovalQuorumStatus, expected string got %T", value)
|
||||
}
|
||||
|
||||
return s.UnmarshalText([]byte(val))
|
||||
}
|
||||
|
||||
func (s DocumentVersionApprovalQuorumStatus) Value() (driver.Value, error) {
|
||||
return s.String(), nil
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
// Copyright (c) 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 (
|
||||
DocumentVersionApprover struct {
|
||||
DocumentVersionID gid.GID `db:"document_version_id"`
|
||||
ApproverProfileID gid.GID `db:"approver_profile_id"`
|
||||
OrganizationID gid.GID `db:"organization_id"`
|
||||
TenantID gid.TenantID `db:"tenant_id"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
}
|
||||
|
||||
DocumentVersionApprovers []*DocumentVersionApprover
|
||||
)
|
||||
|
||||
func (dva DocumentVersionApprover) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
INSERT INTO
|
||||
document_version_approvers (
|
||||
document_version_id,
|
||||
approver_profile_id,
|
||||
organization_id,
|
||||
tenant_id,
|
||||
created_at
|
||||
)
|
||||
VALUES (
|
||||
@document_version_id,
|
||||
@approver_profile_id,
|
||||
@organization_id,
|
||||
@tenant_id,
|
||||
@created_at
|
||||
)
|
||||
ON CONFLICT (document_version_id, approver_profile_id) DO NOTHING;
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"document_version_id": dva.DocumentVersionID,
|
||||
"approver_profile_id": dva.ApproverProfileID,
|
||||
"organization_id": dva.OrganizationID,
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"created_at": dva.CreatedAt,
|
||||
}
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot insert document version approver: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dva *DocumentVersionApprovers) LoadByDocumentVersionID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
documentVersionID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
document_version_id,
|
||||
approver_profile_id,
|
||||
organization_id,
|
||||
tenant_id,
|
||||
created_at
|
||||
FROM
|
||||
document_version_approvers
|
||||
WHERE
|
||||
%s
|
||||
AND document_version_id = @document_version_id
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"document_version_id": documentVersionID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query document version approvers: %w", err)
|
||||
}
|
||||
|
||||
approvers, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[DocumentVersionApprover])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect document version approvers: %w", err)
|
||||
}
|
||||
|
||||
*dva = approvers
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dva *DocumentVersionApprovers) DeleteByDocumentVersionID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
documentVersionID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
DELETE FROM
|
||||
document_version_approvers
|
||||
WHERE
|
||||
%s
|
||||
AND document_version_id = @document_version_id
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"document_version_id": documentVersionID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot delete document version approvers: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dva *DocumentVersionApprovers) ApproverProfileIDs() []gid.GID {
|
||||
ids := make([]gid.GID, len(*dva))
|
||||
for i, a := range *dva {
|
||||
ids[i] = a.ApproverProfileID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -16,12 +16,15 @@ package coredata
|
||||
|
||||
import (
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/mail"
|
||||
)
|
||||
|
||||
type (
|
||||
DocumentVersionFilter struct {
|
||||
userEmail *mail.Addr
|
||||
statuses []DocumentVersionStatus
|
||||
userEmail *mail.Addr
|
||||
approverIdentityID *gid.GID
|
||||
}
|
||||
)
|
||||
|
||||
@@ -29,29 +32,65 @@ func NewDocumentVersionFilter() *DocumentVersionFilter {
|
||||
return &DocumentVersionFilter{}
|
||||
}
|
||||
|
||||
func (f *DocumentVersionFilter) WithStatuses(statuses ...DocumentVersionStatus) *DocumentVersionFilter {
|
||||
f.statuses = statuses
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *DocumentVersionFilter) WithUserEmail(userEmail *mail.Addr) *DocumentVersionFilter {
|
||||
f.userEmail = userEmail
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *DocumentVersionFilter) WithApproverIdentityID(identityID *gid.GID) *DocumentVersionFilter {
|
||||
f.approverIdentityID = identityID
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *DocumentVersionFilter) SQLArguments() pgx.StrictNamedArgs {
|
||||
var filterStatuses []string
|
||||
for _, s := range f.statuses {
|
||||
filterStatuses = append(filterStatuses, s.String())
|
||||
}
|
||||
|
||||
return pgx.StrictNamedArgs{
|
||||
"user_email": f.userEmail,
|
||||
"filter_statuses": filterStatuses,
|
||||
"user_email": f.userEmail,
|
||||
"approver_identity_id": f.approverIdentityID,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *DocumentVersionFilter) SQLFragment() string {
|
||||
return `
|
||||
(
|
||||
@user_email::text IS NULL
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM document_version_signatures dvs
|
||||
INNER JOIN iam_membership_profiles p ON dvs.signed_by_profile_id = p.id
|
||||
INNER JOIN identities i ON p.identity_id = i.id
|
||||
WHERE dvs.document_version_id = document_versions.id
|
||||
AND i.email_address = @user_email::CITEXT
|
||||
AND dvs.state IN ('REQUESTED', 'SIGNED')
|
||||
(
|
||||
@filter_statuses::text[] IS NULL
|
||||
OR document_versions.status::text = ANY(@filter_statuses::text[])
|
||||
)
|
||||
AND
|
||||
(
|
||||
@user_email::text IS NULL
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM document_version_signatures dvs
|
||||
INNER JOIN iam_membership_profiles p ON dvs.signed_by_profile_id = p.id
|
||||
INNER JOIN identities i ON p.identity_id = i.id
|
||||
WHERE dvs.document_version_id = document_versions.id
|
||||
AND i.email_address = @user_email::CITEXT
|
||||
AND dvs.state IN ('REQUESTED', 'SIGNED')
|
||||
)
|
||||
)
|
||||
AND
|
||||
(
|
||||
@approver_identity_id::text IS NULL
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM document_version_approval_quorums dvaq
|
||||
INNER JOIN document_version_approval_decisions dvad ON dvad.quorum_id = dvaq.id
|
||||
INNER JOIN iam_membership_profiles p ON dvad.approver_id = p.id
|
||||
WHERE dvaq.version_id = document_versions.id
|
||||
AND p.identity_id = @approver_identity_id::text
|
||||
)
|
||||
)
|
||||
)`
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ type ElectronicSignature struct {
|
||||
OrganizationID gid.GID `db:"organization_id"`
|
||||
Status ElectronicSignatureStatus `db:"status"`
|
||||
DocumentType ElectronicSignatureDocumentType `db:"document_type"`
|
||||
DocumentName *string `db:"document_name"`
|
||||
FileID gid.GID `db:"file_id"`
|
||||
SignerEmail string `db:"signer_email"`
|
||||
ConsentText string `db:"consent_text"`
|
||||
@@ -83,11 +84,11 @@ func (es *ElectronicSignature) Insert(
|
||||
) error {
|
||||
q := `
|
||||
INSERT INTO electronic_signatures (
|
||||
id, tenant_id, organization_id, status, document_type, file_id,
|
||||
id, tenant_id, organization_id, status, document_type, document_name, file_id,
|
||||
signer_email, consent_text, seal_version, attempt_count, max_attempts,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
@id, @tenant_id, @organization_id, @status, @document_type, @file_id,
|
||||
@id, @tenant_id, @organization_id, @status, @document_type, @document_name, @file_id,
|
||||
@signer_email, @consent_text, @seal_version, @attempt_count, @max_attempts,
|
||||
@created_at, @updated_at
|
||||
)
|
||||
@@ -98,6 +99,7 @@ INSERT INTO electronic_signatures (
|
||||
"organization_id": es.OrganizationID,
|
||||
"status": es.Status,
|
||||
"document_type": es.DocumentType,
|
||||
"document_name": es.DocumentName,
|
||||
"file_id": es.FileID,
|
||||
"signer_email": es.SignerEmail,
|
||||
"consent_text": es.ConsentText,
|
||||
@@ -184,7 +186,7 @@ func (es *ElectronicSignature) LoadByID(
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id, tenant_id, organization_id, status, document_type, file_id,
|
||||
id, tenant_id, organization_id, status, document_type, document_name, file_id,
|
||||
signer_email, consent_text, signer_full_name, signer_ip_address,
|
||||
signer_user_agent, file_hash, seal, seal_version, tsa_token, signed_at,
|
||||
certificate_file_id, certificate_processing_started_at,
|
||||
@@ -222,7 +224,7 @@ func (es *ElectronicSignature) LoadNextAcceptedForUpdateSkipLocked(
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id, tenant_id, organization_id, status, document_type, file_id,
|
||||
id, tenant_id, organization_id, status, document_type, document_name, file_id,
|
||||
signer_email, consent_text, signer_full_name, signer_ip_address,
|
||||
signer_user_agent, file_hash, seal, seal_version, tsa_token, signed_at,
|
||||
certificate_file_id, certificate_processing_started_at,
|
||||
@@ -258,7 +260,7 @@ func (es *ElectronicSignature) LoadNextCompletedWithoutCertificateForUpdate(
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id, tenant_id, organization_id, status, document_type, file_id,
|
||||
id, tenant_id, organization_id, status, document_type, document_name, file_id,
|
||||
signer_email, consent_text, signer_full_name, signer_ip_address,
|
||||
signer_user_agent, file_hash, seal, seal_version, tsa_token, signed_at,
|
||||
certificate_file_id, certificate_processing_started_at,
|
||||
|
||||
@@ -31,6 +31,14 @@ const (
|
||||
ElectronicSignatureDocumentTypeSLA ElectronicSignatureDocumentType = "SLA"
|
||||
ElectronicSignatureDocumentTypeTOS ElectronicSignatureDocumentType = "TOS"
|
||||
ElectronicSignatureDocumentTypePrivacyPolicy ElectronicSignatureDocumentType = "PRIVACY_POLICY"
|
||||
ElectronicSignatureDocumentTypeGovernance ElectronicSignatureDocumentType = "GOVERNANCE"
|
||||
ElectronicSignatureDocumentTypePolicy ElectronicSignatureDocumentType = "POLICY"
|
||||
ElectronicSignatureDocumentTypeProcedure ElectronicSignatureDocumentType = "PROCEDURE"
|
||||
ElectronicSignatureDocumentTypePlan ElectronicSignatureDocumentType = "PLAN"
|
||||
ElectronicSignatureDocumentTypeRegister ElectronicSignatureDocumentType = "REGISTER"
|
||||
ElectronicSignatureDocumentTypeRecord ElectronicSignatureDocumentType = "RECORD"
|
||||
ElectronicSignatureDocumentTypeReport ElectronicSignatureDocumentType = "REPORT"
|
||||
ElectronicSignatureDocumentTypeTemplate ElectronicSignatureDocumentType = "TEMPLATE"
|
||||
ElectronicSignatureDocumentTypeOther ElectronicSignatureDocumentType = "OTHER"
|
||||
|
||||
ESignProcessConsentText = "By typing my full name and clicking Accept, I consent to sign this document electronically and agree that my electronic signature has the same legal validity as a handwritten signature."
|
||||
@@ -45,6 +53,14 @@ func ElectronicSignatureDocumentTypes() []ElectronicSignatureDocumentType {
|
||||
ElectronicSignatureDocumentTypeSLA,
|
||||
ElectronicSignatureDocumentTypeTOS,
|
||||
ElectronicSignatureDocumentTypePrivacyPolicy,
|
||||
ElectronicSignatureDocumentTypeGovernance,
|
||||
ElectronicSignatureDocumentTypePolicy,
|
||||
ElectronicSignatureDocumentTypeProcedure,
|
||||
ElectronicSignatureDocumentTypePlan,
|
||||
ElectronicSignatureDocumentTypeRegister,
|
||||
ElectronicSignatureDocumentTypeRecord,
|
||||
ElectronicSignatureDocumentTypeReport,
|
||||
ElectronicSignatureDocumentTypeTemplate,
|
||||
ElectronicSignatureDocumentTypeOther,
|
||||
}
|
||||
}
|
||||
@@ -71,10 +87,26 @@ func (dt *ElectronicSignatureDocumentType) UnmarshalText(data []byte) error {
|
||||
*dt = ElectronicSignatureDocumentTypeTOS
|
||||
case ElectronicSignatureDocumentTypePrivacyPolicy.String():
|
||||
*dt = ElectronicSignatureDocumentTypePrivacyPolicy
|
||||
case ElectronicSignatureDocumentTypeGovernance.String():
|
||||
*dt = ElectronicSignatureDocumentTypeGovernance
|
||||
case ElectronicSignatureDocumentTypePolicy.String():
|
||||
*dt = ElectronicSignatureDocumentTypePolicy
|
||||
case ElectronicSignatureDocumentTypeProcedure.String():
|
||||
*dt = ElectronicSignatureDocumentTypeProcedure
|
||||
case ElectronicSignatureDocumentTypePlan.String():
|
||||
*dt = ElectronicSignatureDocumentTypePlan
|
||||
case ElectronicSignatureDocumentTypeRegister.String():
|
||||
*dt = ElectronicSignatureDocumentTypeRegister
|
||||
case ElectronicSignatureDocumentTypeRecord.String():
|
||||
*dt = ElectronicSignatureDocumentTypeRecord
|
||||
case ElectronicSignatureDocumentTypeReport.String():
|
||||
*dt = ElectronicSignatureDocumentTypeReport
|
||||
case ElectronicSignatureDocumentTypeTemplate.String():
|
||||
*dt = ElectronicSignatureDocumentTypeTemplate
|
||||
case ElectronicSignatureDocumentTypeOther.String():
|
||||
*dt = ElectronicSignatureDocumentTypeOther
|
||||
default:
|
||||
return fmt.Errorf("invalid ElectronicSignatureDocumentType value: %q", val)
|
||||
return fmt.Errorf("cannot unmarshal ElectronicSignatureDocumentType: invalid value %q", val)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -87,7 +119,7 @@ func (dt ElectronicSignatureDocumentType) String() string {
|
||||
func (dt *ElectronicSignatureDocumentType) Scan(value any) error {
|
||||
val, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid scan source for ElectronicSignatureDocumentType, expected string got %T", value)
|
||||
return fmt.Errorf("cannot scan ElectronicSignatureDocumentType: expected string, got %T", value)
|
||||
}
|
||||
|
||||
return dt.UnmarshalText([]byte(val))
|
||||
@@ -113,6 +145,22 @@ func (dt ElectronicSignatureDocumentType) DisplayName() string {
|
||||
return "Terms of Service"
|
||||
case ElectronicSignatureDocumentTypePrivacyPolicy:
|
||||
return "Privacy Policy"
|
||||
case ElectronicSignatureDocumentTypeGovernance:
|
||||
return "Governance Document"
|
||||
case ElectronicSignatureDocumentTypePolicy:
|
||||
return "Policy"
|
||||
case ElectronicSignatureDocumentTypeProcedure:
|
||||
return "Procedure"
|
||||
case ElectronicSignatureDocumentTypePlan:
|
||||
return "Plan"
|
||||
case ElectronicSignatureDocumentTypeRegister:
|
||||
return "Register"
|
||||
case ElectronicSignatureDocumentTypeRecord:
|
||||
return "Record"
|
||||
case ElectronicSignatureDocumentTypeReport:
|
||||
return "Report"
|
||||
case ElectronicSignatureDocumentTypeTemplate:
|
||||
return "Template"
|
||||
default:
|
||||
return string(dt)
|
||||
}
|
||||
@@ -135,11 +183,50 @@ func (dt ElectronicSignatureDocumentType) ConsentText() (string, error) {
|
||||
docAgreement = "I agree to these Terms of Service."
|
||||
case ElectronicSignatureDocumentTypePrivacyPolicy:
|
||||
docAgreement = "I agree to this Privacy Policy."
|
||||
case ElectronicSignatureDocumentTypeGovernance:
|
||||
docAgreement = "I acknowledge and agree to this Governance Document."
|
||||
case ElectronicSignatureDocumentTypePolicy:
|
||||
docAgreement = "I acknowledge and agree to this Policy."
|
||||
case ElectronicSignatureDocumentTypeProcedure:
|
||||
docAgreement = "I acknowledge and agree to this Procedure."
|
||||
case ElectronicSignatureDocumentTypePlan:
|
||||
docAgreement = "I acknowledge and agree to this Plan."
|
||||
case ElectronicSignatureDocumentTypeRegister:
|
||||
docAgreement = "I acknowledge and agree to this Register."
|
||||
case ElectronicSignatureDocumentTypeRecord:
|
||||
docAgreement = "I acknowledge and agree to this Record."
|
||||
case ElectronicSignatureDocumentTypeReport:
|
||||
docAgreement = "I acknowledge and agree to this Report."
|
||||
case ElectronicSignatureDocumentTypeTemplate:
|
||||
docAgreement = "I acknowledge and agree to this Template."
|
||||
case ElectronicSignatureDocumentTypeOther:
|
||||
return "", fmt.Errorf("document type OTHER requires explicit consent text")
|
||||
return "", fmt.Errorf("cannot get consent text: document type OTHER requires explicit consent text")
|
||||
default:
|
||||
return "", fmt.Errorf("unknown document type %q", dt)
|
||||
return "", fmt.Errorf("cannot get consent text: unknown document type %q", dt)
|
||||
}
|
||||
|
||||
return docAgreement + " " + ESignProcessConsentText, nil
|
||||
}
|
||||
|
||||
func ElectronicSignatureDocumentTypeFromDocumentType(dt DocumentType) ElectronicSignatureDocumentType {
|
||||
switch dt {
|
||||
case DocumentTypeGovernance:
|
||||
return ElectronicSignatureDocumentTypeGovernance
|
||||
case DocumentTypePolicy:
|
||||
return ElectronicSignatureDocumentTypePolicy
|
||||
case DocumentTypeProcedure:
|
||||
return ElectronicSignatureDocumentTypeProcedure
|
||||
case DocumentTypePlan:
|
||||
return ElectronicSignatureDocumentTypePlan
|
||||
case DocumentTypeRegister:
|
||||
return ElectronicSignatureDocumentTypeRegister
|
||||
case DocumentTypeRecord:
|
||||
return ElectronicSignatureDocumentTypeRecord
|
||||
case DocumentTypeReport:
|
||||
return ElectronicSignatureDocumentTypeReport
|
||||
case DocumentTypeTemplate:
|
||||
return ElectronicSignatureDocumentTypeTemplate
|
||||
default:
|
||||
return ElectronicSignatureDocumentTypeOther
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,8 @@ const (
|
||||
MailingListUpdateEntityType uint16 = 66
|
||||
FindingEntityType uint16 = 67
|
||||
AuditLogEntryEntityType uint16 = 68
|
||||
DocumentVersionApprovalQuorumEntityType uint16 = 69
|
||||
DocumentVersionApprovalDecisionEntityType uint16 = 70
|
||||
)
|
||||
|
||||
func NewEntityFromID(id gid.GID) (any, bool) {
|
||||
@@ -226,6 +228,10 @@ func NewEntityFromID(id gid.GID) (any, bool) {
|
||||
return &MailingListUpdate{ID: id}, true
|
||||
case AuditLogEntryEntityType:
|
||||
return &AuditLogEntry{ID: id}, true
|
||||
case DocumentVersionApprovalDecisionEntityType:
|
||||
return &DocumentVersionApprovalDecision{ID: id}, true
|
||||
case DocumentVersionApprovalQuorumEntityType:
|
||||
return &DocumentVersionApprovalQuorum{ID: id}, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
|
||||
@@ -559,150 +559,6 @@ WHERE
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *MembershipProfiles) LoadByDocumentID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
documentID gid.GID,
|
||||
cursor *page.Cursor[MembershipProfileOrderField],
|
||||
) error {
|
||||
q := `
|
||||
WITH profiles AS (
|
||||
SELECT
|
||||
mp.id,
|
||||
mp.identity_id,
|
||||
mp.organization_id,
|
||||
mp.source,
|
||||
mp.state,
|
||||
mp.full_name,
|
||||
mp.kind,
|
||||
mp.additional_email_addresses,
|
||||
mp.position,
|
||||
mp.contract_start_date,
|
||||
mp.contract_end_date,
|
||||
mp.user_name,
|
||||
mp.external_id,
|
||||
mp.nickname,
|
||||
mp.locale,
|
||||
mp.timezone,
|
||||
mp.profile_url,
|
||||
mp.preferred_language,
|
||||
mp.given_name,
|
||||
mp.family_name,
|
||||
mp.formatted_name,
|
||||
mp.middle_name,
|
||||
mp.honorific_prefix,
|
||||
mp.honorific_suffix,
|
||||
mp.employee_number,
|
||||
mp.department,
|
||||
mp.cost_center,
|
||||
mp.enterprise_organization,
|
||||
mp.division,
|
||||
mp.manager_value,
|
||||
mp.created_at,
|
||||
mp.updated_at
|
||||
FROM
|
||||
iam_membership_profiles mp
|
||||
WHERE
|
||||
mp.%s
|
||||
AND mp.id IN (
|
||||
SELECT approver_profile_id
|
||||
FROM document_approvers
|
||||
WHERE document_id = @document_id
|
||||
)
|
||||
AND %s
|
||||
)
|
||||
SELECT
|
||||
p.id,
|
||||
p.identity_id,
|
||||
p.organization_id,
|
||||
i.email_address,
|
||||
p.source,
|
||||
p.state,
|
||||
p.full_name,
|
||||
p.kind,
|
||||
p.additional_email_addresses,
|
||||
p.position,
|
||||
p.contract_start_date,
|
||||
p.contract_end_date,
|
||||
'' AS organization_name,
|
||||
p.user_name,
|
||||
p.external_id,
|
||||
p.nickname,
|
||||
p.locale,
|
||||
p.timezone,
|
||||
p.profile_url,
|
||||
p.preferred_language,
|
||||
p.given_name,
|
||||
p.family_name,
|
||||
p.formatted_name,
|
||||
p.middle_name,
|
||||
p.honorific_prefix,
|
||||
p.honorific_suffix,
|
||||
p.employee_number,
|
||||
p.department,
|
||||
p.cost_center,
|
||||
p.enterprise_organization,
|
||||
p.division,
|
||||
p.manager_value,
|
||||
p.created_at,
|
||||
p.updated_at
|
||||
FROM profiles p
|
||||
INNER JOIN identities i ON i.id = p.identity_id
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
|
||||
|
||||
args := pgx.NamedArgs{"document_id": documentID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
maps.Copy(args, cursor.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query document approver profiles: %w", err)
|
||||
}
|
||||
|
||||
profiles, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[MembershipProfile])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect document approver profiles: %w", err)
|
||||
}
|
||||
|
||||
*p = profiles
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *MembershipProfiles) CountByDocumentID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
documentID gid.GID,
|
||||
) (int, error) {
|
||||
q := `
|
||||
SELECT
|
||||
COUNT(*)
|
||||
FROM
|
||||
iam_membership_profiles mp
|
||||
INNER JOIN document_approvers da ON mp.id = da.approver_profile_id
|
||||
WHERE
|
||||
mp.%s
|
||||
AND da.document_id = @document_id
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"document_id": documentID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
var count int
|
||||
err := conn.QueryRow(ctx, q, args).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot query document approver profiles count: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (p *MembershipProfiles) LoadByDocumentVersionID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
@@ -711,7 +567,19 @@ func (p *MembershipProfiles) LoadByDocumentVersionID(
|
||||
cursor *page.Cursor[MembershipProfileOrderField],
|
||||
) error {
|
||||
q := `
|
||||
WITH profiles AS (
|
||||
WITH latest_quorum AS (
|
||||
SELECT id
|
||||
FROM document_version_approval_quorums
|
||||
WHERE version_id = @version_id
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
),
|
||||
version_approvers AS (
|
||||
SELECT d.approver_id
|
||||
FROM document_version_approval_decisions d
|
||||
WHERE d.quorum_id = (SELECT id FROM latest_quorum)
|
||||
),
|
||||
profiles AS (
|
||||
SELECT
|
||||
mp.id,
|
||||
mp.identity_id,
|
||||
@@ -747,13 +615,9 @@ WITH profiles AS (
|
||||
mp.updated_at
|
||||
FROM
|
||||
iam_membership_profiles mp
|
||||
INNER JOIN version_approvers va ON va.approver_id = mp.id
|
||||
WHERE
|
||||
mp.%s
|
||||
AND mp.id IN (
|
||||
SELECT approver_profile_id
|
||||
FROM document_version_approvers
|
||||
WHERE document_version_id = @document_version_id
|
||||
)
|
||||
AND %s
|
||||
)
|
||||
SELECT
|
||||
@@ -797,7 +661,7 @@ INNER JOIN identities i ON i.id = p.identity_id
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
|
||||
|
||||
args := pgx.NamedArgs{"document_version_id": documentVersionID}
|
||||
args := pgx.NamedArgs{"version_id": documentVersionID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
maps.Copy(args, cursor.SQLArguments())
|
||||
|
||||
@@ -823,19 +687,26 @@ func (p *MembershipProfiles) CountByDocumentVersionID(
|
||||
documentVersionID gid.GID,
|
||||
) (int, error) {
|
||||
q := `
|
||||
WITH latest_quorum AS (
|
||||
SELECT id
|
||||
FROM document_version_approval_quorums
|
||||
WHERE version_id = @version_id
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
)
|
||||
SELECT
|
||||
COUNT(*)
|
||||
COUNT(DISTINCT mp.id)
|
||||
FROM
|
||||
iam_membership_profiles mp
|
||||
INNER JOIN document_version_approvers dva ON mp.id = dva.approver_profile_id
|
||||
INNER JOIN document_version_approval_decisions dvad ON mp.id = dvad.approver_id
|
||||
INNER JOIN latest_quorum lq ON lq.id = dvad.quorum_id
|
||||
WHERE
|
||||
mp.%s
|
||||
AND dva.document_version_id = @document_version_id
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"document_version_id": documentVersionID}
|
||||
args := pgx.StrictNamedArgs{"version_id": documentVersionID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
var count int
|
||||
|
||||
95
pkg/coredata/migrations/20260319T160000Z.sql
Normal file
95
pkg/coredata/migrations/20260319T160000Z.sql
Normal file
@@ -0,0 +1,95 @@
|
||||
-- Create approval quorum status enum
|
||||
CREATE TYPE document_version_approval_quorum_status AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
|
||||
|
||||
-- Create approval decision state enum
|
||||
CREATE TYPE document_version_approval_decision_state AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
|
||||
|
||||
-- Create approval quorum table: groups approval decisions for a document version
|
||||
CREATE TABLE document_version_approval_quorums (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
organization_id TEXT NOT NULL,
|
||||
version_id TEXT NOT NULL REFERENCES document_versions(id) ON DELETE CASCADE,
|
||||
status document_version_approval_quorum_status NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL
|
||||
);
|
||||
|
||||
-- Ensure only one PENDING quorum per document version
|
||||
CREATE UNIQUE INDEX document_one_pending_quorum_idx
|
||||
ON document_version_approval_quorums (version_id)
|
||||
WHERE status = 'PENDING';
|
||||
|
||||
-- Create approval decision table: one row per approver per quorum
|
||||
CREATE TABLE document_version_approval_decisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
organization_id TEXT NOT NULL,
|
||||
quorum_id TEXT NOT NULL REFERENCES document_version_approval_quorums(id) ON DELETE CASCADE,
|
||||
approver_id TEXT NOT NULL REFERENCES iam_membership_profiles(id) ON UPDATE CASCADE ON DELETE RESTRICT,
|
||||
state document_version_approval_decision_state NOT NULL,
|
||||
comment TEXT,
|
||||
electronic_signature_id TEXT REFERENCES electronic_signatures(id) ON DELETE RESTRICT,
|
||||
decided_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
UNIQUE (quorum_id, approver_id)
|
||||
);
|
||||
|
||||
-- Add document types to electronic_signature_document_type enum
|
||||
ALTER TYPE electronic_signature_document_type ADD VALUE 'GOVERNANCE';
|
||||
ALTER TYPE electronic_signature_document_type ADD VALUE 'POLICY';
|
||||
ALTER TYPE electronic_signature_document_type ADD VALUE 'PROCEDURE';
|
||||
ALTER TYPE electronic_signature_document_type ADD VALUE 'PLAN';
|
||||
ALTER TYPE electronic_signature_document_type ADD VALUE 'REGISTER';
|
||||
ALTER TYPE electronic_signature_document_type ADD VALUE 'RECORD';
|
||||
ALTER TYPE electronic_signature_document_type ADD VALUE 'REPORT';
|
||||
ALTER TYPE electronic_signature_document_type ADD VALUE 'TEMPLATE';
|
||||
|
||||
-- Add document_name to electronic_signatures for email subject
|
||||
ALTER TABLE electronic_signatures ADD COLUMN document_name TEXT;
|
||||
|
||||
-- Backfill: create APPROVED quorums for existing published versions that have approvers
|
||||
INSERT INTO document_version_approval_quorums (id, tenant_id, organization_id, version_id, status, created_at, updated_at)
|
||||
SELECT DISTINCT
|
||||
generate_gid(decode_base64_unpadded(dv.tenant_id), 69),
|
||||
dv.tenant_id,
|
||||
dv.organization_id,
|
||||
dv.id,
|
||||
'APPROVED'::document_version_approval_quorum_status,
|
||||
dv.published_at,
|
||||
dv.published_at
|
||||
FROM document_versions dv
|
||||
WHERE dv.status = 'PUBLISHED'
|
||||
AND dv.published_at IS NOT NULL
|
||||
AND (
|
||||
EXISTS (SELECT 1 FROM document_version_approvers dva WHERE dva.document_version_id = dv.id)
|
||||
OR EXISTS (SELECT 1 FROM document_approvers da WHERE da.document_id = dv.document_id)
|
||||
);
|
||||
|
||||
-- Backfill: create APPROVED decisions linked to the quorums
|
||||
INSERT INTO document_version_approval_decisions (
|
||||
id, tenant_id, organization_id, quorum_id,
|
||||
approver_id, state, decided_at, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
generate_gid(decode_base64_unpadded(dv.tenant_id), 70),
|
||||
dv.tenant_id,
|
||||
dv.organization_id,
|
||||
q.id,
|
||||
COALESCE(dva.approver_profile_id, da.approver_profile_id),
|
||||
'APPROVED'::document_version_approval_decision_state,
|
||||
dv.published_at,
|
||||
dv.published_at,
|
||||
dv.published_at
|
||||
FROM document_versions dv
|
||||
JOIN document_version_approval_quorums q ON q.version_id = dv.id
|
||||
LEFT JOIN document_version_approvers dva ON dva.document_version_id = dv.id
|
||||
LEFT JOIN document_approvers da ON da.document_id = dv.document_id
|
||||
AND dva.approver_profile_id IS NULL
|
||||
WHERE dv.status = 'PUBLISHED'
|
||||
AND COALESCE(dva.approver_profile_id, da.approver_profile_id) IS NOT NULL
|
||||
ON CONFLICT (quorum_id, approver_id) DO NOTHING;
|
||||
|
||||
-- TODO: DROP TABLE document_version_approvers once confirmed safe
|
||||
-- TODO: DROP TABLE document_approvers once confirmed safe
|
||||
@@ -386,6 +386,7 @@
|
||||
<span class="classification">{{.Classification | classificationString}}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{{- if gt (len .Approvers) 0}}
|
||||
<tr>
|
||||
<td>Approver{{- if gt (len .Approvers) 1}}s{{- end}}</td>
|
||||
<td>
|
||||
@@ -400,6 +401,7 @@
|
||||
{{- end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{- end}}
|
||||
<tr>
|
||||
<td>Version:</td>
|
||||
<td>{{.Version}}</td>
|
||||
|
||||
@@ -315,8 +315,11 @@ func (w *CompletionCertificateWorker) generateCertificate(
|
||||
}
|
||||
emailPresenter := emails.NewPresenterFromConfig(w.fileManager, presenterCfg, ref.UnrefOrZero(signature.SignerFullName))
|
||||
|
||||
docTypeName := signature.DocumentType.DisplayName()
|
||||
subject, textBody, htmlBody, err := emailPresenter.RenderElectronicSignatureCertificate(ctx, ref.UnrefOrZero(signature.SignerFullName), docTypeName)
|
||||
docName := ref.UnrefOrZero(signature.DocumentName)
|
||||
if docName == "" {
|
||||
docName = signature.DocumentType.DisplayName()
|
||||
}
|
||||
subject, textBody, htmlBody, err := emailPresenter.RenderElectronicSignatureCertificate(ctx, ref.UnrefOrZero(signature.SignerFullName), docName)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("cannot render email: %w", err)
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ type (
|
||||
CreateSignatureRequest struct {
|
||||
OrganizationID gid.GID
|
||||
DocumentType coredata.ElectronicSignatureDocumentType
|
||||
DocumentName *string
|
||||
FileID gid.GID
|
||||
SignerEmail mail.Addr
|
||||
ConsentText string // optional; required when DocumentType == OTHER
|
||||
@@ -61,6 +62,18 @@ type (
|
||||
SignerUA string
|
||||
}
|
||||
|
||||
CreateAndAcceptSignatureRequest struct {
|
||||
OrganizationID gid.GID
|
||||
DocumentType coredata.ElectronicSignatureDocumentType
|
||||
DocumentName *string
|
||||
FileID gid.GID
|
||||
SignerEmail mail.Addr
|
||||
SignerFullName string
|
||||
SignerIPAddr string
|
||||
SignerUA string
|
||||
ConsentText string
|
||||
}
|
||||
|
||||
RecordEventRequest struct {
|
||||
SignatureID gid.GID
|
||||
EventType coredata.ElectronicSignatureEventType
|
||||
@@ -161,6 +174,7 @@ func (s *Service) CreateSignature(
|
||||
OrganizationID: req.OrganizationID,
|
||||
Status: coredata.ElectronicSignatureStatusPending,
|
||||
DocumentType: req.DocumentType,
|
||||
DocumentName: req.DocumentName,
|
||||
FileID: stampedFileID,
|
||||
SignerEmail: req.SignerEmail.String(),
|
||||
ConsentText: consentText,
|
||||
@@ -178,6 +192,59 @@ func (s *Service) CreateSignature(
|
||||
return sig, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateAndAcceptSignature(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
req *CreateAndAcceptSignatureRequest,
|
||||
) (*coredata.ElectronicSignature, error) {
|
||||
sig, err := s.CreateSignature(
|
||||
ctx,
|
||||
conn,
|
||||
&CreateSignatureRequest{
|
||||
OrganizationID: req.OrganizationID,
|
||||
DocumentType: req.DocumentType,
|
||||
DocumentName: req.DocumentName,
|
||||
FileID: req.FileID,
|
||||
SignerEmail: req.SignerEmail,
|
||||
ConsentText: req.ConsentText,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create signature: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
scope := coredata.NewScopeFromObjectID(req.OrganizationID)
|
||||
|
||||
sig.SignerFullName = &req.SignerFullName
|
||||
sig.SignerIPAddress = &req.SignerIPAddr
|
||||
sig.SignerUserAgent = &req.SignerUA
|
||||
sig.SignedAt = &now
|
||||
sig.Status = coredata.ElectronicSignatureStatusAccepted
|
||||
sig.UpdatedAt = now
|
||||
|
||||
if err := sig.Update(ctx, conn, scope); err != nil {
|
||||
return nil, fmt.Errorf("cannot accept signature: %w", err)
|
||||
}
|
||||
|
||||
if err := s.recordEvent(
|
||||
ctx,
|
||||
conn,
|
||||
&RecordEventRequest{
|
||||
SignatureID: sig.ID,
|
||||
EventType: coredata.ElectronicSignatureEventTypeSignatureAccepted,
|
||||
EventSource: coredata.ElectronicSignatureEventSourceServer,
|
||||
ActorEmail: req.SignerEmail,
|
||||
ActorIPAddr: req.SignerIPAddr,
|
||||
ActorUA: req.SignerUA,
|
||||
},
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("cannot record signature event: %w", err)
|
||||
}
|
||||
|
||||
return sig, nil
|
||||
}
|
||||
|
||||
func (s *Service) createStampedDocument(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
|
||||
@@ -187,15 +187,21 @@ const (
|
||||
ActionDocumentSendSigningNotifications = "core:document:send-signing-notifications"
|
||||
|
||||
// DocumentVersion actions
|
||||
ActionDocumentVersionGet = "core:document-version:get"
|
||||
ActionDocumentVersionList = "core:document-version:list"
|
||||
ActionDocumentVersionExportPDF = "core:document-version:export-pdf"
|
||||
ActionDocumentVersionExportSignable = "core:document-version:export-signable-pdf"
|
||||
ActionDocumentVersionSign = "core:document-version:sign"
|
||||
ActionDocumentVersionUpdate = "core:document-version:update"
|
||||
ActionDocumentVersionDeleteDraft = "core:document-version:delete-draft"
|
||||
ActionDocumentVersionPublish = "core:document-version:publish"
|
||||
ActionDocumentVersionExport = "core:document-version:export"
|
||||
ActionDocumentVersionGet = "core:document-version:get"
|
||||
ActionDocumentVersionList = "core:document-version:list"
|
||||
ActionDocumentVersionExportPDF = "core:document-version:export-pdf"
|
||||
ActionDocumentVersionExportSignable = "core:document-version:export-signable-pdf"
|
||||
ActionDocumentVersionSign = "core:document-version:sign"
|
||||
ActionDocumentVersionUpdate = "core:document-version:update"
|
||||
ActionDocumentVersionDeleteDraft = "core:document-version:delete-draft"
|
||||
ActionDocumentVersionRequestApproval = "core:document-version:request-approval"
|
||||
ActionDocumentVersionApprove = "core:document-version:approve"
|
||||
ActionDocumentVersionReject = "core:document-version:reject"
|
||||
ActionDocumentVersionApprovalList = "core:document-version:approval-list"
|
||||
ActionDocumentVersionAddApprover = "core:document-version:add-approver"
|
||||
ActionDocumentVersionRemoveApprover = "core:document-version:remove-approver"
|
||||
ActionDocumentVersionPublish = "core:document-version:publish"
|
||||
ActionDocumentVersionExport = "core:document-version:export"
|
||||
|
||||
// DocumentVersionSignature actions
|
||||
ActionDocumentVersionSignatureRequest = "core:document-version-signature:request"
|
||||
|
||||
985
pkg/probo/document_approval_service.go
Normal file
985
pkg/probo/document_approval_service.go
Normal file
@@ -0,0 +1,985 @@
|
||||
// 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 probo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"net/url"
|
||||
|
||||
"go.gearno.de/crypto/uuid"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/packages/emails"
|
||||
"go.probo.inc/probo/pkg/baseurl"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/esign"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/html2pdf"
|
||||
"go.probo.inc/probo/pkg/iam"
|
||||
"go.probo.inc/probo/pkg/mail"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
"go.probo.inc/probo/pkg/statelesstoken"
|
||||
"go.probo.inc/probo/pkg/validator"
|
||||
)
|
||||
|
||||
type (
|
||||
DocumentApprovalService struct {
|
||||
svc *TenantService
|
||||
html2pdfConverter *html2pdf.Converter
|
||||
invitationTokenValidity time.Duration
|
||||
tokenSecret string
|
||||
}
|
||||
|
||||
ErrDocumentVersionNotPendingApproval struct{}
|
||||
|
||||
ErrApprovalDecisionAlreadyMade struct{}
|
||||
|
||||
RequestApprovalRequest struct {
|
||||
DocumentID gid.GID
|
||||
ApproverIDs []gid.GID
|
||||
Changelog *string
|
||||
}
|
||||
|
||||
ApproveDocumentVersionRequest struct {
|
||||
DocumentVersionID gid.GID
|
||||
IdentityID gid.GID
|
||||
Comment *string
|
||||
SignerFullName string
|
||||
SignerEmail mail.Addr
|
||||
SignerIPAddr string
|
||||
SignerUA string
|
||||
}
|
||||
|
||||
RejectDocumentVersionRequest struct {
|
||||
DocumentVersionID gid.GID
|
||||
IdentityID gid.GID
|
||||
Comment *string
|
||||
}
|
||||
)
|
||||
|
||||
func (e ErrDocumentVersionNotPendingApproval) Error() string {
|
||||
return "document version is not pending approval"
|
||||
}
|
||||
func (e ErrApprovalDecisionAlreadyMade) Error() string {
|
||||
return "approval decision has already been made"
|
||||
}
|
||||
|
||||
func (req *RequestApprovalRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(req.DocumentID, "document_id", validator.Required(), validator.GID(coredata.DocumentEntityType))
|
||||
v.Check(req.ApproverIDs, "approver_ids", validator.Required())
|
||||
v.Check(len(req.ApproverIDs), "approver_ids", validator.Max(100))
|
||||
v.CheckEach(req.ApproverIDs, "approver_ids", func(_ int, item any) {
|
||||
v.Check(item, "approver_ids", validator.GID(coredata.MembershipProfileEntityType))
|
||||
})
|
||||
v.Check(req.Changelog, "changelog", validator.Required(), validator.SafeText(5000))
|
||||
|
||||
return v.Error()
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) RequestApproval(
|
||||
ctx context.Context,
|
||||
req RequestApprovalRequest,
|
||||
) (*coredata.DocumentVersionApprovalQuorum, error) {
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var quorum *coredata.DocumentVersionApprovalQuorum
|
||||
|
||||
err := s.svc.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
document := &coredata.Document{}
|
||||
if err := document.LoadByID(ctx, tx, s.svc.scope, req.DocumentID); err != nil {
|
||||
return fmt.Errorf("cannot load document: %w", err)
|
||||
}
|
||||
|
||||
if document.ArchivedAt != nil {
|
||||
return &ErrDocumentArchived{}
|
||||
}
|
||||
|
||||
documentVersion, err := s.loadLatestVersion(ctx, tx, req.DocumentID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot load latest version: %w", err)
|
||||
}
|
||||
|
||||
if documentVersion.Status == coredata.DocumentVersionStatusPublished {
|
||||
return fmt.Errorf("cannot request approval for a published document")
|
||||
}
|
||||
|
||||
if err := s.rejectPendingQuorum(ctx, tx, documentVersion.ID); err != nil {
|
||||
return fmt.Errorf("cannot reject pending quorum: %w", err)
|
||||
}
|
||||
|
||||
organization := &coredata.Organization{}
|
||||
if err := organization.LoadByID(ctx, tx, s.svc.scope, document.OrganizationID); err != nil {
|
||||
return fmt.Errorf("cannot load organization: %w", err)
|
||||
}
|
||||
|
||||
approverProfiles := &coredata.MembershipProfiles{}
|
||||
if err := approverProfiles.LoadByIDs(ctx, tx, s.svc.scope, req.ApproverIDs); err != nil {
|
||||
return fmt.Errorf("cannot load approver profiles: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
if req.Changelog != nil {
|
||||
documentVersion.Changelog = *req.Changelog
|
||||
documentVersion.UpdatedAt = now
|
||||
if err := documentVersion.Update(ctx, tx, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot update document version changelog: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
quorum = &coredata.DocumentVersionApprovalQuorum{
|
||||
ID: gid.New(s.svc.scope.GetTenantID(), coredata.DocumentVersionApprovalQuorumEntityType),
|
||||
OrganizationID: document.OrganizationID,
|
||||
VersionID: documentVersion.ID,
|
||||
Status: coredata.DocumentVersionApprovalQuorumStatusPending,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := quorum.Insert(ctx, tx, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot insert approval quorum: %w", err)
|
||||
}
|
||||
|
||||
if err := s.createDecisions(ctx, tx, quorum, document.OrganizationID, req.ApproverIDs, now); err != nil {
|
||||
return fmt.Errorf("cannot create approval decisions: %w", err)
|
||||
}
|
||||
|
||||
if err := s.sendApprovalEmails(ctx, tx, *approverProfiles, document, organization, documentVersion.ID); err != nil {
|
||||
return fmt.Errorf("cannot send approval emails: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return quorum, nil
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) Approve(
|
||||
ctx context.Context,
|
||||
req ApproveDocumentVersionRequest,
|
||||
) (*coredata.DocumentVersionApprovalDecision, error) {
|
||||
var (
|
||||
documentVersion *coredata.DocumentVersion
|
||||
document *coredata.Document
|
||||
quorum *coredata.DocumentVersionApprovalQuorum
|
||||
decision *coredata.DocumentVersionApprovalDecision
|
||||
)
|
||||
|
||||
err := s.svc.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
documentVersion = &coredata.DocumentVersion{}
|
||||
if err := documentVersion.LoadByID(ctx, conn, s.svc.scope, req.DocumentVersionID); err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
var profile *coredata.MembershipProfile
|
||||
var err error
|
||||
quorum, profile, err = s.loadQuorumAndProfile(ctx, conn, req.DocumentVersionID, req.IdentityID, documentVersion.OrganizationID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot load quorum and profile: %w", err)
|
||||
}
|
||||
|
||||
if quorum.Status != coredata.DocumentVersionApprovalQuorumStatusPending {
|
||||
return &ErrDocumentVersionNotPendingApproval{}
|
||||
}
|
||||
|
||||
decision = &coredata.DocumentVersionApprovalDecision{}
|
||||
if err := decision.LoadByQuorumIDAndApproverID(ctx, conn, s.svc.scope, quorum.ID, profile.ID); err != nil {
|
||||
return fmt.Errorf("cannot load approval decision: %w", err)
|
||||
}
|
||||
|
||||
if decision.State != coredata.DocumentVersionApprovalDecisionStatePending {
|
||||
return &ErrApprovalDecisionAlreadyMade{}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
pdfData, err := s.generateApprovalPDF(ctx, req.DocumentVersionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot export document PDF: %w", err)
|
||||
}
|
||||
|
||||
fileRecord := &coredata.File{
|
||||
ID: gid.New(s.svc.scope.GetTenantID(), coredata.FileEntityType),
|
||||
OrganizationID: documentVersion.OrganizationID,
|
||||
BucketName: s.svc.bucket,
|
||||
MimeType: "application/pdf",
|
||||
FileName: fmt.Sprintf("approval-%s.pdf", decision.ID),
|
||||
FileKey: uuid.MustNewV4().String(),
|
||||
Visibility: coredata.FileVisibilityPrivate,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
fileSize, err := s.svc.fileManager.PutFile(
|
||||
ctx,
|
||||
fileRecord,
|
||||
bytes.NewReader(pdfData),
|
||||
map[string]string{
|
||||
"type": "approval-document",
|
||||
"decision-id": decision.ID.String(),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot upload approval PDF: %w", err)
|
||||
}
|
||||
|
||||
fileRecord.FileSize = fileSize
|
||||
|
||||
approverID := decision.ApproverID
|
||||
|
||||
err = s.svc.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
decision = &coredata.DocumentVersionApprovalDecision{}
|
||||
if err := decision.LoadByQuorumIDAndApproverID(ctx, tx, s.svc.scope, quorum.ID, approverID); err != nil {
|
||||
return fmt.Errorf("cannot load approval decision: %w", err)
|
||||
}
|
||||
|
||||
if decision.State != coredata.DocumentVersionApprovalDecisionStatePending {
|
||||
return &ErrApprovalDecisionAlreadyMade{}
|
||||
}
|
||||
|
||||
if err := fileRecord.Insert(ctx, tx, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot insert approval file record: %w", err)
|
||||
}
|
||||
|
||||
esig, err := s.svc.esign.CreateAndAcceptSignature(
|
||||
ctx,
|
||||
tx,
|
||||
&esign.CreateAndAcceptSignatureRequest{
|
||||
OrganizationID: documentVersion.OrganizationID,
|
||||
DocumentType: coredata.ElectronicSignatureDocumentTypeFromDocumentType(document.DocumentType),
|
||||
DocumentName: &document.Title,
|
||||
FileID: fileRecord.ID,
|
||||
SignerEmail: req.SignerEmail,
|
||||
SignerFullName: req.SignerFullName,
|
||||
SignerIPAddr: req.SignerIPAddr,
|
||||
SignerUA: req.SignerUA,
|
||||
ConsentText: "By clicking Approve, I consent to approve this document electronically and agree that my electronic signature has the same legal validity as a handwritten signature.",
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create electronic signature: %w", err)
|
||||
}
|
||||
|
||||
decision.State = coredata.DocumentVersionApprovalDecisionStateApproved
|
||||
decision.Comment = req.Comment
|
||||
decision.ElectronicSignatureID = &esig.ID
|
||||
decision.DecidedAt = &now
|
||||
decision.UpdatedAt = now
|
||||
|
||||
if err := decision.Update(ctx, tx, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot update approval decision: %w", err)
|
||||
}
|
||||
|
||||
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 nil, err
|
||||
}
|
||||
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) Reject(
|
||||
ctx context.Context,
|
||||
req RejectDocumentVersionRequest,
|
||||
) (*coredata.DocumentVersionApprovalDecision, error) {
|
||||
var decision *coredata.DocumentVersionApprovalDecision
|
||||
|
||||
err := s.svc.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) 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)
|
||||
}
|
||||
|
||||
quorum, profile, err := s.loadQuorumAndProfile(ctx, tx, req.DocumentVersionID, req.IdentityID, documentVersion.OrganizationID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot load quorum and profile: %w", err)
|
||||
}
|
||||
|
||||
decision = &coredata.DocumentVersionApprovalDecision{}
|
||||
if err := decision.LoadByQuorumIDAndApproverID(ctx, tx, s.svc.scope, quorum.ID, profile.ID); err != nil {
|
||||
return fmt.Errorf("cannot load approval decision: %w", err)
|
||||
}
|
||||
|
||||
if decision.State != coredata.DocumentVersionApprovalDecisionStatePending {
|
||||
return &ErrApprovalDecisionAlreadyMade{}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
decision.State = coredata.DocumentVersionApprovalDecisionStateRejected
|
||||
decision.Comment = req.Comment
|
||||
decision.DecidedAt = &now
|
||||
decision.UpdatedAt = now
|
||||
|
||||
if err := decision.Update(ctx, tx, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot update approval decision: %w", err)
|
||||
}
|
||||
|
||||
quorum.Status = coredata.DocumentVersionApprovalQuorumStatusRejected
|
||||
quorum.UpdatedAt = now
|
||||
|
||||
if err := quorum.Update(ctx, tx, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot update approval quorum: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) AddApprover(
|
||||
ctx context.Context,
|
||||
documentVersionID gid.GID,
|
||||
approverID gid.GID,
|
||||
) (*coredata.DocumentVersionApprovalDecision, error) {
|
||||
var decision *coredata.DocumentVersionApprovalDecision
|
||||
|
||||
err := s.svc.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
documentVersion := &coredata.DocumentVersion{}
|
||||
if err := documentVersion.LoadByID(ctx, tx, s.svc.scope, documentVersionID); err != nil {
|
||||
return fmt.Errorf("cannot load document version: %w", err)
|
||||
}
|
||||
|
||||
quorum := &coredata.DocumentVersionApprovalQuorum{}
|
||||
if err := quorum.LoadLastByDocumentVersionID(ctx, tx, s.svc.scope, documentVersionID); err != nil {
|
||||
return &ErrDocumentVersionNotPendingApproval{}
|
||||
}
|
||||
|
||||
if quorum.Status != coredata.DocumentVersionApprovalQuorumStatusPending {
|
||||
return &ErrDocumentVersionNotPendingApproval{}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
decision = &coredata.DocumentVersionApprovalDecision{
|
||||
ID: gid.New(s.svc.scope.GetTenantID(), coredata.DocumentVersionApprovalDecisionEntityType),
|
||||
OrganizationID: documentVersion.OrganizationID,
|
||||
QuorumID: quorum.ID,
|
||||
ApproverID: approverID,
|
||||
State: coredata.DocumentVersionApprovalDecisionStatePending,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := decision.Insert(ctx, tx, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot insert approval decision: %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)
|
||||
}
|
||||
|
||||
organization := &coredata.Organization{}
|
||||
if err := organization.LoadByID(ctx, tx, s.svc.scope, document.OrganizationID); err != nil {
|
||||
return fmt.Errorf("cannot load organization: %w", err)
|
||||
}
|
||||
|
||||
profile := &coredata.MembershipProfile{}
|
||||
if err := profile.LoadByID(ctx, tx, s.svc.scope, approverID); err != nil {
|
||||
return fmt.Errorf("cannot load approver profile: %w", err)
|
||||
}
|
||||
|
||||
if err := s.sendApprovalEmails(
|
||||
ctx,
|
||||
tx,
|
||||
coredata.MembershipProfiles{profile},
|
||||
document,
|
||||
organization,
|
||||
documentVersionID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("cannot send approval email: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) RemoveApprover(
|
||||
ctx context.Context,
|
||||
approvalDecisionID gid.GID,
|
||||
) (gid.GID, error) {
|
||||
var documentVersionID gid.GID
|
||||
|
||||
err := s.svc.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) 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(
|
||||
ctx context.Context,
|
||||
quorumID gid.GID,
|
||||
) (*coredata.DocumentVersionApprovalQuorum, error) {
|
||||
quorum := &coredata.DocumentVersionApprovalQuorum{}
|
||||
|
||||
err := s.svc.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
if err := quorum.LoadByID(ctx, conn, s.svc.scope, quorumID); err != nil {
|
||||
return fmt.Errorf("cannot load approval quorum: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return quorum, nil
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) ListQuorums(
|
||||
ctx context.Context,
|
||||
documentVersionID gid.GID,
|
||||
cursor *page.Cursor[coredata.DocumentVersionApprovalQuorumOrderField],
|
||||
) (*page.Page[*coredata.DocumentVersionApprovalQuorum, coredata.DocumentVersionApprovalQuorumOrderField], error) {
|
||||
var quorums coredata.DocumentVersionApprovalQuorums
|
||||
|
||||
err := s.svc.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
if err := quorums.LoadAllByDocumentVersionID(ctx, conn, s.svc.scope, documentVersionID, cursor); err != nil {
|
||||
return fmt.Errorf("cannot list approval quorums: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return page.NewPage(quorums, cursor), nil
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) CountQuorums(
|
||||
ctx context.Context,
|
||||
documentVersionID gid.GID,
|
||||
) (int, error) {
|
||||
var count int
|
||||
|
||||
err := s.svc.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) (err error) {
|
||||
quorums := &coredata.DocumentVersionApprovalQuorums{}
|
||||
count, err = quorums.CountByDocumentVersionID(ctx, conn, s.svc.scope, documentVersionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot count approval quorums: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) ListDecisions(
|
||||
ctx context.Context,
|
||||
quorumID gid.GID,
|
||||
cursor *page.Cursor[coredata.DocumentVersionApprovalDecisionOrderField],
|
||||
filter *coredata.DocumentVersionApprovalDecisionFilter,
|
||||
) (*page.Page[*coredata.DocumentVersionApprovalDecision, coredata.DocumentVersionApprovalDecisionOrderField], error) {
|
||||
var decisions coredata.DocumentVersionApprovalDecisions
|
||||
|
||||
err := s.svc.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
if err := decisions.LoadByQuorumID(ctx, conn, s.svc.scope, quorumID, cursor, filter); err != nil {
|
||||
return fmt.Errorf("cannot list approval decisions: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return page.NewPage(decisions, cursor), nil
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) CountDecisions(
|
||||
ctx context.Context,
|
||||
quorumID gid.GID,
|
||||
filter *coredata.DocumentVersionApprovalDecisionFilter,
|
||||
) (int, error) {
|
||||
var count int
|
||||
|
||||
err := s.svc.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) (err error) {
|
||||
decisions := &coredata.DocumentVersionApprovalDecisions{}
|
||||
count, err = decisions.CountByQuorumID(ctx, conn, s.svc.scope, quorumID, filter)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot count approval decisions: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) GetViewerDecision(
|
||||
ctx context.Context,
|
||||
documentVersionID gid.GID,
|
||||
identityID gid.GID,
|
||||
) (*coredata.DocumentVersionApprovalDecision, error) {
|
||||
var decision *coredata.DocumentVersionApprovalDecision
|
||||
|
||||
err := s.svc.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
documentVersion := &coredata.DocumentVersion{}
|
||||
if err := documentVersion.LoadByID(ctx, conn, s.svc.scope, documentVersionID); err != nil {
|
||||
return fmt.Errorf("cannot load document version: %w", err)
|
||||
}
|
||||
|
||||
profile := &coredata.MembershipProfile{}
|
||||
if err := profile.LoadByIdentityIDAndOrganizationID(
|
||||
ctx,
|
||||
conn,
|
||||
s.svc.scope,
|
||||
identityID,
|
||||
documentVersion.OrganizationID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("cannot load viewer profile: %w", err)
|
||||
}
|
||||
|
||||
quorum := &coredata.DocumentVersionApprovalQuorum{}
|
||||
if err := quorum.LoadLastByDocumentVersionID(ctx, conn, s.svc.scope, documentVersionID); err != nil {
|
||||
return fmt.Errorf("cannot load last approval quorum: %w", err)
|
||||
}
|
||||
|
||||
d := &coredata.DocumentVersionApprovalDecision{}
|
||||
if err := d.LoadByQuorumIDAndApproverID(ctx, conn, s.svc.scope, quorum.ID, profile.ID); err != nil {
|
||||
return fmt.Errorf("cannot load viewer approval decision: %w", err)
|
||||
}
|
||||
|
||||
decision = d
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) loadLatestVersion(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
documentID gid.GID,
|
||||
) (*coredata.DocumentVersion, error) {
|
||||
version := &coredata.DocumentVersion{}
|
||||
if err := version.LoadLatestVersion(ctx, conn, s.svc.scope, documentID); err != nil {
|
||||
return nil, fmt.Errorf("cannot load latest version for document %q: %w", documentID, err)
|
||||
}
|
||||
|
||||
return version, nil
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) loadQuorumAndProfile(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
documentVersionID gid.GID,
|
||||
identityID gid.GID,
|
||||
organizationID gid.GID,
|
||||
) (*coredata.DocumentVersionApprovalQuorum, *coredata.MembershipProfile, error) {
|
||||
quorum := &coredata.DocumentVersionApprovalQuorum{}
|
||||
if err := quorum.LoadLastByDocumentVersionID(ctx, conn, s.svc.scope, documentVersionID); err != nil {
|
||||
if errors.Is(err, coredata.ErrResourceNotFound) {
|
||||
return nil, nil, &ErrDocumentVersionNotPendingApproval{}
|
||||
}
|
||||
return nil, nil, fmt.Errorf("cannot load last approval quorum: %w", err)
|
||||
}
|
||||
|
||||
profile := &coredata.MembershipProfile{}
|
||||
if err := profile.LoadByIdentityIDAndOrganizationID(ctx, conn, s.svc.scope, identityID, organizationID); err != nil {
|
||||
return nil, nil, fmt.Errorf("cannot find profile for identity: %w", err)
|
||||
}
|
||||
|
||||
return quorum, profile, nil
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) rejectPendingQuorum(
|
||||
ctx context.Context,
|
||||
tx pg.Conn,
|
||||
documentVersionID gid.GID,
|
||||
) error {
|
||||
existingQuorum := &coredata.DocumentVersionApprovalQuorum{}
|
||||
if err := existingQuorum.LoadLastByDocumentVersionID(ctx, tx, s.svc.scope, documentVersionID); err != nil {
|
||||
if errors.Is(err, coredata.ErrResourceNotFound) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("cannot load last quorum: %w", err)
|
||||
}
|
||||
|
||||
if existingQuorum.Status != coredata.DocumentVersionApprovalQuorumStatusPending {
|
||||
return nil
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
existingQuorum.Status = coredata.DocumentVersionApprovalQuorumStatusRejected
|
||||
existingQuorum.UpdatedAt = now
|
||||
|
||||
if err := existingQuorum.Update(ctx, tx, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot reject existing quorum: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) createDecisions(
|
||||
ctx context.Context,
|
||||
tx pg.Conn,
|
||||
quorum *coredata.DocumentVersionApprovalQuorum,
|
||||
organizationID gid.GID,
|
||||
approverIDs []gid.GID,
|
||||
now time.Time,
|
||||
) error {
|
||||
decisions := make(coredata.DocumentVersionApprovalDecisions, 0, len(approverIDs))
|
||||
for _, approverID := range approverIDs {
|
||||
decisions = append(decisions, &coredata.DocumentVersionApprovalDecision{
|
||||
ID: gid.New(s.svc.scope.GetTenantID(), coredata.DocumentVersionApprovalDecisionEntityType),
|
||||
OrganizationID: organizationID,
|
||||
QuorumID: quorum.ID,
|
||||
ApproverID: approverID,
|
||||
State: coredata.DocumentVersionApprovalDecisionStatePending,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
if err := decisions.BulkInsert(ctx, tx, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot insert approval decisions: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) sendApprovalEmails(
|
||||
ctx context.Context,
|
||||
tx pg.Conn,
|
||||
profiles coredata.MembershipProfiles,
|
||||
document *coredata.Document,
|
||||
organization *coredata.Organization,
|
||||
documentVersionID gid.GID,
|
||||
) error {
|
||||
now := time.Now()
|
||||
approvalURLPath := "/organizations/" + document.OrganizationID.String() + "/employee/approvals/" + document.ID.String()
|
||||
|
||||
approvalEmails := make(coredata.Emails, 0, len(profiles))
|
||||
for _, profile := range profiles {
|
||||
emailPresenter := emails.NewPresenter(s.svc.fileManager, s.svc.bucket, s.svc.baseURL, profile.FullName)
|
||||
|
||||
var (
|
||||
emailLinkURLPath = approvalURLPath
|
||||
query = make(url.Values)
|
||||
)
|
||||
if profile.State != coredata.ProfileStateActive {
|
||||
if profile.Source != coredata.ProfileSourceSCIM {
|
||||
invitation := &coredata.Invitation{
|
||||
ID: gid.New(document.OrganizationID.TenantID(), coredata.InvitationEntityType),
|
||||
OrganizationID: document.OrganizationID,
|
||||
UserID: profile.ID,
|
||||
Status: coredata.InvitationStatusPending,
|
||||
ExpiresAt: now.Add(s.invitationTokenValidity),
|
||||
CreatedAt: now,
|
||||
}
|
||||
if err := invitation.Insert(ctx, tx, coredata.NewScopeFromObjectID(document.OrganizationID)); err != nil {
|
||||
return fmt.Errorf("cannot insert invitation: %w", err)
|
||||
}
|
||||
|
||||
invitationToken, err := statelesstoken.NewToken(
|
||||
s.tokenSecret,
|
||||
iam.TokenTypeOrganizationInvitation,
|
||||
s.invitationTokenValidity,
|
||||
iam.InvitationTokenData{InvitationID: invitation.ID},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot generate invitation token: %w", err)
|
||||
}
|
||||
|
||||
emailLinkURLPath = "/auth/activate-account"
|
||||
continueURL := baseurl.MustParse(s.svc.baseURL).AppendPath(approvalURLPath).MustString()
|
||||
query.Add("token", invitationToken)
|
||||
query.Add("continue", continueURL)
|
||||
}
|
||||
}
|
||||
|
||||
subject, textBody, htmlBody, err := emailPresenter.RenderDocumentApproval(
|
||||
ctx,
|
||||
emailLinkURLPath,
|
||||
query,
|
||||
organization.Name,
|
||||
document.Title,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot render approval request email: %w", err)
|
||||
}
|
||||
|
||||
approvalEmails = append(approvalEmails, coredata.NewEmail(
|
||||
profile.FullName,
|
||||
profile.EmailAddress,
|
||||
subject,
|
||||
textBody,
|
||||
htmlBody,
|
||||
&coredata.EmailOptions{
|
||||
SenderName: new(organization.Name),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
if err := approvalEmails.BulkInsert(ctx, tx); err != nil {
|
||||
return fmt.Errorf("cannot insert approval emails: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) generateApprovalPDF(
|
||||
ctx context.Context,
|
||||
documentVersionID gid.GID,
|
||||
) ([]byte, error) {
|
||||
var pdfData []byte
|
||||
|
||||
err := s.svc.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
var err error
|
||||
pdfData, err = exportDocumentPDF(
|
||||
ctx,
|
||||
s.svc,
|
||||
s.html2pdfConverter,
|
||||
conn,
|
||||
s.svc.scope,
|
||||
documentVersionID,
|
||||
ExportPDFOptions{},
|
||||
)
|
||||
return err
|
||||
},
|
||||
)
|
||||
|
||||
return pdfData, err
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) countDecisions(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
quorumID gid.GID,
|
||||
) (int, error) {
|
||||
decisions := &coredata.DocumentVersionApprovalDecisions{}
|
||||
count, err := decisions.CountByQuorumID(
|
||||
ctx,
|
||||
conn,
|
||||
s.svc.scope,
|
||||
quorumID,
|
||||
coredata.NewDocumentVersionApprovalDecisionFilter(nil),
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot count decisions: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) maybeApproveQuorum(
|
||||
ctx context.Context,
|
||||
tx pg.Conn,
|
||||
quorumID gid.GID,
|
||||
) error {
|
||||
totalCount, err := s.countDecisions(ctx, tx, quorumID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot count total decisions: %w", err)
|
||||
}
|
||||
|
||||
if totalCount > 0 {
|
||||
decisions := &coredata.DocumentVersionApprovalDecisions{}
|
||||
approvedCount, err := decisions.CountApprovedByQuorumID(ctx, tx, s.svc.scope, quorumID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot count approved decisions: %w", err)
|
||||
}
|
||||
|
||||
if approvedCount != totalCount {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
quorum := &coredata.DocumentVersionApprovalQuorum{}
|
||||
if err := quorum.LoadByID(ctx, tx, s.svc.scope, quorumID); err != nil {
|
||||
return fmt.Errorf("cannot load quorum: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
quorum.Status = coredata.DocumentVersionApprovalQuorumStatusApproved
|
||||
quorum.UpdatedAt = now
|
||||
|
||||
if err := quorum.Update(ctx, tx, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot update quorum: %w", err)
|
||||
}
|
||||
|
||||
if err := s.publishVersion(ctx, tx, quorum.VersionID); err != nil {
|
||||
return fmt.Errorf("cannot publish version: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DocumentApprovalService) publishVersion(
|
||||
ctx context.Context,
|
||||
tx pg.Conn,
|
||||
versionID gid.GID,
|
||||
) error {
|
||||
version := &coredata.DocumentVersion{}
|
||||
if err := version.LoadByID(ctx, tx, s.svc.scope, versionID); err != nil {
|
||||
return fmt.Errorf("cannot load document version: %w", err)
|
||||
}
|
||||
|
||||
document := &coredata.Document{}
|
||||
if err := document.LoadByID(ctx, tx, s.svc.scope, version.DocumentID); err != nil {
|
||||
return fmt.Errorf("cannot load document: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
document.CurrentPublishedVersion = &version.VersionNumber
|
||||
document.UpdatedAt = now
|
||||
|
||||
version.Status = coredata.DocumentVersionStatusPublished
|
||||
version.PublishedAt = &now
|
||||
version.UpdatedAt = now
|
||||
|
||||
if err := document.Update(ctx, tx, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot update document: %w", err)
|
||||
}
|
||||
|
||||
if err := version.Update(ctx, tx, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot update document version: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
@@ -76,7 +77,6 @@ type (
|
||||
OrganizationID gid.GID
|
||||
Title string
|
||||
Content string
|
||||
ApproverIDs []gid.GID
|
||||
Classification coredata.DocumentClassification
|
||||
DocumentType coredata.DocumentType
|
||||
TrustCenterVisibility *coredata.TrustCenterVisibility
|
||||
@@ -85,7 +85,6 @@ type (
|
||||
UpdateDocumentRequest struct {
|
||||
DocumentID gid.GID
|
||||
Title *string
|
||||
ApproverIDs []gid.GID
|
||||
Classification *coredata.DocumentClassification
|
||||
DocumentType *coredata.DocumentType
|
||||
TrustCenterVisibility *coredata.TrustCenterVisibility
|
||||
@@ -123,10 +122,6 @@ func (cdr *CreateDocumentRequest) Validate() error {
|
||||
v.Check(cdr.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
|
||||
v.Check(cdr.Title, "title", validator.Required(), validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
v.Check(cdr.Content, "content", validator.Required(), validator.NotEmpty(), validator.MaxLen(documentMaxLength))
|
||||
v.Check(cdr.ApproverIDs, "approver_ids", validator.Required(), validator.NotEmpty())
|
||||
for _, id := range cdr.ApproverIDs {
|
||||
v.Check(id, "approver_ids", validator.Required(), validator.GID(coredata.MembershipProfileEntityType))
|
||||
}
|
||||
v.Check(cdr.Classification, "classification", validator.Required(), validator.OneOfSlice(coredata.DocumentClassifications()))
|
||||
v.Check(cdr.DocumentType, "document_type", validator.Required(), validator.OneOfSlice(coredata.DocumentTypes()))
|
||||
v.Check(cdr.TrustCenterVisibility, "trust_center_visibility", validator.OneOfSlice(coredata.TrustCenterVisibilities()))
|
||||
@@ -139,9 +134,6 @@ func (udr *UpdateDocumentRequest) Validate() error {
|
||||
|
||||
v.Check(udr.DocumentID, "document_id", validator.Required(), validator.GID(coredata.DocumentEntityType))
|
||||
v.Check(udr.Title, "title", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||
for _, id := range udr.ApproverIDs {
|
||||
v.Check(id, "approver_ids", validator.Required(), validator.GID(coredata.MembershipProfileEntityType))
|
||||
}
|
||||
v.Check(udr.Classification, "classification", validator.OneOfSlice(coredata.DocumentClassifications()))
|
||||
v.Check(udr.DocumentType, "document_type", validator.OneOfSlice(coredata.DocumentTypes()))
|
||||
v.Check(udr.TrustCenterVisibility, "trust_center_visibility", validator.OneOfSlice(coredata.TrustCenterVisibilities()))
|
||||
@@ -244,57 +236,6 @@ func (s *DocumentService) GetByIDs(
|
||||
return documents, nil
|
||||
}
|
||||
|
||||
func (s *DocumentService) ListApprovers(
|
||||
ctx context.Context,
|
||||
documentID gid.GID,
|
||||
cursor *page.Cursor[coredata.MembershipProfileOrderField],
|
||||
) (*page.Page[*coredata.MembershipProfile, coredata.MembershipProfileOrderField], error) {
|
||||
var profiles coredata.MembershipProfiles
|
||||
|
||||
err := s.svc.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
if err := profiles.LoadByDocumentID(ctx, conn, s.svc.scope, documentID, cursor); err != nil {
|
||||
return fmt.Errorf("cannot load document approvers: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return page.NewPage(profiles, cursor), nil
|
||||
}
|
||||
|
||||
func (s *DocumentService) CountApprovers(
|
||||
ctx context.Context,
|
||||
documentID gid.GID,
|
||||
) (int, error) {
|
||||
var count int
|
||||
|
||||
err := s.svc.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) (err error) {
|
||||
profiles := coredata.MembershipProfiles{}
|
||||
count, err = profiles.CountByDocumentID(ctx, conn, s.svc.scope, documentID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot count document approvers: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (s *DocumentService) ListVersionApprovers(
|
||||
ctx context.Context,
|
||||
documentVersionID gid.GID,
|
||||
@@ -602,54 +543,18 @@ func (s *DocumentService) Create(
|
||||
return fmt.Errorf("cannot load organization: %w", err)
|
||||
}
|
||||
|
||||
// Validate all approver profiles exist
|
||||
approverProfiles := coredata.MembershipProfiles{}
|
||||
if err := approverProfiles.LoadByIDs(ctx, conn, s.svc.scope, req.ApproverIDs); err != nil {
|
||||
return fmt.Errorf("cannot load approver profiles: %w", err)
|
||||
}
|
||||
|
||||
if len(approverProfiles) != len(req.ApproverIDs) {
|
||||
return fmt.Errorf("one or more approver profiles not found")
|
||||
}
|
||||
|
||||
document.OrganizationID = organization.ID
|
||||
|
||||
if err := document.Insert(ctx, conn, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot insert document: %w", err)
|
||||
}
|
||||
|
||||
// Insert document approvers
|
||||
for _, approverID := range req.ApproverIDs {
|
||||
da := coredata.DocumentApprover{
|
||||
DocumentID: documentID,
|
||||
ApproverProfileID: approverID,
|
||||
OrganizationID: organization.ID,
|
||||
CreatedAt: now,
|
||||
}
|
||||
if err := da.Insert(ctx, conn, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot insert document approver: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
documentVersion.OrganizationID = organization.ID
|
||||
|
||||
if err := documentVersion.Insert(ctx, conn, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot create document version: %w", err)
|
||||
}
|
||||
|
||||
// Insert document version approvers
|
||||
for _, approverID := range req.ApproverIDs {
|
||||
dva := coredata.DocumentVersionApprover{
|
||||
DocumentVersionID: documentVersionID,
|
||||
ApproverProfileID: approverID,
|
||||
OrganizationID: organization.ID,
|
||||
CreatedAt: now,
|
||||
}
|
||||
if err := dva.Insert(ctx, conn, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot insert document version approver: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
@@ -870,28 +775,6 @@ func (s *DocumentService) UpdateVersion(
|
||||
return fmt.Errorf("cannot update document version: %w", err)
|
||||
}
|
||||
|
||||
docApprovers := &coredata.DocumentApprovers{}
|
||||
if err := docApprovers.LoadByDocumentID(ctx, conn, s.svc.scope, document.ID); err != nil {
|
||||
return fmt.Errorf("cannot load document approvers: %w", err)
|
||||
}
|
||||
|
||||
versionApprovers := &coredata.DocumentVersionApprovers{}
|
||||
if err := versionApprovers.DeleteByDocumentVersionID(ctx, conn, s.svc.scope, documentVersion.ID); err != nil {
|
||||
return fmt.Errorf("cannot delete document version approvers: %w", err)
|
||||
}
|
||||
|
||||
for _, da := range *docApprovers {
|
||||
dva := coredata.DocumentVersionApprover{
|
||||
DocumentVersionID: documentVersion.ID,
|
||||
ApproverProfileID: da.ApproverProfileID,
|
||||
OrganizationID: da.OrganizationID,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := dva.Insert(ctx, conn, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot insert document version approver: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
@@ -1132,23 +1015,6 @@ func (s *DocumentService) CreateDraft(
|
||||
return fmt.Errorf("cannot create draft: %w", err)
|
||||
}
|
||||
|
||||
docApprovers := &coredata.DocumentApprovers{}
|
||||
if err := docApprovers.LoadByDocumentID(ctx, conn, s.svc.scope, documentID); err != nil {
|
||||
return fmt.Errorf("cannot load document approvers: %w", err)
|
||||
}
|
||||
|
||||
for _, da := range *docApprovers {
|
||||
dva := coredata.DocumentVersionApprover{
|
||||
DocumentVersionID: draftVersionID,
|
||||
ApproverProfileID: da.ApproverProfileID,
|
||||
OrganizationID: da.OrganizationID,
|
||||
CreatedAt: now,
|
||||
}
|
||||
if err := dva.Insert(ctx, conn, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot insert document version approver: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
@@ -1460,6 +1326,36 @@ func (s *DocumentService) IsSigned(
|
||||
return signed, nil
|
||||
}
|
||||
|
||||
func (s *DocumentService) GetViewerApprovalState(
|
||||
ctx context.Context,
|
||||
documentID gid.GID,
|
||||
identityID gid.GID,
|
||||
) (coredata.DocumentVersionApprovalDecisionState, error) {
|
||||
document := &coredata.Document{}
|
||||
|
||||
var state coredata.DocumentVersionApprovalDecisionState
|
||||
err := s.svc.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
var err error
|
||||
state, err = document.GetViewerApprovalStateForLastVersion(
|
||||
ctx,
|
||||
conn,
|
||||
s.svc.scope,
|
||||
documentID,
|
||||
identityID,
|
||||
)
|
||||
return err
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot get viewer approval state: %w", err)
|
||||
}
|
||||
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (s *DocumentService) CountForOrganizationID(
|
||||
ctx context.Context,
|
||||
organizationID gid.GID,
|
||||
@@ -1656,33 +1552,6 @@ func (s *DocumentService) Update(
|
||||
document.TrustCenterVisibility = *req.TrustCenterVisibility
|
||||
}
|
||||
|
||||
if len(req.ApproverIDs) > 0 {
|
||||
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)
|
||||
}
|
||||
if len(approverProfiles) != len(req.ApproverIDs) {
|
||||
return fmt.Errorf("one or more approver profiles not found")
|
||||
}
|
||||
|
||||
docApprovers := &coredata.DocumentApprovers{}
|
||||
if err := docApprovers.DeleteByDocumentID(ctx, tx, s.svc.scope, req.DocumentID); err != nil {
|
||||
return fmt.Errorf("cannot delete document approvers: %w", err)
|
||||
}
|
||||
|
||||
for _, approverID := range req.ApproverIDs {
|
||||
da := coredata.DocumentApprover{
|
||||
DocumentID: req.DocumentID,
|
||||
ApproverProfileID: approverID,
|
||||
OrganizationID: document.OrganizationID,
|
||||
CreatedAt: now,
|
||||
}
|
||||
if err := da.Insert(ctx, tx, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot insert document approver: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.UpdatedAt = now
|
||||
|
||||
if err := document.Update(ctx, tx, s.svc.scope); err != nil {
|
||||
@@ -1699,25 +1568,6 @@ func (s *DocumentService) Update(
|
||||
if err := draftVersion.Update(ctx, tx, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot update draft version: %w", err)
|
||||
}
|
||||
|
||||
if len(req.ApproverIDs) > 0 {
|
||||
versionApprovers := &coredata.DocumentVersionApprovers{}
|
||||
if err := versionApprovers.DeleteByDocumentVersionID(ctx, tx, s.svc.scope, draftVersion.ID); err != nil {
|
||||
return fmt.Errorf("cannot delete draft version approvers: %w", err)
|
||||
}
|
||||
|
||||
for _, approverID := range req.ApproverIDs {
|
||||
dva := coredata.DocumentVersionApprover{
|
||||
DocumentVersionID: draftVersion.ID,
|
||||
ApproverProfileID: approverID,
|
||||
OrganizationID: document.OrganizationID,
|
||||
CreatedAt: now,
|
||||
}
|
||||
if err := dva.Insert(ctx, tx, s.svc.scope); err != nil {
|
||||
return fmt.Errorf("cannot insert draft version approver: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -2014,25 +1864,53 @@ func exportDocumentPDF(
|
||||
return nil, fmt.Errorf("cannot load document: %w", err)
|
||||
}
|
||||
|
||||
versionApprovers := &coredata.DocumentVersionApprovers{}
|
||||
if err := versionApprovers.LoadByDocumentVersionID(ctx, conn, scope, documentVersionID); err != nil {
|
||||
return nil, fmt.Errorf("cannot load document version approvers: %w", err)
|
||||
}
|
||||
// Only show approvers from the last approved quorum in the export.
|
||||
var approverNames []string
|
||||
|
||||
approverProfiles := coredata.MembershipProfiles{}
|
||||
if err := approverProfiles.LoadByIDs(ctx, conn, scope, versionApprovers.ApproverProfileIDs()); err != nil {
|
||||
return nil, fmt.Errorf("cannot load document approver profiles: %w", err)
|
||||
}
|
||||
lastQuorum := &coredata.DocumentVersionApprovalQuorum{}
|
||||
if err := lastQuorum.LoadLastByDocumentVersionID(ctx, conn, scope, documentVersionID); err != nil {
|
||||
if !errors.Is(err, coredata.ErrResourceNotFound) {
|
||||
return nil, fmt.Errorf("cannot load last approval quorum: %w", err)
|
||||
}
|
||||
} else if lastQuorum.Status == coredata.DocumentVersionApprovalQuorumStatusApproved {
|
||||
approvedDecisions := &coredata.DocumentVersionApprovalDecisions{}
|
||||
approvedFilter := coredata.NewDocumentVersionApprovalDecisionFilter(
|
||||
coredata.DocumentVersionApprovalDecisionStates{coredata.DocumentVersionApprovalDecisionStateApproved},
|
||||
)
|
||||
if err := approvedDecisions.LoadByQuorumID(
|
||||
ctx,
|
||||
conn,
|
||||
scope,
|
||||
lastQuorum.ID,
|
||||
page.NewCursor(
|
||||
100,
|
||||
nil,
|
||||
page.Head,
|
||||
page.OrderBy[coredata.DocumentVersionApprovalDecisionOrderField]{
|
||||
Field: coredata.DocumentVersionApprovalDecisionOrderFieldCreatedAt,
|
||||
Direction: page.OrderDirectionAsc,
|
||||
},
|
||||
),
|
||||
approvedFilter,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("cannot load approved decisions: %w", err)
|
||||
}
|
||||
|
||||
profileByID := make(map[gid.GID]*coredata.MembershipProfile, len(approverProfiles))
|
||||
for _, p := range approverProfiles {
|
||||
profileByID[p.ID] = p
|
||||
}
|
||||
approverProfileIDs := make([]gid.GID, 0, len(*approvedDecisions))
|
||||
for _, d := range *approvedDecisions {
|
||||
approverProfileIDs = append(approverProfileIDs, d.ApproverID)
|
||||
}
|
||||
|
||||
approverNames := make([]string, 0, len(*versionApprovers))
|
||||
for _, a := range *versionApprovers {
|
||||
if p, ok := profileByID[a.ApproverProfileID]; ok {
|
||||
approverNames = append(approverNames, p.FullName)
|
||||
if len(approverProfileIDs) > 0 {
|
||||
approverProfiles := coredata.MembershipProfiles{}
|
||||
if err := approverProfiles.LoadByIDs(ctx, conn, scope, approverProfileIDs); err != nil {
|
||||
return nil, fmt.Errorf("cannot load approver profiles: %w", err)
|
||||
}
|
||||
|
||||
approverNames = make([]string, 0, len(approverProfiles))
|
||||
for _, p := range approverProfiles {
|
||||
approverNames = append(approverNames, p.FullName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,11 @@ var (
|
||||
ActionDocumentSendSigningNotifications,
|
||||
ActionDocumentVersionUpdate,
|
||||
ActionDocumentVersionPublish,
|
||||
ActionDocumentVersionRequestApproval,
|
||||
ActionDocumentVersionApprove,
|
||||
ActionDocumentVersionReject,
|
||||
ActionDocumentVersionAddApprover,
|
||||
ActionDocumentVersionRemoveApprover,
|
||||
ActionDocumentVersionDeleteDraft,
|
||||
ActionDocumentVersionSignatureRequest,
|
||||
ActionDocumentVersionCancelSignature,
|
||||
@@ -42,6 +47,31 @@ var (
|
||||
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.
|
||||
@@ -50,6 +80,11 @@ var OwnerPolicy = policy.NewPolicy(
|
||||
"Probo Owner",
|
||||
documentWriteActiveOnly,
|
||||
documentUnarchiveArchivedOnly,
|
||||
|
||||
documentRequestApprovalNoPendingQuorum,
|
||||
documentRequestApprovalNotPublished,
|
||||
|
||||
documentApproverRequiresPendingQuorum,
|
||||
policy.Allow("core:*").WithSID("full-core-access").When(organizationCondition),
|
||||
).WithDescription("Full probo access for organization owners")
|
||||
|
||||
@@ -59,6 +94,11 @@ var AdminPolicy = policy.NewPolicy(
|
||||
"Probo Admin",
|
||||
documentWriteActiveOnly,
|
||||
documentUnarchiveArchivedOnly,
|
||||
|
||||
documentRequestApprovalNoPendingQuorum,
|
||||
documentRequestApprovalNotPublished,
|
||||
|
||||
documentApproverRequiresPendingQuorum,
|
||||
policy.Allow("core:*").WithSID("full-core-access").When(organizationCondition),
|
||||
).WithDescription("Probo admin access - can manage core entities")
|
||||
|
||||
@@ -66,6 +106,7 @@ var AdminPolicy = policy.NewPolicy(
|
||||
var ViewerPolicy = policy.NewPolicy(
|
||||
"probo:viewer",
|
||||
"Probo Viewer",
|
||||
documentWriteActiveOnly,
|
||||
policy.Allow(
|
||||
ActionOrganizationGet,
|
||||
ActionOrganizationGetLogoUrl,
|
||||
@@ -88,6 +129,7 @@ var ViewerPolicy = policy.NewPolicy(
|
||||
ActionDocumentGet, ActionDocumentList,
|
||||
ActionDocumentVersionGet, ActionDocumentVersionList,
|
||||
ActionDocumentVersionSignatureGet, ActionDocumentVersionSignatureList,
|
||||
ActionDocumentVersionApprovalList,
|
||||
ActionRiskGet, ActionRiskList,
|
||||
ActionAssetGet, ActionAssetList,
|
||||
ActionDatumGet, ActionDatumList,
|
||||
@@ -123,6 +165,10 @@ var ViewerPolicy = policy.NewPolicy(
|
||||
ActionDocumentVersionExportPDF, ActionDocumentVersionExportSignable, ActionDocumentVersionSign,
|
||||
).WithSID("document-signing").When(organizationCondition),
|
||||
|
||||
policy.Allow(
|
||||
ActionDocumentVersionApprove, ActionDocumentVersionReject,
|
||||
).WithSID("document-approval").When(organizationCondition),
|
||||
|
||||
policy.Allow(
|
||||
ActionProcessingActivityExport,
|
||||
ActionDataProtectionImpactAssessmentExport,
|
||||
@@ -155,6 +201,7 @@ var AuditorPolicy = policy.NewPolicy(
|
||||
ActionDocumentGet, ActionDocumentList,
|
||||
ActionDocumentVersionGet, ActionDocumentVersionList,
|
||||
ActionDocumentVersionSignatureGet, ActionDocumentVersionSignatureList,
|
||||
ActionDocumentVersionApprovalList,
|
||||
ActionRiskGet, ActionRiskList,
|
||||
ActionAssetGet, ActionAssetList,
|
||||
ActionDatumGet, ActionDatumList,
|
||||
@@ -184,6 +231,7 @@ var AuditorPolicy = policy.NewPolicy(
|
||||
var EmployeePolicy = policy.NewPolicy(
|
||||
"probo:employee",
|
||||
"Probo Employee",
|
||||
documentWriteActiveOnly,
|
||||
policy.Allow(
|
||||
ActionOrganizationGet,
|
||||
ActionOrganizationGetLogoUrl,
|
||||
@@ -198,7 +246,14 @@ var EmployeePolicy = policy.NewPolicy(
|
||||
ActionDocumentVersionSign,
|
||||
ActionDocumentVersionExportSignable,
|
||||
).WithSID("document-version-signing").When(organizationCondition),
|
||||
).WithDescription("Employee access - can sign documents and view internal content")
|
||||
|
||||
policy.Allow(
|
||||
ActionDocumentVersionApprovalList,
|
||||
ActionDocumentVersionApprove,
|
||||
ActionDocumentVersionReject,
|
||||
ActionDocumentVersionExportPDF,
|
||||
).WithSID("document-version-approval").When(organizationCondition),
|
||||
).WithDescription("Employee access - can sign documents, approve documents, and view internal content")
|
||||
|
||||
// ProboPolicySet returns the PolicySet for the probo service.
|
||||
func ProboPolicySet() *iam.PolicySet {
|
||||
|
||||
@@ -87,6 +87,7 @@ type (
|
||||
Organizations *OrganizationService
|
||||
Vendors *VendorService
|
||||
Documents *DocumentService
|
||||
DocumentApprovals *DocumentApprovalService
|
||||
Controls *ControlService
|
||||
Risks *RiskService
|
||||
VendorComplianceReports *VendorComplianceReportService
|
||||
@@ -212,6 +213,12 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService {
|
||||
invitationTokenValidity: s.invitationTokenValidity,
|
||||
tokenSecret: s.tokenSecret,
|
||||
}
|
||||
tenantService.DocumentApprovals = &DocumentApprovalService{
|
||||
svc: tenantService,
|
||||
html2pdfConverter: s.html2pdfConverter,
|
||||
invitationTokenValidity: s.invitationTokenValidity,
|
||||
tokenSecret: s.tokenSecret,
|
||||
}
|
||||
tenantService.Organizations = &OrganizationService{
|
||||
svc: tenantService,
|
||||
fileValidator: filevalidation.NewValidator(
|
||||
|
||||
@@ -1623,7 +1623,7 @@ input ApplicabilityStatementOrder
|
||||
}
|
||||
|
||||
input DocumentVersionFilter {
|
||||
status: DocumentVersionStatus
|
||||
statuses: [DocumentVersionStatus!]
|
||||
}
|
||||
|
||||
# Input Types for Filtering
|
||||
@@ -2441,13 +2441,6 @@ type Document implements Node {
|
||||
classification: DocumentClassification!
|
||||
currentPublishedVersion: Int
|
||||
trustCenterVisibility: TrustCenterVisibility!
|
||||
approvers(
|
||||
first: Int
|
||||
after: CursorKey
|
||||
last: Int
|
||||
before: CursorKey
|
||||
orderBy: ProfileOrder
|
||||
): ProfileConnection! @goField(forceResolver: true)
|
||||
organization: Organization! @goField(forceResolver: true)
|
||||
|
||||
versions(
|
||||
@@ -2477,16 +2470,17 @@ type Document implements Node {
|
||||
permission(action: String!): Boolean! @goField(forceResolver: true)
|
||||
}
|
||||
|
||||
type SignableDocument
|
||||
type EmployeeDocument
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.SignableDocument"
|
||||
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.EmployeeDocument"
|
||||
) {
|
||||
id: ID!
|
||||
title: String!
|
||||
description: String
|
||||
documentType: DocumentType!
|
||||
classification: DocumentClassification!
|
||||
signed: Boolean! @goField(forceResolver: true)
|
||||
signed: Boolean @goField(forceResolver: true)
|
||||
approvalState: DocumentVersionApprovalDecisionState @goField(forceResolver: true)
|
||||
|
||||
versions(
|
||||
first: Int
|
||||
@@ -2494,13 +2488,26 @@ type SignableDocument
|
||||
last: Int
|
||||
before: CursorKey
|
||||
orderBy: DocumentVersionOrder
|
||||
filter: DocumentVersionFilter
|
||||
): DocumentVersionConnection! @goField(forceResolver: true)
|
||||
): EmployeeDocumentVersionConnection! @goField(forceResolver: true)
|
||||
|
||||
createdAt: Datetime!
|
||||
updatedAt: Datetime!
|
||||
}
|
||||
|
||||
type EmployeeDocumentVersion
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.EmployeeDocumentVersion"
|
||||
) {
|
||||
id: ID!
|
||||
version: Int!
|
||||
status: DocumentVersionStatus!
|
||||
signed: Boolean! @goField(forceResolver: true)
|
||||
approvalDecision: DocumentVersionApprovalDecision @goField(forceResolver: true)
|
||||
publishedAt: Datetime
|
||||
createdAt: Datetime!
|
||||
updatedAt: Datetime!
|
||||
}
|
||||
|
||||
type Meeting implements Node {
|
||||
id: ID!
|
||||
name: String!
|
||||
@@ -2952,9 +2959,20 @@ type Viewer {
|
||||
last: Int
|
||||
before: CursorKey
|
||||
orderBy: DocumentOrder
|
||||
): SignableDocumentConnection! @goField(forceResolver: true)
|
||||
): EmployeeDocumentConnection! @goField(forceResolver: true)
|
||||
|
||||
signableDocument(id: ID!): SignableDocument @goField(forceResolver: true)
|
||||
signableDocument(id: ID!): EmployeeDocument @goField(forceResolver: true)
|
||||
|
||||
approvableDocuments(
|
||||
organizationId: ID!
|
||||
first: Int
|
||||
after: CursorKey
|
||||
last: Int
|
||||
before: CursorKey
|
||||
orderBy: DocumentOrder
|
||||
): EmployeeDocumentConnection! @goField(forceResolver: true)
|
||||
|
||||
approvableDocument(id: ID!): EmployeeDocument @goField(forceResolver: true)
|
||||
}
|
||||
|
||||
type TrustCenterConnection {
|
||||
@@ -3226,20 +3244,36 @@ type EvidenceEdge {
|
||||
node: Evidence!
|
||||
}
|
||||
|
||||
type SignableDocumentConnection
|
||||
type EmployeeDocumentConnection
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.SignableDocumentConnection"
|
||||
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.EmployeeDocumentConnection"
|
||||
) {
|
||||
edges: [SignableDocumentEdge!]!
|
||||
edges: [EmployeeDocumentEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
}
|
||||
|
||||
type SignableDocumentEdge
|
||||
type EmployeeDocumentEdge
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.SignableDocumentEdge"
|
||||
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.EmployeeDocumentEdge"
|
||||
) {
|
||||
cursor: CursorKey!
|
||||
node: SignableDocument!
|
||||
node: EmployeeDocument!
|
||||
}
|
||||
|
||||
type EmployeeDocumentVersionConnection
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.EmployeeDocumentVersionConnection"
|
||||
) {
|
||||
edges: [EmployeeDocumentVersionEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
}
|
||||
|
||||
type EmployeeDocumentVersionEdge
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.EmployeeDocumentVersionEdge"
|
||||
) {
|
||||
cursor: CursorKey!
|
||||
node: EmployeeDocumentVersion!
|
||||
}
|
||||
|
||||
type DocumentConnection
|
||||
@@ -3762,6 +3796,9 @@ type Mutation {
|
||||
bulkPublishDocumentVersions(
|
||||
input: BulkPublishDocumentVersionsInput!
|
||||
): BulkPublishDocumentVersionsPayload!
|
||||
requestDocumentVersionApproval(
|
||||
input: RequestDocumentVersionApprovalInput!
|
||||
): RequestDocumentVersionApprovalPayload!
|
||||
bulkDeleteDocuments(
|
||||
input: BulkDeleteDocumentsInput!
|
||||
): BulkDeleteDocumentsPayload!
|
||||
@@ -3797,6 +3834,18 @@ type Mutation {
|
||||
input: CancelSignatureRequestInput!
|
||||
): CancelSignatureRequestPayload!
|
||||
signDocument(input: SignDocumentInput!): SignDocumentPayload!
|
||||
addDocumentVersionApprover(
|
||||
input: AddDocumentVersionApproverInput!
|
||||
): AddDocumentVersionApproverPayload!
|
||||
removeDocumentVersionApprover(
|
||||
input: RemoveDocumentVersionApproverInput!
|
||||
): RemoveDocumentVersionApproverPayload!
|
||||
approveDocumentVersion(
|
||||
input: ApproveDocumentVersionInput!
|
||||
): ApproveDocumentVersionPayload!
|
||||
rejectDocumentVersion(
|
||||
input: RejectDocumentVersionInput!
|
||||
): RejectDocumentVersionPayload!
|
||||
|
||||
exportDocumentVersionPDF(
|
||||
input: ExportDocumentVersionPDFInput!
|
||||
@@ -4409,7 +4458,6 @@ input CreateDocumentInput {
|
||||
organizationId: ID!
|
||||
title: String!
|
||||
content: String!
|
||||
approverIds: [ID!]!
|
||||
documentType: DocumentType!
|
||||
classification: DocumentClassification!
|
||||
trustCenterVisibility: TrustCenterVisibility
|
||||
@@ -4419,7 +4467,6 @@ input UpdateDocumentInput {
|
||||
id: ID!
|
||||
title: String
|
||||
content: String
|
||||
approverIds: [ID!]
|
||||
documentType: DocumentType
|
||||
classification: DocumentClassification
|
||||
trustCenterVisibility: TrustCenterVisibility
|
||||
@@ -5324,6 +5371,14 @@ type DocumentVersion implements Node {
|
||||
filter: DocumentVersionSignatureFilter
|
||||
): DocumentVersionSignatureConnection! @goField(forceResolver: true)
|
||||
|
||||
approvalQuorums(
|
||||
first: Int
|
||||
after: CursorKey
|
||||
last: Int
|
||||
before: CursorKey
|
||||
orderBy: DocumentVersionApprovalQuorumOrder
|
||||
): DocumentVersionApprovalQuorumConnection! @goField(forceResolver: true)
|
||||
|
||||
signed: Boolean! @goField(forceResolver: true)
|
||||
|
||||
publishedAt: Datetime
|
||||
@@ -5398,6 +5453,170 @@ type DocumentVersionSignature implements Node {
|
||||
permission(action: String!): Boolean! @goField(forceResolver: true)
|
||||
}
|
||||
|
||||
enum DocumentVersionApprovalDecisionState
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalDecisionState"
|
||||
) {
|
||||
PENDING
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalDecisionStatePending"
|
||||
)
|
||||
APPROVED
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalDecisionStateApproved"
|
||||
)
|
||||
REJECTED
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalDecisionStateRejected"
|
||||
)
|
||||
}
|
||||
|
||||
enum DocumentVersionApprovalDecisionOrderField
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalDecisionOrderField"
|
||||
) {
|
||||
CREATED_AT
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalDecisionOrderFieldCreatedAt"
|
||||
)
|
||||
}
|
||||
|
||||
enum DocumentVersionApprovalQuorumStatus
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalQuorumStatus"
|
||||
) {
|
||||
PENDING
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalQuorumStatusPending"
|
||||
)
|
||||
APPROVED
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalQuorumStatusApproved"
|
||||
)
|
||||
REJECTED
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalQuorumStatusRejected"
|
||||
)
|
||||
}
|
||||
|
||||
enum DocumentVersionApprovalQuorumOrderField
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalQuorumOrderField"
|
||||
) {
|
||||
CREATED_AT
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalQuorumOrderFieldCreatedAt"
|
||||
)
|
||||
}
|
||||
|
||||
input DocumentVersionApprovalQuorumOrder {
|
||||
field: DocumentVersionApprovalQuorumOrderField!
|
||||
direction: OrderDirection!
|
||||
}
|
||||
|
||||
type DocumentVersionApprovalQuorumConnection
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.DocumentVersionApprovalQuorumConnection"
|
||||
) {
|
||||
edges: [DocumentVersionApprovalQuorumEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
totalCount: Int! @goField(forceResolver: true)
|
||||
}
|
||||
|
||||
type DocumentVersionApprovalQuorumEdge {
|
||||
cursor: CursorKey!
|
||||
node: DocumentVersionApprovalQuorum!
|
||||
}
|
||||
|
||||
type DocumentVersionApprovalQuorum implements Node {
|
||||
id: ID!
|
||||
documentVersion: DocumentVersion! @goField(forceResolver: true)
|
||||
status: DocumentVersionApprovalQuorumStatus!
|
||||
decisions(
|
||||
first: Int
|
||||
after: CursorKey
|
||||
last: Int
|
||||
before: CursorKey
|
||||
orderBy: DocumentVersionApprovalDecisionOrder
|
||||
filter: DocumentVersionApprovalDecisionFilter
|
||||
): DocumentVersionApprovalDecisionConnection! @goField(forceResolver: true)
|
||||
createdAt: Datetime!
|
||||
updatedAt: Datetime!
|
||||
permission(action: String!): Boolean! @goField(forceResolver: true)
|
||||
}
|
||||
|
||||
input DocumentVersionApprovalDecisionFilter {
|
||||
states: [DocumentVersionApprovalDecisionState!]
|
||||
}
|
||||
|
||||
input DocumentVersionApprovalDecisionOrder {
|
||||
field: DocumentVersionApprovalDecisionOrderField!
|
||||
direction: OrderDirection!
|
||||
}
|
||||
|
||||
type DocumentVersionApprovalDecisionConnection
|
||||
@goModel(
|
||||
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.DocumentVersionApprovalDecisionConnection"
|
||||
) {
|
||||
edges: [DocumentVersionApprovalDecisionEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
totalCount: Int! @goField(forceResolver: true)
|
||||
}
|
||||
|
||||
type DocumentVersionApprovalDecisionEdge {
|
||||
cursor: CursorKey!
|
||||
node: DocumentVersionApprovalDecision!
|
||||
}
|
||||
|
||||
type DocumentVersionApprovalDecision implements Node {
|
||||
id: ID!
|
||||
quorum: DocumentVersionApprovalQuorum! @goField(forceResolver: true)
|
||||
documentVersion: DocumentVersion! @goField(forceResolver: true)
|
||||
approver: Profile! @goField(forceResolver: true)
|
||||
state: DocumentVersionApprovalDecisionState!
|
||||
comment: String
|
||||
decidedAt: Datetime
|
||||
createdAt: Datetime!
|
||||
updatedAt: Datetime!
|
||||
permission(action: String!): Boolean! @goField(forceResolver: true)
|
||||
}
|
||||
|
||||
input ApproveDocumentVersionInput {
|
||||
documentVersionId: ID!
|
||||
comment: String
|
||||
}
|
||||
|
||||
type ApproveDocumentVersionPayload {
|
||||
approvalDecision: DocumentVersionApprovalDecision!
|
||||
}
|
||||
|
||||
input RejectDocumentVersionInput {
|
||||
documentVersionId: ID!
|
||||
comment: String
|
||||
}
|
||||
|
||||
type RejectDocumentVersionPayload {
|
||||
approvalDecision: DocumentVersionApprovalDecision!
|
||||
}
|
||||
|
||||
input AddDocumentVersionApproverInput {
|
||||
documentVersionId: ID!
|
||||
approverId: ID!
|
||||
}
|
||||
|
||||
type AddDocumentVersionApproverPayload {
|
||||
approvalDecisionEdge: DocumentVersionApprovalDecisionEdge!
|
||||
}
|
||||
|
||||
input RemoveDocumentVersionApproverInput {
|
||||
approvalDecisionId: ID!
|
||||
}
|
||||
|
||||
type RemoveDocumentVersionApproverPayload {
|
||||
deletedApprovalDecisionId: ID!
|
||||
documentVersion: DocumentVersion!
|
||||
}
|
||||
|
||||
input RequestSignatureInput {
|
||||
documentVersionId: ID!
|
||||
signatoryId: ID!
|
||||
@@ -5416,16 +5635,6 @@ type BulkRequestSignaturesPayload {
|
||||
documentVersionSignatureEdges: [DocumentVersionSignatureEdge!]!
|
||||
}
|
||||
|
||||
input BulkPublishDocumentVersionsInput {
|
||||
documentIds: [ID!]!
|
||||
changelog: String!
|
||||
}
|
||||
|
||||
type BulkPublishDocumentVersionsPayload {
|
||||
documentVersionEdges: [DocumentVersionEdge!]!
|
||||
documentEdges: [DocumentEdge!]!
|
||||
}
|
||||
|
||||
input BulkDeleteDocumentsInput {
|
||||
documentIds: [ID!]!
|
||||
}
|
||||
@@ -5461,14 +5670,34 @@ type BulkExportDocumentsPayload {
|
||||
exportJobId: ID!
|
||||
}
|
||||
|
||||
input RequestDocumentVersionApprovalInput {
|
||||
documentId: ID!
|
||||
approverIds: [ID!]!
|
||||
changelog: String
|
||||
}
|
||||
|
||||
type RequestDocumentVersionApprovalPayload {
|
||||
approvalQuorum: DocumentVersionApprovalQuorum!
|
||||
}
|
||||
|
||||
input PublishDocumentVersionInput {
|
||||
documentId: ID!
|
||||
changelog: String
|
||||
}
|
||||
|
||||
type PublishDocumentVersionPayload {
|
||||
documentVersion: DocumentVersion!
|
||||
document: Document!
|
||||
documentVersion: DocumentVersion!
|
||||
}
|
||||
|
||||
input BulkPublishDocumentVersionsInput {
|
||||
documentIds: [ID!]!
|
||||
changelog: String!
|
||||
}
|
||||
|
||||
type BulkPublishDocumentVersionsPayload {
|
||||
documentVersions: [DocumentVersion!]!
|
||||
documents: [Document!]!
|
||||
}
|
||||
|
||||
type CreateDraftDocumentVersionPayload {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// 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 types
|
||||
|
||||
import (
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
)
|
||||
|
||||
type (
|
||||
DocumentVersionApprovalDecisionOrderBy OrderBy[coredata.DocumentVersionApprovalDecisionOrderField]
|
||||
|
||||
DocumentVersionApprovalDecisionConnection struct {
|
||||
TotalCount int
|
||||
Edges []*DocumentVersionApprovalDecisionEdge
|
||||
PageInfo PageInfo
|
||||
|
||||
Resolver any
|
||||
ParentID gid.GID
|
||||
Filters *coredata.DocumentVersionApprovalDecisionFilter
|
||||
}
|
||||
)
|
||||
|
||||
func NewDocumentVersionApprovalDecisionConnection(
|
||||
page *page.Page[*coredata.DocumentVersionApprovalDecision, coredata.DocumentVersionApprovalDecisionOrderField],
|
||||
parentType any,
|
||||
parentID gid.GID,
|
||||
filter *coredata.DocumentVersionApprovalDecisionFilter,
|
||||
) *DocumentVersionApprovalDecisionConnection {
|
||||
edges := make([]*DocumentVersionApprovalDecisionEdge, len(page.Data))
|
||||
for i, decision := range page.Data {
|
||||
edges[i] = NewDocumentVersionApprovalDecisionEdge(decision, page.Cursor.OrderBy.Field)
|
||||
}
|
||||
|
||||
return &DocumentVersionApprovalDecisionConnection{
|
||||
Edges: edges,
|
||||
PageInfo: *NewPageInfo(page),
|
||||
|
||||
Resolver: parentType,
|
||||
ParentID: parentID,
|
||||
Filters: filter,
|
||||
}
|
||||
}
|
||||
|
||||
func NewDocumentVersionApprovalDecisionEdge(decision *coredata.DocumentVersionApprovalDecision, orderBy coredata.DocumentVersionApprovalDecisionOrderField) *DocumentVersionApprovalDecisionEdge {
|
||||
return &DocumentVersionApprovalDecisionEdge{
|
||||
Cursor: decision.CursorKey(orderBy),
|
||||
Node: NewDocumentVersionApprovalDecision(decision),
|
||||
}
|
||||
}
|
||||
|
||||
func NewDocumentVersionApprovalDecision(decision *coredata.DocumentVersionApprovalDecision) *DocumentVersionApprovalDecision {
|
||||
return &DocumentVersionApprovalDecision{
|
||||
Quorum: &DocumentVersionApprovalQuorum{
|
||||
ID: decision.QuorumID,
|
||||
},
|
||||
Approver: &Profile{
|
||||
ID: decision.ApproverID,
|
||||
},
|
||||
ID: decision.ID,
|
||||
State: decision.State,
|
||||
Comment: decision.Comment,
|
||||
DecidedAt: decision.DecidedAt,
|
||||
CreatedAt: decision.CreatedAt,
|
||||
UpdatedAt: decision.UpdatedAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// 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 types
|
||||
|
||||
import (
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
)
|
||||
|
||||
type (
|
||||
DocumentVersionApprovalQuorumConnection struct {
|
||||
TotalCount int
|
||||
Edges []*DocumentVersionApprovalQuorumEdge
|
||||
PageInfo PageInfo
|
||||
|
||||
Resolver any
|
||||
ParentID gid.GID
|
||||
}
|
||||
)
|
||||
|
||||
func NewDocumentVersionApprovalQuorumConnection(
|
||||
page *page.Page[*coredata.DocumentVersionApprovalQuorum, coredata.DocumentVersionApprovalQuorumOrderField],
|
||||
parentType any,
|
||||
parentID gid.GID,
|
||||
) *DocumentVersionApprovalQuorumConnection {
|
||||
edges := make([]*DocumentVersionApprovalQuorumEdge, len(page.Data))
|
||||
for i, quorum := range page.Data {
|
||||
edges[i] = NewDocumentVersionApprovalQuorumEdge(quorum, page.Cursor.OrderBy.Field)
|
||||
}
|
||||
|
||||
return &DocumentVersionApprovalQuorumConnection{
|
||||
Edges: edges,
|
||||
PageInfo: *NewPageInfo(page),
|
||||
|
||||
Resolver: parentType,
|
||||
ParentID: parentID,
|
||||
}
|
||||
}
|
||||
|
||||
func NewDocumentVersionApprovalQuorumEdge(
|
||||
quorum *coredata.DocumentVersionApprovalQuorum,
|
||||
orderBy coredata.DocumentVersionApprovalQuorumOrderField,
|
||||
) *DocumentVersionApprovalQuorumEdge {
|
||||
return &DocumentVersionApprovalQuorumEdge{
|
||||
Cursor: quorum.CursorKey(orderBy),
|
||||
Node: NewDocumentVersionApprovalQuorum(quorum),
|
||||
}
|
||||
}
|
||||
|
||||
func NewDocumentVersionApprovalQuorum(quorum *coredata.DocumentVersionApprovalQuorum) *DocumentVersionApprovalQuorum {
|
||||
return &DocumentVersionApprovalQuorum{
|
||||
ID: quorum.ID,
|
||||
DocumentVersion: &DocumentVersion{
|
||||
ID: quorum.VersionID,
|
||||
},
|
||||
Status: quorum.Status,
|
||||
CreatedAt: quorum.CreatedAt,
|
||||
UpdatedAt: quorum.UpdatedAt,
|
||||
}
|
||||
}
|
||||
141
pkg/server/api/console/v1/types/employee_document.go
Normal file
141
pkg/server/api/console/v1/types/employee_document.go
Normal file
@@ -0,0 +1,141 @@
|
||||
// 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 types
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
)
|
||||
|
||||
type EmployeeDocumentFilterMode int
|
||||
|
||||
const (
|
||||
EmployeeDocumentFilterModeSignature EmployeeDocumentFilterMode = iota
|
||||
EmployeeDocumentFilterModeApproval
|
||||
)
|
||||
|
||||
type (
|
||||
EmployeeDocumentConnection struct {
|
||||
Edges []*EmployeeDocumentEdge
|
||||
PageInfo *PageInfo
|
||||
}
|
||||
|
||||
EmployeeDocumentEdge struct {
|
||||
Cursor page.CursorKey
|
||||
Node *EmployeeDocument
|
||||
}
|
||||
|
||||
EmployeeDocument struct {
|
||||
ID gid.GID
|
||||
Title string
|
||||
Description *string
|
||||
DocumentType coredata.DocumentType
|
||||
Classification coredata.DocumentClassification
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
|
||||
FilterMode EmployeeDocumentFilterMode
|
||||
}
|
||||
|
||||
EmployeeDocumentVersionConnection struct {
|
||||
Edges []*EmployeeDocumentVersionEdge
|
||||
PageInfo *PageInfo
|
||||
}
|
||||
|
||||
EmployeeDocumentVersionEdge struct {
|
||||
Cursor page.CursorKey
|
||||
Node *EmployeeDocumentVersion
|
||||
}
|
||||
|
||||
EmployeeDocumentVersion struct {
|
||||
ID gid.GID
|
||||
OrganizationID gid.GID
|
||||
Version int
|
||||
Status coredata.DocumentVersionStatus
|
||||
PublishedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
)
|
||||
|
||||
func NewEmployeeDocumentConnection(
|
||||
p *page.Page[*EmployeeDocument, coredata.DocumentOrderField],
|
||||
) *EmployeeDocumentConnection {
|
||||
var edges = make([]*EmployeeDocumentEdge, len(p.Data))
|
||||
|
||||
for i := range edges {
|
||||
edges[i] = NewEmployeeDocumentEdge(p.Data[i], p.Cursor.OrderBy.Field)
|
||||
}
|
||||
|
||||
return &EmployeeDocumentConnection{
|
||||
Edges: edges,
|
||||
PageInfo: NewPageInfo(p),
|
||||
}
|
||||
}
|
||||
|
||||
func NewEmployeeDocumentEdge(document *EmployeeDocument, orderBy coredata.DocumentOrderField) *EmployeeDocumentEdge {
|
||||
return &EmployeeDocumentEdge{
|
||||
Cursor: document.CursorKey(orderBy),
|
||||
Node: document,
|
||||
}
|
||||
}
|
||||
|
||||
func (d EmployeeDocument) CursorKey(orderBy coredata.DocumentOrderField) page.CursorKey {
|
||||
switch orderBy {
|
||||
case coredata.DocumentOrderFieldCreatedAt:
|
||||
return page.NewCursorKey(d.ID, d.CreatedAt)
|
||||
case coredata.DocumentOrderFieldTitle:
|
||||
return page.NewCursorKey(d.ID, d.Title)
|
||||
case coredata.DocumentOrderFieldDocumentType:
|
||||
return page.NewCursorKey(d.ID, d.DocumentType)
|
||||
}
|
||||
|
||||
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
|
||||
}
|
||||
|
||||
func NewEmployeeDocumentVersionConnection(
|
||||
p *page.Page[*EmployeeDocumentVersion, coredata.DocumentVersionOrderField],
|
||||
) *EmployeeDocumentVersionConnection {
|
||||
var edges = make([]*EmployeeDocumentVersionEdge, len(p.Data))
|
||||
|
||||
for i := range edges {
|
||||
edges[i] = NewEmployeeDocumentVersionEdge(p.Data[i], p.Cursor.OrderBy.Field)
|
||||
}
|
||||
|
||||
return &EmployeeDocumentVersionConnection{
|
||||
Edges: edges,
|
||||
PageInfo: NewPageInfo(p),
|
||||
}
|
||||
}
|
||||
|
||||
func NewEmployeeDocumentVersionEdge(version *EmployeeDocumentVersion, orderBy coredata.DocumentVersionOrderField) *EmployeeDocumentVersionEdge {
|
||||
return &EmployeeDocumentVersionEdge{
|
||||
Cursor: version.CursorKey(orderBy),
|
||||
Node: version,
|
||||
}
|
||||
}
|
||||
|
||||
func (v EmployeeDocumentVersion) CursorKey(orderBy coredata.DocumentVersionOrderField) page.CursorKey {
|
||||
switch orderBy {
|
||||
case coredata.DocumentVersionOrderFieldCreatedAt:
|
||||
return page.NewCursorKey(v.ID, v.CreatedAt)
|
||||
}
|
||||
|
||||
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
// 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 types
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
)
|
||||
|
||||
type (
|
||||
SignableDocumentConnection struct {
|
||||
Edges []*SignableDocumentEdge
|
||||
PageInfo *PageInfo
|
||||
}
|
||||
|
||||
SignableDocumentEdge struct {
|
||||
Cursor page.CursorKey
|
||||
Node *SignableDocument
|
||||
}
|
||||
|
||||
SignableDocument struct {
|
||||
ID gid.GID
|
||||
Title string
|
||||
Description *string
|
||||
DocumentType coredata.DocumentType
|
||||
Classification coredata.DocumentClassification
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
)
|
||||
|
||||
func (SignableDocument) IsNode() {}
|
||||
func (d SignableDocument) GetID() gid.GID { return d.ID }
|
||||
|
||||
func NewSignableDocumentConnection(
|
||||
p *page.Page[*SignableDocument, coredata.DocumentOrderField],
|
||||
) *SignableDocumentConnection {
|
||||
var edges = make([]*SignableDocumentEdge, len(p.Data))
|
||||
|
||||
for i := range edges {
|
||||
edges[i] = NewSignableDocumentEdge(p.Data[i], p.Cursor.OrderBy.Field)
|
||||
}
|
||||
|
||||
return &SignableDocumentConnection{
|
||||
Edges: edges,
|
||||
PageInfo: NewPageInfo(p),
|
||||
}
|
||||
}
|
||||
|
||||
func NewSignableDocumentEdge(document *SignableDocument, orderBy coredata.DocumentOrderField) *SignableDocumentEdge {
|
||||
return &SignableDocumentEdge{
|
||||
Cursor: document.CursorKey(orderBy),
|
||||
Node: document,
|
||||
}
|
||||
}
|
||||
|
||||
func (d SignableDocument) CursorKey(orderBy coredata.DocumentOrderField) page.CursorKey {
|
||||
switch orderBy {
|
||||
case coredata.DocumentOrderFieldCreatedAt:
|
||||
return page.NewCursorKey(d.ID, d.CreatedAt)
|
||||
case coredata.DocumentOrderFieldTitle:
|
||||
return page.NewCursorKey(d.ID, d.Title)
|
||||
case coredata.DocumentOrderFieldDocumentType:
|
||||
return page.NewCursorKey(d.ID, d.DocumentType)
|
||||
}
|
||||
|
||||
panic("unsupported order by")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,41 +0,0 @@
|
||||
// Copyright (c) 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 mcp_v1
|
||||
|
||||
import (
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
)
|
||||
|
||||
func allApproversCursor() *page.Cursor[coredata.MembershipProfileOrderField] {
|
||||
return page.NewCursor(
|
||||
100,
|
||||
nil,
|
||||
page.Head,
|
||||
page.OrderBy[coredata.MembershipProfileOrderField]{
|
||||
Field: coredata.MembershipProfileOrderFieldCreatedAt,
|
||||
Direction: page.OrderDirectionDesc,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func profileIDs(p *page.Page[*coredata.MembershipProfile, coredata.MembershipProfileOrderField]) []gid.GID {
|
||||
ids := make([]gid.GID, len(p.Data))
|
||||
for i, profile := range p.Data {
|
||||
ids[i] = profile.ID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -1685,16 +1685,7 @@ func (r *Resolver) ListControlDocumentsTool(ctx context.Context, req *mcp.CallTo
|
||||
return nil, types.ListControlDocumentsOutput{}, fmt.Errorf("failed to list control documents: %w", err)
|
||||
}
|
||||
|
||||
approverIDsMap := make(map[gid.GID][]gid.GID)
|
||||
for _, d := range docPage.Data {
|
||||
approverPage, err := prb.Documents.ListApprovers(ctx, d.ID, allApproversCursor())
|
||||
if err != nil {
|
||||
return nil, types.ListControlDocumentsOutput{}, fmt.Errorf("failed to list document approvers: %w", err)
|
||||
}
|
||||
approverIDsMap[d.ID] = profileIDs(approverPage)
|
||||
}
|
||||
|
||||
return nil, types.NewListControlDocumentsOutput(docPage, approverIDsMap), nil
|
||||
return nil, types.NewListControlDocumentsOutput(docPage), nil
|
||||
}
|
||||
|
||||
func (r *Resolver) ListControlAuditsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListControlAuditsInput) (*mcp.CallToolResult, types.ListControlAuditsOutput, error) {
|
||||
@@ -2059,16 +2050,7 @@ func (r *Resolver) ListDocumentsTool(ctx context.Context, req *mcp.CallToolReque
|
||||
panic(fmt.Errorf("cannot list organization documents: %w", err))
|
||||
}
|
||||
|
||||
approverIDsMap := make(map[gid.GID][]gid.GID)
|
||||
for _, d := range docPage.Data {
|
||||
approverPage, err := prb.Documents.ListApprovers(ctx, d.ID, allApproversCursor())
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot list document approvers: %w", err))
|
||||
}
|
||||
approverIDsMap[d.ID] = profileIDs(approverPage)
|
||||
}
|
||||
|
||||
return nil, types.NewListDocumentsOutput(docPage, approverIDsMap), nil
|
||||
return nil, types.NewListDocumentsOutput(docPage), nil
|
||||
}
|
||||
|
||||
func (r *Resolver) GetDocumentTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetDocumentInput) (*mcp.CallToolResult, types.GetDocumentOutput, error) {
|
||||
@@ -2081,13 +2063,8 @@ func (r *Resolver) GetDocumentTool(ctx context.Context, req *mcp.CallToolRequest
|
||||
panic(fmt.Errorf("cannot get document: %w", err))
|
||||
}
|
||||
|
||||
approverPage, err := prb.Documents.ListApprovers(ctx, input.ID, allApproversCursor())
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot list document approvers: %w", err))
|
||||
}
|
||||
|
||||
return nil, types.GetDocumentOutput{
|
||||
Document: types.NewDocument(document, profileIDs(approverPage)),
|
||||
Document: types.NewDocument(document),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -2107,7 +2084,6 @@ func (r *Resolver) AddDocumentTool(ctx context.Context, req *mcp.CallToolRequest
|
||||
OrganizationID: input.OrganizationID,
|
||||
Title: input.Title,
|
||||
Content: input.Content,
|
||||
ApproverIDs: input.ApproverIds,
|
||||
Classification: input.Classification,
|
||||
DocumentType: input.DocumentType,
|
||||
TrustCenterVisibility: trustCenterVisibility,
|
||||
@@ -2117,7 +2093,7 @@ func (r *Resolver) AddDocumentTool(ctx context.Context, req *mcp.CallToolRequest
|
||||
panic(fmt.Errorf("cannot create document: %w", err))
|
||||
}
|
||||
|
||||
return nil, types.NewAddDocumentOutput(document, documentVersion, input.ApproverIds, input.ApproverIds), nil
|
||||
return nil, types.NewAddDocumentOutput(document, documentVersion), nil
|
||||
}
|
||||
|
||||
func (r *Resolver) UpdateDocumentTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UpdateDocumentInput) (*mcp.CallToolResult, types.UpdateDocumentOutput, error) {
|
||||
@@ -2130,7 +2106,6 @@ func (r *Resolver) UpdateDocumentTool(ctx context.Context, req *mcp.CallToolRequ
|
||||
probo.UpdateDocumentRequest{
|
||||
DocumentID: input.ID,
|
||||
Title: input.Title,
|
||||
ApproverIDs: input.ApproverIds,
|
||||
Classification: input.Classification,
|
||||
DocumentType: input.DocumentType,
|
||||
TrustCenterVisibility: input.TrustCenterVisibility,
|
||||
@@ -2140,13 +2115,8 @@ func (r *Resolver) UpdateDocumentTool(ctx context.Context, req *mcp.CallToolRequ
|
||||
panic(fmt.Errorf("cannot update document: %w", err))
|
||||
}
|
||||
|
||||
approverPage, err := svc.Documents.ListApprovers(ctx, input.ID, allApproversCursor())
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot list document approvers: %w", err))
|
||||
}
|
||||
|
||||
return nil, types.UpdateDocumentOutput{
|
||||
Document: types.NewDocument(document, profileIDs(approverPage)),
|
||||
Document: types.NewDocument(document),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -2172,16 +2142,7 @@ func (r *Resolver) ListDocumentVersionsTool(ctx context.Context, req *mcp.CallTo
|
||||
panic(fmt.Errorf("cannot list document versions: %w", err))
|
||||
}
|
||||
|
||||
approverIDsMap := make(map[gid.GID][]gid.GID)
|
||||
for _, v := range versionPage.Data {
|
||||
approverPage, err := svc.Documents.ListVersionApprovers(ctx, v.ID, allApproversCursor())
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot list document version approvers: %w", err))
|
||||
}
|
||||
approverIDsMap[v.ID] = profileIDs(approverPage)
|
||||
}
|
||||
|
||||
return nil, types.NewListDocumentVersionsOutput(versionPage, approverIDsMap), nil
|
||||
return nil, types.NewListDocumentVersionsOutput(versionPage), nil
|
||||
}
|
||||
|
||||
func (r *Resolver) GetDocumentVersionTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetDocumentVersionInput) (*mcp.CallToolResult, types.GetDocumentVersionOutput, error) {
|
||||
@@ -2194,13 +2155,8 @@ func (r *Resolver) GetDocumentVersionTool(ctx context.Context, req *mcp.CallTool
|
||||
panic(fmt.Errorf("cannot get document version: %w", err))
|
||||
}
|
||||
|
||||
approverPage, err := svc.Documents.ListVersionApprovers(ctx, input.ID, allApproversCursor())
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot list document version approvers: %w", err))
|
||||
}
|
||||
|
||||
return nil, types.GetDocumentVersionOutput{
|
||||
DocumentVersion: types.NewDocumentVersion(version, profileIDs(approverPage)),
|
||||
DocumentVersion: types.NewDocumentVersion(version),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -2214,13 +2170,8 @@ func (r *Resolver) CreateDraftDocumentVersionTool(ctx context.Context, req *mcp.
|
||||
panic(fmt.Errorf("cannot create draft document version: %w", err))
|
||||
}
|
||||
|
||||
approverPage, err := svc.Documents.ListVersionApprovers(ctx, draftVersion.ID, allApproversCursor())
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot list document version approvers: %w", err))
|
||||
}
|
||||
|
||||
return nil, types.CreateDraftDocumentVersionOutput{
|
||||
DocumentVersion: types.NewDocumentVersion(draftVersion, profileIDs(approverPage)),
|
||||
DocumentVersion: types.NewDocumentVersion(draftVersion),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -2240,13 +2191,8 @@ func (r *Resolver) UpdateDocumentVersionTool(ctx context.Context, req *mcp.CallT
|
||||
panic(fmt.Errorf("cannot update document version: %w", err))
|
||||
}
|
||||
|
||||
versionApproverPage, err := svc.Documents.ListVersionApprovers(ctx, documentVersion.ID, allApproversCursor())
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot list document version approvers: %w", err))
|
||||
}
|
||||
|
||||
return nil, types.UpdateDocumentVersionOutput{
|
||||
DocumentVersion: types.NewDocumentVersion(documentVersion, profileIDs(versionApproverPage)),
|
||||
DocumentVersion: types.NewDocumentVersion(documentVersion),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -2262,19 +2208,9 @@ func (r *Resolver) PublishDocumentVersionTool(ctx context.Context, req *mcp.Call
|
||||
panic(fmt.Errorf("cannot publish document version: %w", err))
|
||||
}
|
||||
|
||||
docApproverPage, err := svc.Documents.ListApprovers(ctx, document.ID, allApproversCursor())
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot list document approvers: %w", err))
|
||||
}
|
||||
|
||||
versionApproverPage, err := svc.Documents.ListVersionApprovers(ctx, documentVersion.ID, allApproversCursor())
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot list document version approvers: %w", err))
|
||||
}
|
||||
|
||||
return nil, types.PublishDocumentVersionOutput{
|
||||
Document: types.NewDocument(document, profileIDs(docApproverPage)),
|
||||
DocumentVersion: types.NewDocumentVersion(documentVersion, profileIDs(versionApproverPage)),
|
||||
Document: types.NewDocument(document),
|
||||
DocumentVersion: types.NewDocumentVersion(documentVersion),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -3243,13 +3179,8 @@ func (r *Resolver) ArchiveDocumentTool(ctx context.Context, req *mcp.CallToolReq
|
||||
return nil, types.ArchiveDocumentOutput{}, fmt.Errorf("cannot archive document: %w", err)
|
||||
}
|
||||
|
||||
approverPage, err := svc.Documents.ListApprovers(ctx, input.ID, allApproversCursor())
|
||||
if err != nil {
|
||||
return nil, types.ArchiveDocumentOutput{}, fmt.Errorf("cannot list document approvers: %w", err)
|
||||
}
|
||||
|
||||
return nil, types.ArchiveDocumentOutput{
|
||||
Document: types.NewDocument(document, profileIDs(approverPage)),
|
||||
Document: types.NewDocument(document),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -3263,13 +3194,8 @@ func (r *Resolver) UnarchiveDocumentTool(ctx context.Context, req *mcp.CallToolR
|
||||
return nil, types.UnarchiveDocumentOutput{}, fmt.Errorf("cannot unarchive document: %w", err)
|
||||
}
|
||||
|
||||
approverPage, err := svc.Documents.ListApprovers(ctx, input.ID, allApproversCursor())
|
||||
if err != nil {
|
||||
return nil, types.UnarchiveDocumentOutput{}, fmt.Errorf("cannot list document approvers: %w", err)
|
||||
}
|
||||
|
||||
return nil, types.UnarchiveDocumentOutput{
|
||||
Document: types.NewDocument(document, profileIDs(approverPage)),
|
||||
Document: types.NewDocument(document),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -3374,3 +3300,27 @@ func (r *Resolver) GetAuditLogEntryTool(ctx context.Context, req *mcp.CallToolRe
|
||||
AuditLogEntry: types.NewAuditLogEntry(entry),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Resolver) RequestDocumentVersionApprovalTool(ctx context.Context, req *mcp.CallToolRequest, input *types.RequestDocumentVersionApprovalInput) (*mcp.CallToolResult, types.RequestDocumentVersionApprovalOutput, error) {
|
||||
r.MustAuthorize(ctx, input.DocumentID, probo.ActionDocumentVersionRequestApproval)
|
||||
|
||||
svc := r.ProboService(ctx, input.DocumentID)
|
||||
|
||||
quorum, err := svc.DocumentApprovals.RequestApproval(ctx, probo.RequestApprovalRequest{
|
||||
DocumentID: input.DocumentID,
|
||||
ApproverIDs: input.ApproverIds,
|
||||
Changelog: input.Changelog,
|
||||
})
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot request document version approval: %w", err))
|
||||
}
|
||||
|
||||
documentVersion, err := svc.Documents.GetVersion(ctx, quorum.VersionID)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot get document version: %w", err))
|
||||
}
|
||||
|
||||
return nil, types.RequestDocumentVersionApprovalOutput{
|
||||
DocumentVersion: types.NewDocumentVersion(documentVersion),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -5184,7 +5184,6 @@ components:
|
||||
required:
|
||||
- id
|
||||
- organization_id
|
||||
- approver_ids
|
||||
- title
|
||||
- document_type
|
||||
- classification
|
||||
@@ -5199,11 +5198,6 @@ components:
|
||||
organization_id:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Organization ID
|
||||
approver_ids:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Approver IDs
|
||||
title:
|
||||
type: string
|
||||
description: Document title
|
||||
@@ -5246,7 +5240,6 @@ components:
|
||||
- organization_id
|
||||
- document_id
|
||||
- title
|
||||
- approver_ids
|
||||
- version_number
|
||||
- classification
|
||||
- content
|
||||
@@ -5267,11 +5260,6 @@ components:
|
||||
title:
|
||||
type: string
|
||||
description: Document version title
|
||||
approver_ids:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Approver IDs
|
||||
version_number:
|
||||
type: integer
|
||||
description: Version number
|
||||
@@ -5418,7 +5406,6 @@ components:
|
||||
- organization_id
|
||||
- title
|
||||
- content
|
||||
- approver_ids
|
||||
- classification
|
||||
- document_type
|
||||
properties:
|
||||
@@ -5431,11 +5418,6 @@ components:
|
||||
content:
|
||||
type: string
|
||||
description: Document content
|
||||
approver_ids:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Approver IDs
|
||||
classification:
|
||||
$ref: "#/components/schemas/DocumentClassification"
|
||||
description: Document classification
|
||||
@@ -5468,11 +5450,6 @@ components:
|
||||
title:
|
||||
type: string
|
||||
description: Document title
|
||||
approver_ids:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Approver IDs
|
||||
classification:
|
||||
$ref: "#/components/schemas/DocumentClassification"
|
||||
description: Document classification
|
||||
@@ -5639,7 +5616,7 @@ components:
|
||||
description: Document ID
|
||||
changelog:
|
||||
type: string
|
||||
description: Changelog
|
||||
description: Changelog for this version
|
||||
|
||||
PublishDocumentVersionOutput:
|
||||
type: object
|
||||
@@ -5652,6 +5629,32 @@ components:
|
||||
document_version:
|
||||
$ref: "#/components/schemas/DocumentVersion"
|
||||
|
||||
RequestDocumentVersionApprovalInput:
|
||||
type: object
|
||||
required:
|
||||
- document_id
|
||||
- approver_ids
|
||||
properties:
|
||||
document_id:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Document ID
|
||||
approver_ids:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Approver profile IDs
|
||||
changelog:
|
||||
type: string
|
||||
description: Changelog for this version
|
||||
|
||||
RequestDocumentVersionApprovalOutput:
|
||||
type: object
|
||||
required:
|
||||
- document_version
|
||||
properties:
|
||||
document_version:
|
||||
$ref: "#/components/schemas/DocumentVersion"
|
||||
|
||||
DeleteDocumentInput:
|
||||
type: object
|
||||
required:
|
||||
@@ -7476,6 +7479,14 @@ tools:
|
||||
$ref: "#/components/schemas/PublishDocumentVersionInput"
|
||||
outputSchema:
|
||||
$ref: "#/components/schemas/PublishDocumentVersionOutput"
|
||||
- name: requestDocumentVersionApproval
|
||||
description: Request approval for a document version
|
||||
hints:
|
||||
readonly: false
|
||||
inputSchema:
|
||||
$ref: "#/components/schemas/RequestDocumentVersionApprovalInput"
|
||||
outputSchema:
|
||||
$ref: "#/components/schemas/RequestDocumentVersionApprovalOutput"
|
||||
- name: deleteDocument
|
||||
description: Delete a document
|
||||
hints:
|
||||
|
||||
@@ -16,15 +16,13 @@ package types
|
||||
|
||||
import (
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
)
|
||||
|
||||
func NewDocument(d *coredata.Document, approverIDs []gid.GID) *Document {
|
||||
func NewDocument(d *coredata.Document) *Document {
|
||||
return &Document{
|
||||
ID: d.ID,
|
||||
OrganizationID: d.OrganizationID,
|
||||
ApproverIds: approverIDs,
|
||||
Title: d.Title,
|
||||
DocumentType: d.DocumentType,
|
||||
Classification: d.Classification,
|
||||
@@ -37,10 +35,10 @@ func NewDocument(d *coredata.Document, approverIDs []gid.GID) *Document {
|
||||
}
|
||||
}
|
||||
|
||||
func NewListControlDocumentsOutput(documentPage *page.Page[*coredata.Document, coredata.DocumentOrderField], approverIDsMap map[gid.GID][]gid.GID) ListControlDocumentsOutput {
|
||||
func NewListControlDocumentsOutput(documentPage *page.Page[*coredata.Document, coredata.DocumentOrderField]) ListControlDocumentsOutput {
|
||||
documents := make([]*Document, 0, len(documentPage.Data))
|
||||
for _, d := range documentPage.Data {
|
||||
documents = append(documents, NewDocument(d, approverIDsMap[d.ID]))
|
||||
documents = append(documents, NewDocument(d))
|
||||
}
|
||||
|
||||
var nextCursor *page.CursorKey
|
||||
@@ -55,10 +53,10 @@ func NewListControlDocumentsOutput(documentPage *page.Page[*coredata.Document, c
|
||||
}
|
||||
}
|
||||
|
||||
func NewListDocumentsOutput(documentPage *page.Page[*coredata.Document, coredata.DocumentOrderField], approverIDsMap map[gid.GID][]gid.GID) ListDocumentsOutput {
|
||||
func NewListDocumentsOutput(documentPage *page.Page[*coredata.Document, coredata.DocumentOrderField]) ListDocumentsOutput {
|
||||
documents := make([]*Document, 0, len(documentPage.Data))
|
||||
for _, d := range documentPage.Data {
|
||||
documents = append(documents, NewDocument(d, approverIDsMap[d.ID]))
|
||||
documents = append(documents, NewDocument(d))
|
||||
}
|
||||
|
||||
var nextCursor *page.CursorKey
|
||||
@@ -73,20 +71,19 @@ func NewListDocumentsOutput(documentPage *page.Page[*coredata.Document, coredata
|
||||
}
|
||||
}
|
||||
|
||||
func NewAddDocumentOutput(doc *coredata.Document, docVersion *coredata.DocumentVersion, docApproverIDs []gid.GID, versionApproverIDs []gid.GID) AddDocumentOutput {
|
||||
func NewAddDocumentOutput(doc *coredata.Document, docVersion *coredata.DocumentVersion) AddDocumentOutput {
|
||||
return AddDocumentOutput{
|
||||
Document: NewDocument(doc, docApproverIDs),
|
||||
DocumentVersion: NewDocumentVersion(docVersion, versionApproverIDs),
|
||||
Document: NewDocument(doc),
|
||||
DocumentVersion: NewDocumentVersion(docVersion),
|
||||
}
|
||||
}
|
||||
|
||||
func NewDocumentVersion(dv *coredata.DocumentVersion, approverIDs []gid.GID) *DocumentVersion {
|
||||
func NewDocumentVersion(dv *coredata.DocumentVersion) *DocumentVersion {
|
||||
return &DocumentVersion{
|
||||
ID: dv.ID,
|
||||
OrganizationID: dv.OrganizationID,
|
||||
DocumentID: dv.DocumentID,
|
||||
Title: dv.Title,
|
||||
ApproverIds: approverIDs,
|
||||
VersionNumber: dv.VersionNumber,
|
||||
Classification: dv.Classification,
|
||||
Content: dv.Content,
|
||||
@@ -98,10 +95,10 @@ func NewDocumentVersion(dv *coredata.DocumentVersion, approverIDs []gid.GID) *Do
|
||||
}
|
||||
}
|
||||
|
||||
func NewListDocumentVersionsOutput(versionPage *page.Page[*coredata.DocumentVersion, coredata.DocumentVersionOrderField], approverIDsMap map[gid.GID][]gid.GID) ListDocumentVersionsOutput {
|
||||
func NewListDocumentVersionsOutput(versionPage *page.Page[*coredata.DocumentVersion, coredata.DocumentVersionOrderField]) ListDocumentVersionsOutput {
|
||||
versions := make([]*DocumentVersion, 0, len(versionPage.Data))
|
||||
for _, v := range versionPage.Data {
|
||||
versions = append(versions, NewDocumentVersion(v, approverIDsMap[v.ID]))
|
||||
versions = append(versions, NewDocumentVersion(v))
|
||||
}
|
||||
|
||||
var nextCursor *page.CursorKey
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"errors"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/docgen"
|
||||
@@ -160,19 +161,50 @@ func (s *DocumentService) exportPDFData(
|
||||
return fmt.Errorf("cannot load latest published document version: %w", err)
|
||||
}
|
||||
|
||||
// Load approvers
|
||||
docApprovers := &coredata.DocumentApprovers{}
|
||||
if err := docApprovers.LoadByDocumentID(ctx, conn, s.svc.scope, documentID); err != nil {
|
||||
return fmt.Errorf("cannot load document approvers: %w", err)
|
||||
}
|
||||
lastQuorum := &coredata.DocumentVersionApprovalQuorum{}
|
||||
if err := lastQuorum.LoadLastByDocumentVersionID(ctx, conn, s.svc.scope, version.ID); err != nil {
|
||||
if !errors.Is(err, coredata.ErrResourceNotFound) {
|
||||
return fmt.Errorf("cannot load last approval quorum: %w", err)
|
||||
}
|
||||
} else if lastQuorum.Status == coredata.DocumentVersionApprovalQuorumStatusApproved {
|
||||
approvedDecisions := &coredata.DocumentVersionApprovalDecisions{}
|
||||
approvedFilter := coredata.NewDocumentVersionApprovalDecisionFilter(
|
||||
coredata.DocumentVersionApprovalDecisionStates{coredata.DocumentVersionApprovalDecisionStateApproved},
|
||||
)
|
||||
if err := approvedDecisions.LoadByQuorumID(
|
||||
ctx,
|
||||
conn,
|
||||
s.svc.scope,
|
||||
lastQuorum.ID,
|
||||
page.NewCursor(
|
||||
100,
|
||||
nil,
|
||||
page.Head,
|
||||
page.OrderBy[coredata.DocumentVersionApprovalDecisionOrderField]{
|
||||
Field: coredata.DocumentVersionApprovalDecisionOrderFieldCreatedAt,
|
||||
Direction: page.OrderDirectionAsc,
|
||||
},
|
||||
),
|
||||
approvedFilter,
|
||||
); err != nil {
|
||||
return fmt.Errorf("cannot load approved decisions: %w", err)
|
||||
}
|
||||
|
||||
profiles := coredata.MembershipProfiles{}
|
||||
if err := profiles.LoadByIDs(ctx, conn, s.svc.scope, docApprovers.ApproverProfileIDs()); err != nil {
|
||||
return fmt.Errorf("cannot load document approver profiles: %w", err)
|
||||
}
|
||||
approverProfileIDs := make([]gid.GID, 0, len(*approvedDecisions))
|
||||
for _, d := range *approvedDecisions {
|
||||
approverProfileIDs = append(approverProfileIDs, d.ApproverID)
|
||||
}
|
||||
|
||||
for _, p := range profiles {
|
||||
approverNames = append(approverNames, p.FullName)
|
||||
if len(approverProfileIDs) > 0 {
|
||||
profiles := coredata.MembershipProfiles{}
|
||||
if err := profiles.LoadByIDs(ctx, conn, s.svc.scope, approverProfileIDs); err != nil {
|
||||
return fmt.Errorf("cannot load approver profiles: %w", err)
|
||||
}
|
||||
|
||||
for _, p := range profiles {
|
||||
approverNames = append(approverNames, p.FullName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := organization.LoadByID(ctx, conn, s.svc.scope, document.OrganizationID); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user