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 { 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 ComponentProps, Suspense, useState } from "react";
|
||||||
import { type Control, Controller, type FieldValues, type Path } from "react-hook-form";
|
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 => (
|
{availablePeople.map(person => (
|
||||||
<Option key={person.id} value={person.id} className="flex gap-2">
|
<Option key={person.id} value={person.id} className="flex gap-2">
|
||||||
<Avatar
|
|
||||||
name={person.fullName}
|
|
||||||
size="s"
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span>{person.fullName}</span>
|
<span>{person.fullName}</span>
|
||||||
{person.emailAddress && (
|
{person.emailAddress && (
|
||||||
@@ -125,10 +121,6 @@ function PeopleMultiSelectWithQuery<T extends FieldValues = FieldValues>(
|
|||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{selectedPeople.map(person => (
|
{selectedPeople.map(person => (
|
||||||
<Badge key={person.id} variant="neutral" className="flex items-center gap-2">
|
<Badge key={person.id} variant="neutral" className="flex items-center gap-2">
|
||||||
<Avatar
|
|
||||||
name={person.fullName}
|
|
||||||
size="s"
|
|
||||||
/>
|
|
||||||
<span>{person.fullName}</span>
|
<span>{person.fullName}</span>
|
||||||
{!props.disabled && (
|
{!props.disabled && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useFormWithSchema } from "../useFormWithSchema";
|
|||||||
export const documentSchema = z.object({
|
export const documentSchema = z.object({
|
||||||
title: z.string().min(1, "Title is required"),
|
title: z.string().min(1, "Title is required"),
|
||||||
content: z.string().min(1, "Content 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"]),
|
documentType: z.enum(["OTHER", "GOVERNANCE", "POLICY", "PROCEDURE", "PLAN", "REGISTER", "RECORD", "REPORT", "TEMPLATE"]),
|
||||||
classification: z.enum(["PUBLIC", "INTERNAL", "CONFIDENTIAL", "SECRET"]),
|
classification: z.enum(["PUBLIC", "INTERNAL", "CONFIDENTIAL", "SECRET"]),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const documentFragment = graphql`
|
|||||||
latestPublishedVersion: versions(
|
latestPublishedVersion: versions(
|
||||||
first: 1
|
first: 1
|
||||||
orderBy: { field: CREATED_AT, direction: DESC }
|
orderBy: { field: CREATED_AT, direction: DESC }
|
||||||
filter: { status: PUBLISHED }
|
filter: { statuses: [PUBLISHED] }
|
||||||
) {
|
) {
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import { useTranslate } from "@probo/i18n";
|
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 { type PreloadedQuery, usePreloadedQuery } from "react-relay";
|
||||||
import { Outlet, useParams } from "react-router";
|
import { Outlet, useParams } from "react-router";
|
||||||
import { graphql } from "relay-runtime";
|
import { graphql } from "relay-runtime";
|
||||||
|
|
||||||
import type { DocumentLayoutQuery } from "#/__generated__/core/DocumentLayoutQuery.graphql";
|
import type { DocumentLayoutQuery } from "#/__generated__/core/DocumentLayoutQuery.graphql";
|
||||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
|
||||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||||
|
|
||||||
import { DocumentActionsDropdownn } from "./_components/DocumentActionsDropdown";
|
import { DocumentActionsDropdownn } from "./_components/DocumentActionsDropdown";
|
||||||
import { DocumentLayoutDrawer } from "./_components/DocumentLayoutDrawer";
|
import { DocumentLayoutDrawer } from "./_components/DocumentLayoutDrawer";
|
||||||
import { DocumentTitleForm } from "./_components/DocumentTitleForm";
|
import { DocumentTitleForm } from "./_components/DocumentTitleForm";
|
||||||
import { DocumentVersionsDropdown } from "./_components/DocumentVersionsDropdown";
|
import { DocumentVersionsDropdown } from "./_components/DocumentVersionsDropdown";
|
||||||
|
import { PublishDialog, type PublishDialogRef } from "./_components/PublishDialog";
|
||||||
|
|
||||||
export const documentLayoutQuery = graphql`
|
export const documentLayoutQuery = graphql`
|
||||||
query DocumentLayoutQuery($documentId: ID! $versionId: ID! $versionSpecified: Boolean!) {
|
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 }) {
|
signedSignatures: signatures(first: 0 filter: { states: [SIGNED], activeContract: true }) {
|
||||||
totalCount
|
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) {
|
document: node(id: $documentId) {
|
||||||
@@ -38,6 +53,7 @@ export const documentLayoutQuery = graphql`
|
|||||||
title
|
title
|
||||||
status
|
status
|
||||||
canPublish: permission(action: "core:document-version:publish")
|
canPublish: permission(action: "core:document-version:publish")
|
||||||
|
...PublishDialog_documentFragment
|
||||||
controlInfo: controls(first: 0) {
|
controlInfo: controls(first: 0) {
|
||||||
totalCount
|
totalCount
|
||||||
}
|
}
|
||||||
@@ -58,6 +74,20 @@ export const documentLayoutQuery = graphql`
|
|||||||
signedSignatures: signatures(first: 0 filter: { states: [SIGNED], activeContract: true }) {
|
signedSignatures: signatures(first: 0 filter: { states: [SIGNED], activeContract: true }) {
|
||||||
totalCount
|
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 }) {
|
export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQuery>; onRefetch: () => void }) {
|
||||||
const { queryRef, onRefetch } = props;
|
const { queryRef, onRefetch } = props;
|
||||||
|
|
||||||
@@ -86,6 +104,14 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
|
|||||||
|
|
||||||
const { __ } = useTranslate();
|
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);
|
const { document, version } = usePreloadedQuery<DocumentLayoutQuery>(documentLayoutQuery, queryRef);
|
||||||
if (document.__typename !== "Document" || (version && version.__typename !== "DocumentVersion")) {
|
if (document.__typename !== "Document" || (version && version.__typename !== "DocumentVersion")) {
|
||||||
throw new Error("invalid node type");
|
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
|
// 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 currentVersion = version ?? lastVersion as NonNullable<typeof version | typeof lastVersion>;
|
||||||
const isDraft = currentVersion.status === "DRAFT";
|
const isDraft = currentVersion.status === "DRAFT";
|
||||||
|
const isPublished = currentVersion.status === "PUBLISHED";
|
||||||
const [publishDocumentVersion, isPublishing] = useMutationWithToasts(
|
const lastQuorum = currentVersion.approvalQuorums?.edges?.[0]?.node ?? null;
|
||||||
publishDocumentVersionMutation,
|
const hasApprovals = lastQuorum != null;
|
||||||
{
|
const hasPendingApproval = lastQuorum?.status === "PENDING";
|
||||||
successMessage: __("Document published successfully."),
|
|
||||||
errorMessage: __("Failed to publish document"),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePublish = async () => {
|
|
||||||
await publishDocumentVersion({
|
|
||||||
variables: {
|
|
||||||
input: { documentId: document.id },
|
|
||||||
},
|
|
||||||
onSuccess: onRefetch,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const urlPrefix = versionId
|
const urlPrefix = versionId
|
||||||
? `/organizations/${organizationId}/documents/${document.id}/versions/${versionId}`
|
? `/organizations/${organizationId}/documents/${document.id}/versions/${versionId}`
|
||||||
@@ -140,9 +153,8 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{isDraft && document.canPublish && (
|
{isDraft && document.canPublish && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => void handlePublish()}
|
icon={IconUpload}
|
||||||
icon={IconCheckmark1}
|
onClick={() => publishDialogRef.current?.open()}
|
||||||
disabled={isPublishing}
|
|
||||||
>
|
>
|
||||||
{__("Publish")}
|
{__("Publish")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -166,7 +178,17 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
|
|||||||
{__("Controls")}
|
{__("Controls")}
|
||||||
<TabBadge>{document.controlInfo.totalCount}</TabBadge>
|
<TabBadge>{document.controlInfo.totalCount}</TabBadge>
|
||||||
</TabLink>
|
</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`}>
|
<TabLink to={`${urlPrefix}/signatures`}>
|
||||||
{__("Signatures")}
|
{__("Signatures")}
|
||||||
<TabBadge>
|
<TabBadge>
|
||||||
@@ -178,10 +200,18 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
|
|||||||
)}
|
)}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<Outlet />
|
<Outlet context={{ onRefetch, approvalRequestedAt }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DocumentLayoutDrawer documentFragmentRef={document} versionFragmentRef={currentVersion} />
|
<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 { ControlledField } from "#/components/form/ControlledField";
|
||||||
import { DocumentClassificationOptions } from "#/components/form/DocumentClassificationOptions";
|
import { DocumentClassificationOptions } from "#/components/form/DocumentClassificationOptions";
|
||||||
import { DocumentTypeOptions } from "#/components/form/DocumentTypeOptions";
|
import { DocumentTypeOptions } from "#/components/form/DocumentTypeOptions";
|
||||||
import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField";
|
|
||||||
import { documentSchema, useDocumentForm } from "#/hooks/forms/useDocumentForm";
|
import { documentSchema, useDocumentForm } from "#/hooks/forms/useDocumentForm";
|
||||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||||
@@ -148,18 +147,6 @@ export function CreateDocumentDialog({ trigger, connection }: Props) {
|
|||||||
</ControlledField>
|
</ControlledField>
|
||||||
</PropertyRow>
|
</PropertyRow>
|
||||||
|
|
||||||
<PropertyRow
|
|
||||||
id="approverIds"
|
|
||||||
label={__("Approvers")}
|
|
||||||
error={errors.approverIds?.message}
|
|
||||||
>
|
|
||||||
<PeopleMultiSelectField
|
|
||||||
name="approverIds"
|
|
||||||
control={control}
|
|
||||||
organizationId={organizationId}
|
|
||||||
placeholder={__("Add approvers...")}
|
|
||||||
/>
|
|
||||||
</PropertyRow>
|
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { documentClassifications, documentTypes, formatDate, getDocumentClassificationLabel, getDocumentTypeLabel } from "@probo/helpers";
|
import { documentClassifications, documentTypes, formatDate, getDocumentClassificationLabel, getDocumentTypeLabel } from "@probo/helpers";
|
||||||
import { useTranslate } from "@probo/i18n";
|
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 { useState } from "react";
|
||||||
import { useFragment } from "react-relay";
|
import { useFragment } from "react-relay";
|
||||||
import { graphql } from "relay-runtime";
|
import { graphql } from "relay-runtime";
|
||||||
@@ -12,10 +12,8 @@ import type { DocumentLayoutDrawerMutation } from "#/__generated__/core/Document
|
|||||||
import { ControlledField } from "#/components/form/ControlledField";
|
import { ControlledField } from "#/components/form/ControlledField";
|
||||||
import { DocumentClassificationOptions } from "#/components/form/DocumentClassificationOptions";
|
import { DocumentClassificationOptions } from "#/components/form/DocumentClassificationOptions";
|
||||||
import { DocumentTypeOptions } from "#/components/form/DocumentTypeOptions";
|
import { DocumentTypeOptions } from "#/components/form/DocumentTypeOptions";
|
||||||
import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField";
|
|
||||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
|
||||||
|
|
||||||
const documentFragment = graphql`
|
const documentFragment = graphql`
|
||||||
fragment DocumentLayoutDrawer_documentFragment on Document {
|
fragment DocumentLayoutDrawer_documentFragment on Document {
|
||||||
@@ -24,14 +22,6 @@ const documentFragment = graphql`
|
|||||||
status
|
status
|
||||||
archivedAt
|
archivedAt
|
||||||
canUpdate: permission(action: "core:document:update")
|
canUpdate: permission(action: "core:document:update")
|
||||||
approvers(first: 100) {
|
|
||||||
edges {
|
|
||||||
node {
|
|
||||||
id
|
|
||||||
fullName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -53,21 +43,12 @@ const updateDocumentMutation = graphql`
|
|||||||
id
|
id
|
||||||
documentType
|
documentType
|
||||||
classification
|
classification
|
||||||
approvers(first: 100) {
|
|
||||||
edges {
|
|
||||||
node {
|
|
||||||
id
|
|
||||||
fullName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
approverIds: z.array(z.string()).min(1, "At least one approver is required"),
|
|
||||||
documentType: z.enum(documentTypes),
|
documentType: z.enum(documentTypes),
|
||||||
classification: z.enum(documentClassifications),
|
classification: z.enum(documentClassifications),
|
||||||
});
|
});
|
||||||
@@ -78,10 +59,8 @@ export function DocumentLayoutDrawer(props: {
|
|||||||
}) {
|
}) {
|
||||||
const { documentFragmentRef, versionFragmentRef } = props;
|
const { documentFragmentRef, versionFragmentRef } = props;
|
||||||
|
|
||||||
const organizationId = useOrganizationId();
|
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
|
|
||||||
const [isEditingApprover, setIsEditingApprover] = useState(false);
|
|
||||||
const [isEditingType, setIsEditingType] = useState(false);
|
const [isEditingType, setIsEditingType] = useState(false);
|
||||||
const [isEditingClassification, setIsEditingClassification] = useState(false);
|
const [isEditingClassification, setIsEditingClassification] = useState(false);
|
||||||
|
|
||||||
@@ -91,13 +70,10 @@ export function DocumentLayoutDrawer(props: {
|
|||||||
const isDraft = version.status === "DRAFT";
|
const isDraft = version.status === "DRAFT";
|
||||||
const canEdit = document.canUpdate;
|
const canEdit = document.canUpdate;
|
||||||
|
|
||||||
const approvers = document.approvers.edges.map(e => e.node);
|
|
||||||
|
|
||||||
const { control, handleSubmit, reset } = useFormWithSchema(
|
const { control, handleSubmit, reset } = useFormWithSchema(
|
||||||
schema,
|
schema,
|
||||||
{
|
{
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
approverIds: approvers.map(a => a.id),
|
|
||||||
documentType: document.documentType,
|
documentType: document.documentType,
|
||||||
classification: version.classification,
|
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: {
|
const handleUpdateDocumentType = async (data: {
|
||||||
documentType: (typeof documentTypes)[number];
|
documentType: (typeof documentTypes)[number];
|
||||||
}) => {
|
}) => {
|
||||||
@@ -164,42 +126,6 @@ export function DocumentLayoutDrawer(props: {
|
|||||||
<div className="text-base text-txt-primary font-medium mb-4">
|
<div className="text-base text-txt-primary font-medium mb-4">
|
||||||
{__("Properties")}
|
{__("Properties")}
|
||||||
</div>
|
</div>
|
||||||
<PropertyRow label={__("Approvers")}>
|
|
||||||
{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")}>
|
<PropertyRow label={__("Type")}>
|
||||||
{isEditingType
|
{isEditingType
|
||||||
? (
|
? (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { documentTypes, getDocumentTypeLabel, sprintf } from "@probo/helpers";
|
import { documentTypes, getDocumentTypeLabel, sprintf } from "@probo/helpers";
|
||||||
import { useList } from "@probo/hooks";
|
import { useList } from "@probo/hooks";
|
||||||
import { useTranslate } from "@probo/i18n";
|
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 { type ComponentProps, use, useEffect, useRef, useState, useTransition } from "react";
|
||||||
import { usePaginationFragment } from "react-relay";
|
import { usePaginationFragment } from "react-relay";
|
||||||
import { ConnectionHandler, graphql } from "relay-runtime";
|
import { ConnectionHandler, graphql } from "relay-runtime";
|
||||||
@@ -295,7 +295,7 @@ export function DocumentList(props: {
|
|||||||
onChange={() => reset(documents.map(d => d.id))}
|
onChange={() => reset(documents.map(d => d.id))}
|
||||||
/>
|
/>
|
||||||
</Th>
|
</Th>
|
||||||
<SortableTh field="TITLE" className="min-w-0" onOrderChange={handleOrderChange}>
|
<SortableTh field="TITLE" className="min-w-0 pr-12" onOrderChange={handleOrderChange}>
|
||||||
{__("Name")}
|
{__("Name")}
|
||||||
</SortableTh>
|
</SortableTh>
|
||||||
<Th className="w-24">{__("Status")}</Th>
|
<Th className="w-24">{__("Status")}</Th>
|
||||||
@@ -306,13 +306,14 @@ export function DocumentList(props: {
|
|||||||
<Th className="w-32">{__("Classification")}</Th>
|
<Th className="w-32">{__("Classification")}</Th>
|
||||||
<Th className="w-60">{__("Approvers")}</Th>
|
<Th className="w-60">{__("Approvers")}</Th>
|
||||||
<Th className="w-60">{__("Last update")}</Th>
|
<Th className="w-60">{__("Last update")}</Th>
|
||||||
|
<Th className="w-20">{__("Approvals")}</Th>
|
||||||
<Th className="w-20">{__("Signatures")}</Th>
|
<Th className="w-20">{__("Signatures")}</Th>
|
||||||
{hasAnyAction && <Th className="w-18"></Th>}
|
{hasAnyAction && <Th className="w-18"></Th>}
|
||||||
</Tr>
|
</Tr>
|
||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
<Tr>
|
<Tr>
|
||||||
<Th colspan={10} compact>
|
<Th colspan={hasAnyAction ? 11 : 10} compact>
|
||||||
<div className="flex justify-between items-center h-8">
|
<div className="flex justify-between items-center h-8">
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
{sprintf(__("%s documents selected"), selection.length)}
|
{sprintf(__("%s documents selected"), selection.length)}
|
||||||
@@ -357,7 +358,10 @@ export function DocumentList(props: {
|
|||||||
<>
|
<>
|
||||||
{canUpdateAny && (
|
{canUpdateAny && (
|
||||||
<PublishDocumentsDialog documentIds={selection} onSave={clear}>
|
<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")}
|
{__("Publish")}
|
||||||
</Button>
|
</Button>
|
||||||
</PublishDocumentsDialog>
|
</PublishDocumentsDialog>
|
||||||
|
|||||||
@@ -17,20 +17,32 @@ const fragment = graphql`
|
|||||||
classification
|
classification
|
||||||
updatedAt
|
updatedAt
|
||||||
canDelete: permission(action: "core:document:delete")
|
canDelete: permission(action: "core:document:delete")
|
||||||
approvers(first: 100) {
|
recentVersions: versions(first: 2 orderBy: { field: CREATED_AT direction: DESC }) {
|
||||||
edges {
|
|
||||||
node {
|
|
||||||
id
|
|
||||||
fullName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lastVersion: versions(first: 1 orderBy: { field: CREATED_AT direction: DESC }) {
|
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
id
|
id
|
||||||
status
|
status
|
||||||
version
|
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 }) {
|
signatures(first: 0 filter: { activeContract: true }) {
|
||||||
totalCount
|
totalCount
|
||||||
}
|
}
|
||||||
@@ -74,11 +86,22 @@ export function DocumentListItem(props: {
|
|||||||
fragment,
|
fragment,
|
||||||
fragmentRef,
|
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 { __ } = useTranslate();
|
||||||
|
|
||||||
|
const statusVariant = {
|
||||||
|
DRAFT: "neutral",
|
||||||
|
PUBLISHED: "success",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const statusLabel = {
|
||||||
|
DRAFT: __("Draft"),
|
||||||
|
PUBLISHED: __("Published"),
|
||||||
|
} as const;
|
||||||
|
|
||||||
const [deleteDocument] = useMutationWithToasts<DocumentListItem_deleteMutation>(
|
const [deleteDocument] = useMutationWithToasts<DocumentListItem_deleteMutation>(
|
||||||
deleteDocumentMutation,
|
deleteDocumentMutation,
|
||||||
{
|
{
|
||||||
@@ -119,8 +142,8 @@ export function DocumentListItem(props: {
|
|||||||
<div className="flex gap-4 items-center">{document.title}</div>
|
<div className="flex gap-4 items-center">{document.title}</div>
|
||||||
</Td>
|
</Td>
|
||||||
<Td className="w-24">
|
<Td className="w-24">
|
||||||
<Badge variant={isDraft ? "neutral" : "success"}>
|
<Badge variant={statusVariant[lastVersion.status]}>
|
||||||
{isDraft ? __("Draft") : __("Published")}
|
{statusLabel[lastVersion.status]}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Td>
|
</Td>
|
||||||
<Td className="w-20">
|
<Td className="w-20">
|
||||||
@@ -134,9 +157,24 @@ export function DocumentListItem(props: {
|
|||||||
{getDocumentClassificationLabel(__, document.classification)}
|
{getDocumentClassificationLabel(__, document.classification)}
|
||||||
</Td>
|
</Td>
|
||||||
<Td className="w-60">
|
<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>
|
||||||
<Td className="w-60">{formatDate(document.updatedAt)}</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">
|
<Td className="w-20">
|
||||||
{lastVersion.signedSignatures.totalCount}
|
{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 { useTranslate } from "@probo/i18n";
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
|
||||||
Button,
|
Button,
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
Field,
|
IconWarning,
|
||||||
|
Textarea,
|
||||||
useDialogRef,
|
useDialogRef,
|
||||||
|
useToast,
|
||||||
} from "@probo/ui";
|
} from "@probo/ui";
|
||||||
import { type ReactNode } from "react";
|
import { type ReactNode } from "react";
|
||||||
|
import { useMutation } from "react-relay";
|
||||||
import { graphql } from "relay-runtime";
|
import { graphql } from "relay-runtime";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import type { PublishDocumentsDialogMutation } from "#/__generated__/core/PublishDocumentsDialogMutation.graphql";
|
import type { PublishDocumentsDialogMutation } from "#/__generated__/core/PublishDocumentsDialogMutation.graphql";
|
||||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
documentIds: string[];
|
documentIds: string[];
|
||||||
@@ -28,11 +29,12 @@ const documentsPublishMutation = graphql`
|
|||||||
$input: BulkPublishDocumentVersionsInput!
|
$input: BulkPublishDocumentVersionsInput!
|
||||||
) {
|
) {
|
||||||
bulkPublishDocumentVersions(input: $input) {
|
bulkPublishDocumentVersions(input: $input) {
|
||||||
documentEdges {
|
documentVersions {
|
||||||
node {
|
id
|
||||||
id
|
}
|
||||||
...DocumentListItemFragment
|
documents {
|
||||||
}
|
id
|
||||||
|
...DocumentListItemFragment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,49 +46,56 @@ export function PublishDocumentsDialog({
|
|||||||
onSave,
|
onSave,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
|
const { toast } = useToast();
|
||||||
const dialogRef = useDialogRef();
|
const dialogRef = useDialogRef();
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
changelog: z.string().min(1, __("Changelog is required")),
|
changelog: z.string().min(1, __("Changelog is required")),
|
||||||
});
|
});
|
||||||
|
|
||||||
const [publishMutation]
|
const [publishMutation, isPublishing] = useMutation<PublishDocumentsDialogMutation>(documentsPublishMutation);
|
||||||
= 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 {
|
const {
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
register,
|
register,
|
||||||
formState: { isSubmitting, errors },
|
formState: { errors },
|
||||||
} = useFormWithSchema(schema, {
|
} = useFormWithSchema(schema, {
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
changelog: "",
|
changelog: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = async (data: z.infer<typeof schema>) => {
|
const onSubmit = (data: z.infer<typeof schema>) => {
|
||||||
await publishMutation({
|
publishMutation({
|
||||||
variables: {
|
variables: {
|
||||||
input: {
|
input: {
|
||||||
documentIds,
|
documentIds,
|
||||||
changelog: data.changelog,
|
changelog: data.changelog,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onCompleted(_, errors) {
|
||||||
dialogRef.current?.close();
|
if (errors?.length) {
|
||||||
onSave();
|
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"
|
className="max-w-xl"
|
||||||
ref={dialogRef}
|
ref={dialogRef}
|
||||||
trigger={children}
|
trigger={children}
|
||||||
title={<Breadcrumb items={[__("Documents"), __("Publish documents")]} />}
|
title={__("Publish documents")}
|
||||||
>
|
>
|
||||||
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
|
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
|
||||||
<DialogContent padded>
|
<DialogContent padded>
|
||||||
<Field
|
<div className="space-y-4">
|
||||||
id="changelog"
|
<div className="flex items-start gap-2 rounded-lg bg-bg-warning/10 border border-border-warning p-3">
|
||||||
aria-label={__("Changelog")}
|
<IconWarning size={16} className="text-txt-warning shrink-0 mt-0.5" />
|
||||||
required
|
<p className="text-sm text-txt-warning">
|
||||||
variant="title"
|
{__("This will publish the selected documents directly without requiring approval.")}
|
||||||
placeholder={__("Changelog")}
|
</p>
|
||||||
{...register("changelog")}
|
</div>
|
||||||
error={errors.changelog?.message}
|
<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>
|
</DialogContent>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="submit" disabled={isSubmitting}>
|
<Button type="submit" disabled={isPublishing}>
|
||||||
{sprintf(__("Publish %s documents"), documentIds.length)}
|
{sprintf(__("Publish %s documents"), documentIds.length)}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</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 { sprintf } from "@probo/helpers";
|
||||||
import { useTranslate } from "@probo/i18n";
|
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 { useFragment } from "react-relay";
|
||||||
import { type DataID, graphql } from "relay-runtime";
|
import { type DataID, graphql } from "relay-runtime";
|
||||||
|
|
||||||
@@ -53,7 +53,6 @@ export function DocumentSignatureListItem(props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-3 items-center py-3">
|
<div className="flex gap-3 items-center py-3">
|
||||||
<Avatar size="l" name={signature.signedBy.fullName} />
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-sm text-txt-primary font-medium">
|
<div className="text-sm text-txt-primary font-medium">
|
||||||
{signature.signedBy.fullName}
|
{signature.signedBy.fullName}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import { Avatar, Button } from "@probo/ui";
|
import { Button } from "@probo/ui";
|
||||||
import { useFragment } from "react-relay";
|
import { useFragment } from "react-relay";
|
||||||
import { type DataID, graphql } from "relay-runtime";
|
import { type DataID, graphql } from "relay-runtime";
|
||||||
|
|
||||||
@@ -77,7 +77,6 @@ export function DocumentSignaturePlaceholder(props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-3 items-center py-3">
|
<div className="flex gap-3 items-center py-3">
|
||||||
<Avatar size="l" name={person.fullName} />
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-sm text-txt-primary font-medium">
|
<div className="text-sm text-txt-primary font-medium">
|
||||||
{person.fullName}
|
{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`
|
const documentFragment = graphql`
|
||||||
fragment EmployeeDocumentSignaturePageDocumentFragment on SignableDocument {
|
fragment EmployeeDocumentSignaturePageDocumentFragment on EmployeeDocument {
|
||||||
id
|
id
|
||||||
title
|
title
|
||||||
# eslint-disable-next-line relay/unused-fields
|
# eslint-disable-next-line relay/unused-fields
|
||||||
@@ -159,7 +159,7 @@ function DocumentSignatureContent({
|
|||||||
description: __("Document signed successfully"),
|
description: __("Document signed successfully"),
|
||||||
variant: "success",
|
variant: "success",
|
||||||
});
|
});
|
||||||
void navigate(`/organizations/${organizationId}/employee`);
|
void navigate(`/organizations/${organizationId}/employee/signatures`);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast({
|
toast({
|
||||||
@@ -256,7 +256,7 @@ function DocumentSignatureContent({
|
|||||||
isSigning={isSigning}
|
isSigning={isSigning}
|
||||||
onSign={handleSign}
|
onSign={handleSign}
|
||||||
onBack={() =>
|
onBack={() =>
|
||||||
void navigate(`/organizations/${organizationId}/employee`)}
|
void navigate(`/organizations/${organizationId}/employee/signatures`)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useParams } from "react-router";
|
|||||||
|
|
||||||
import type { EmployeeDocumentSignaturePageQuery } from "#/__generated__/core/EmployeeDocumentSignaturePageQuery.graphql";
|
import type { EmployeeDocumentSignaturePageQuery } from "#/__generated__/core/EmployeeDocumentSignaturePageQuery.graphql";
|
||||||
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
|
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
|
||||||
import { CoreRelayProvider } from "#/providers/CoreRelayProvider";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
EmployeeDocumentSignaturePage,
|
EmployeeDocumentSignaturePage,
|
||||||
@@ -39,8 +38,6 @@ function EmployeeDocumentSignaturePageQueryLoader() {
|
|||||||
|
|
||||||
export default function EmployeeDocumentSignaturePageLoader() {
|
export default function EmployeeDocumentSignaturePageLoader() {
|
||||||
return (
|
return (
|
||||||
<CoreRelayProvider>
|
<EmployeeDocumentSignaturePageQueryLoader />
|
||||||
<EmployeeDocumentSignaturePageQueryLoader />
|
|
||||||
</CoreRelayProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { usePageTitle } from "@probo/hooks";
|
import { usePageTitle } from "@probo/hooks";
|
||||||
import { useTranslate } from "@probo/i18n";
|
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 { graphql, type PreloadedQuery, usePreloadedQuery } from "react-relay";
|
||||||
|
|
||||||
import type { EmployeeDocumentsPageQuery } from "#/__generated__/core/EmployeeDocumentsPageQuery.graphql";
|
import type { EmployeeDocumentsPageQuery } from "#/__generated__/core/EmployeeDocumentsPageQuery.graphql";
|
||||||
@@ -46,19 +46,18 @@ export function EmployeeDocumentsPage(props: {
|
|||||||
usePageTitle(__("Documents"));
|
usePageTitle(__("Documents"));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<>
|
||||||
<PageHeader title={__("Documents")} />
|
|
||||||
{documents.length > 0
|
{documents.length > 0
|
||||||
? (
|
? (
|
||||||
<Card>
|
<Card>
|
||||||
<table className="w-full">
|
<table className="w-full table-fixed">
|
||||||
<Thead>
|
<Thead>
|
||||||
<Tr>
|
<Tr>
|
||||||
<Th className="min-w-0 pr-12">{__("Name")}</Th>
|
<Th className="text-left">{__("Name")}</Th>
|
||||||
<Th className="w-48">{__("Type")}</Th>
|
<Th className="w-48 text-left">{__("Type")}</Th>
|
||||||
<Th className="w-36">{__("Classification")}</Th>
|
<Th className="w-36 text-left">{__("Classification")}</Th>
|
||||||
<Th className="w-40">{__("Last update")}</Th>
|
<Th className="w-40 text-left">{__("Last update")}</Th>
|
||||||
<Th className="w-32">{__("Signed")}</Th>
|
<Th className="w-32 text-left">{__("Signed")}</Th>
|
||||||
</Tr>
|
</Tr>
|
||||||
</Thead>
|
</Thead>
|
||||||
<Tbody>
|
<Tbody>
|
||||||
@@ -85,6 +84,6 @@ export function EmployeeDocumentsPage(props: {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useQueryLoader } from "react-relay";
|
|||||||
import type { EmployeeDocumentsPageQuery } from "#/__generated__/core/EmployeeDocumentsPageQuery.graphql";
|
import type { EmployeeDocumentsPageQuery } from "#/__generated__/core/EmployeeDocumentsPageQuery.graphql";
|
||||||
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
|
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
|
||||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||||
import { CoreRelayProvider } from "#/providers/CoreRelayProvider";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
EmployeeDocumentsPage,
|
EmployeeDocumentsPage,
|
||||||
@@ -32,10 +31,8 @@ function EmployeeDocumentsPageQueryLoader() {
|
|||||||
|
|
||||||
export default function EmployeeDocumentsPageLoader() {
|
export default function EmployeeDocumentsPageLoader() {
|
||||||
return (
|
return (
|
||||||
<CoreRelayProvider>
|
<Suspense fallback={<PageSkeleton />}>
|
||||||
<Suspense fallback={<PageSkeleton />}>
|
<EmployeeDocumentsPageQueryLoader />
|
||||||
<EmployeeDocumentsPageQueryLoader />
|
</Suspense>
|
||||||
</Suspense>
|
|
||||||
</CoreRelayProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
import type { DocumentRowFragment$key } from "#/__generated__/core/DocumentRowFragment.graphql";
|
||||||
|
|
||||||
const fragment = graphql`
|
const fragment = graphql`
|
||||||
fragment DocumentRowFragment on SignableDocument {
|
fragment DocumentRowFragment on EmployeeDocument {
|
||||||
id
|
id
|
||||||
title
|
title
|
||||||
documentType
|
documentType
|
||||||
@@ -31,8 +31,8 @@ export function DocumentRow({
|
|||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tr to={`/organizations/${organizationId}/employee/${document.id}`}>
|
<Tr to={`/organizations/${organizationId}/employee/signatures/${document.id}`}>
|
||||||
<Td className="min-w-0 pr-12">{document.title}</Td>
|
<Td>{document.title}</Td>
|
||||||
<Td className="w-48">
|
<Td className="w-48">
|
||||||
{getDocumentTypeLabel(__, document.documentType)}
|
{getDocumentTypeLabel(__, document.documentType)}
|
||||||
</Td>
|
</Td>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { graphql, useFragment } from "react-relay";
|
|||||||
import type { VersionActionsFragment$key } from "#/__generated__/core/VersionActionsFragment.graphql";
|
import type { VersionActionsFragment$key } from "#/__generated__/core/VersionActionsFragment.graphql";
|
||||||
|
|
||||||
const fragment = graphql`
|
const fragment = graphql`
|
||||||
fragment VersionActionsFragment on DocumentVersion {
|
fragment VersionActionsFragment on EmployeeDocumentVersion {
|
||||||
id
|
id
|
||||||
signed
|
signed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { graphql, useFragment } from "react-relay";
|
|||||||
import type { VersionRowFragment$key } from "#/__generated__/core/VersionRowFragment.graphql";
|
import type { VersionRowFragment$key } from "#/__generated__/core/VersionRowFragment.graphql";
|
||||||
|
|
||||||
const fragment = graphql`
|
const fragment = graphql`
|
||||||
fragment VersionRowFragment on DocumentVersion {
|
fragment VersionRowFragment on EmployeeDocumentVersion {
|
||||||
# eslint-disable-next-line relay/unused-fields
|
# eslint-disable-next-line relay/unused-fields
|
||||||
id
|
id
|
||||||
version
|
version
|
||||||
|
|||||||
@@ -136,18 +136,55 @@ const routes = [
|
|||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
index: true,
|
index: true,
|
||||||
|
loader: ({ params: { organizationId } }) => {
|
||||||
|
// eslint-disable-next-line
|
||||||
|
throw redirect(`/organizations/${organizationId}/employee/signatures`);
|
||||||
|
},
|
||||||
|
Component: () => null,
|
||||||
|
},
|
||||||
|
{
|
||||||
Component: lazy(
|
Component: lazy(
|
||||||
() =>
|
() => import("./pages/organizations/employee/EmployeeTabsLayout"),
|
||||||
import("./pages/organizations/employee/EmployeeDocumentsPageLoader"),
|
|
||||||
),
|
),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "signatures",
|
||||||
|
Component: lazy(
|
||||||
|
() =>
|
||||||
|
import("./pages/organizations/employee/EmployeeDocumentsPageLoader"),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "approvals",
|
||||||
|
Component: lazy(
|
||||||
|
() =>
|
||||||
|
import("./pages/organizations/employee/EmployeeApprovalsPageLoader"),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ":documentId",
|
path: ":documentId",
|
||||||
|
loader: ({ params: { organizationId, documentId } }) => {
|
||||||
|
// eslint-disable-next-line
|
||||||
|
throw redirect(`/organizations/${organizationId}/employee/signatures/${documentId}`);
|
||||||
|
},
|
||||||
|
Component: () => null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "signatures/:documentId",
|
||||||
Component: lazy(
|
Component: lazy(
|
||||||
() =>
|
() =>
|
||||||
import("./pages/organizations/employee/EmployeeDocumentSignaturePageLoader"),
|
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"),
|
import("#/pages/organizations/documents/controls/DocumentControlsPageLoader"),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: `${prefix}approvals`,
|
||||||
|
Fallback: LinkCardSkeleton,
|
||||||
|
Component: lazy(
|
||||||
|
() =>
|
||||||
|
import("#/pages/organizations/documents/approvals/DocumentApprovalsPageLoader"),
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: `${prefix}signatures`,
|
path: `${prefix}signatures`,
|
||||||
Fallback: LinkCardSkeleton,
|
Fallback: LinkCardSkeleton,
|
||||||
@@ -61,6 +69,9 @@ export const documentsRoutes = [
|
|||||||
path: "documents/:documentId",
|
path: "documents/:documentId",
|
||||||
Fallback: PageSkeleton,
|
Fallback: PageSkeleton,
|
||||||
Component: lazy(() => import("#/pages/organizations/documents/DocumentLayoutLoader")),
|
Component: lazy(() => import("#/pages/organizations/documents/DocumentLayoutLoader")),
|
||||||
children: [...documentTabs(""), ...documentTabs("versions/:versionId/")],
|
children: [
|
||||||
|
...documentTabs(""),
|
||||||
|
...documentTabs("versions/:versionId/"),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
] satisfies AppRoute[];
|
] satisfies AppRoute[];
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import (
|
|||||||
func TestDocument_Create(t *testing.T) {
|
func TestDocument_Create(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
approverProfileID := factory.CreateUser(owner)
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -114,7 +113,6 @@ func TestDocument_Create(t *testing.T) {
|
|||||||
|
|
||||||
input := map[string]any{
|
input := map[string]any{
|
||||||
"organizationId": owner.GetOrganizationID().String(),
|
"organizationId": owner.GetOrganizationID().String(),
|
||||||
"approverIds": []string{approverProfileID},
|
|
||||||
}
|
}
|
||||||
maps.Copy(input, tt.input)
|
maps.Copy(input, tt.input)
|
||||||
|
|
||||||
@@ -150,13 +148,11 @@ func TestDocument_Create(t *testing.T) {
|
|||||||
func TestDocument_Create_Validation(t *testing.T) {
|
func TestDocument_Create_Validation(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
approverProfileID := factory.CreateUser(owner)
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
input map[string]any
|
input map[string]any
|
||||||
skipOrganization bool
|
skipOrganization bool
|
||||||
skipApprover bool
|
|
||||||
wantErrorContains string
|
wantErrorContains string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
@@ -170,17 +166,6 @@ func TestDocument_Create_Validation(t *testing.T) {
|
|||||||
skipOrganization: true,
|
skipOrganization: true,
|
||||||
wantErrorContains: "organizationId",
|
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",
|
name: "title with HTML tags",
|
||||||
input: map[string]any{
|
input: map[string]any{
|
||||||
@@ -281,9 +266,6 @@ func TestDocument_Create_Validation(t *testing.T) {
|
|||||||
if !tt.skipOrganization {
|
if !tt.skipOrganization {
|
||||||
input["organizationId"] = owner.GetOrganizationID().String()
|
input["organizationId"] = owner.GetOrganizationID().String()
|
||||||
}
|
}
|
||||||
if !tt.skipApprover {
|
|
||||||
input["approverIds"] = []string{approverProfileID}
|
|
||||||
}
|
|
||||||
maps.Copy(input, tt.input)
|
maps.Copy(input, tt.input)
|
||||||
|
|
||||||
_, err := owner.Do(query, map[string]any{"input": 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) {
|
func TestDocument_Update(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
approverProfileID := factory.CreateUser(owner)
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -308,7 +289,7 @@ func TestDocument_Update(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "update title",
|
name: "update title",
|
||||||
setup: func() string {
|
setup: func() string {
|
||||||
return factory.NewDocument(owner, approverProfileID).
|
return factory.NewDocument(owner).
|
||||||
WithTitle("Document to Update").
|
WithTitle("Document to Update").
|
||||||
Create()
|
Create()
|
||||||
},
|
},
|
||||||
@@ -324,7 +305,7 @@ func TestDocument_Update(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "update document type",
|
name: "update document type",
|
||||||
setup: func() string {
|
setup: func() string {
|
||||||
return factory.NewDocument(owner, approverProfileID).
|
return factory.NewDocument(owner).
|
||||||
WithTitle("Type Test").
|
WithTitle("Type Test").
|
||||||
WithDocumentType("POLICY").
|
WithDocumentType("POLICY").
|
||||||
Create()
|
Create()
|
||||||
@@ -380,8 +361,8 @@ func TestDocument_Update(t *testing.T) {
|
|||||||
func TestDocument_Update_Validation(t *testing.T) {
|
func TestDocument_Update_Validation(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
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 {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -463,10 +444,9 @@ func TestDocument_Update_Validation(t *testing.T) {
|
|||||||
func TestDocument_Delete(t *testing.T) {
|
func TestDocument_Delete(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
approverProfileID := factory.CreateUser(owner)
|
|
||||||
|
|
||||||
t.Run("delete existing document", func(t *testing.T) {
|
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 := `
|
query := `
|
||||||
mutation DeleteDocument($input: DeleteDocumentInput!) {
|
mutation DeleteDocument($input: DeleteDocumentInput!) {
|
||||||
@@ -528,11 +508,10 @@ func TestDocument_Delete_Validation(t *testing.T) {
|
|||||||
func TestDocument_List(t *testing.T) {
|
func TestDocument_List(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
approverProfileID := factory.CreateUser(owner)
|
|
||||||
|
|
||||||
documentTitles := []string{"Document A", "Document B", "Document C"}
|
documentTitles := []string{"Document A", "Document B", "Document C"}
|
||||||
for _, title := range documentTitles {
|
for _, title := range documentTitles {
|
||||||
factory.NewDocument(owner, approverProfileID).WithTitle(title).Create()
|
factory.NewDocument(owner).WithTitle(title).Create()
|
||||||
}
|
}
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
@@ -600,7 +579,6 @@ func TestDocument_Query(t *testing.T) {
|
|||||||
func TestDocument_Timestamps(t *testing.T) {
|
func TestDocument_Timestamps(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
approverProfileID := factory.CreateUser(owner)
|
|
||||||
|
|
||||||
t.Run("createdAt and updatedAt are set on create", func(t *testing.T) {
|
t.Run("createdAt and updatedAt are set on create", func(t *testing.T) {
|
||||||
beforeCreate := time.Now().Add(-time.Second)
|
beforeCreate := time.Now().Add(-time.Second)
|
||||||
@@ -634,7 +612,6 @@ func TestDocument_Timestamps(t *testing.T) {
|
|||||||
err := owner.Execute(query, map[string]any{
|
err := owner.Execute(query, map[string]any{
|
||||||
"input": map[string]any{
|
"input": map[string]any{
|
||||||
"organizationId": owner.GetOrganizationID().String(),
|
"organizationId": owner.GetOrganizationID().String(),
|
||||||
"approverIds": []string{approverProfileID},
|
|
||||||
"title": "Timestamp Test Document",
|
"title": "Timestamp Test Document",
|
||||||
"content": "Test content",
|
"content": "Test content",
|
||||||
"documentType": "POLICY",
|
"documentType": "POLICY",
|
||||||
@@ -648,7 +625,7 @@ func TestDocument_Timestamps(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("updatedAt changes on update", func(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 := `
|
getQuery := `
|
||||||
query($id: ID!) {
|
query($id: ID!) {
|
||||||
@@ -713,50 +690,8 @@ func TestDocument_Timestamps(t *testing.T) {
|
|||||||
func TestDocument_SubResolvers(t *testing.T) {
|
func TestDocument_SubResolvers(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
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) {
|
documentID := factory.NewDocument(owner).WithTitle("SubResolver Test Document").Create()
|
||||||
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)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("organization sub-resolver", func(t *testing.T) {
|
t.Run("organization sub-resolver", func(t *testing.T) {
|
||||||
query := `
|
query := `
|
||||||
@@ -796,7 +731,6 @@ func TestDocument_RBAC(t *testing.T) {
|
|||||||
t.Run("create", func(t *testing.T) {
|
t.Run("create", func(t *testing.T) {
|
||||||
t.Run("owner can create", func(t *testing.T) {
|
t.Run("owner can create", func(t *testing.T) {
|
||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
approverProfileID := factory.CreateUser(owner)
|
|
||||||
|
|
||||||
_, err := owner.Do(`
|
_, err := owner.Do(`
|
||||||
mutation CreateDocument($input: CreateDocumentInput!) {
|
mutation CreateDocument($input: CreateDocumentInput!) {
|
||||||
@@ -807,7 +741,6 @@ func TestDocument_RBAC(t *testing.T) {
|
|||||||
`, map[string]any{
|
`, map[string]any{
|
||||||
"input": map[string]any{
|
"input": map[string]any{
|
||||||
"organizationId": owner.GetOrganizationID().String(),
|
"organizationId": owner.GetOrganizationID().String(),
|
||||||
"approverIds": []string{approverProfileID},
|
|
||||||
"title": "RBAC Test Document",
|
"title": "RBAC Test Document",
|
||||||
"content": "Test content",
|
"content": "Test content",
|
||||||
"documentType": "POLICY",
|
"documentType": "POLICY",
|
||||||
@@ -820,7 +753,6 @@ func TestDocument_RBAC(t *testing.T) {
|
|||||||
t.Run("admin can create", func(t *testing.T) {
|
t.Run("admin can create", func(t *testing.T) {
|
||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
admin := testutil.NewClientInOrg(t, testutil.RoleAdmin, owner)
|
admin := testutil.NewClientInOrg(t, testutil.RoleAdmin, owner)
|
||||||
approverProfileID := factory.CreateUser(owner)
|
|
||||||
|
|
||||||
_, err := admin.Do(`
|
_, err := admin.Do(`
|
||||||
mutation CreateDocument($input: CreateDocumentInput!) {
|
mutation CreateDocument($input: CreateDocumentInput!) {
|
||||||
@@ -831,7 +763,6 @@ func TestDocument_RBAC(t *testing.T) {
|
|||||||
`, map[string]any{
|
`, map[string]any{
|
||||||
"input": map[string]any{
|
"input": map[string]any{
|
||||||
"organizationId": admin.GetOrganizationID().String(),
|
"organizationId": admin.GetOrganizationID().String(),
|
||||||
"approverIds": []string{approverProfileID},
|
|
||||||
"title": "RBAC Test Document",
|
"title": "RBAC Test Document",
|
||||||
"content": "Test content",
|
"content": "Test content",
|
||||||
"documentType": "POLICY",
|
"documentType": "POLICY",
|
||||||
@@ -844,7 +775,6 @@ func TestDocument_RBAC(t *testing.T) {
|
|||||||
t.Run("viewer cannot create", func(t *testing.T) {
|
t.Run("viewer cannot create", func(t *testing.T) {
|
||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
|
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
|
||||||
approverProfileID := factory.CreateUser(owner)
|
|
||||||
|
|
||||||
_, err := viewer.Do(`
|
_, err := viewer.Do(`
|
||||||
mutation CreateDocument($input: CreateDocumentInput!) {
|
mutation CreateDocument($input: CreateDocumentInput!) {
|
||||||
@@ -855,7 +785,6 @@ func TestDocument_RBAC(t *testing.T) {
|
|||||||
`, map[string]any{
|
`, map[string]any{
|
||||||
"input": map[string]any{
|
"input": map[string]any{
|
||||||
"organizationId": viewer.GetOrganizationID().String(),
|
"organizationId": viewer.GetOrganizationID().String(),
|
||||||
"approverIds": []string{approverProfileID},
|
|
||||||
"title": "RBAC Test Document",
|
"title": "RBAC Test Document",
|
||||||
"content": "Test content",
|
"content": "Test content",
|
||||||
"documentType": "POLICY",
|
"documentType": "POLICY",
|
||||||
@@ -869,8 +798,8 @@ func TestDocument_RBAC(t *testing.T) {
|
|||||||
t.Run("update", func(t *testing.T) {
|
t.Run("update", func(t *testing.T) {
|
||||||
t.Run("owner can update", func(t *testing.T) {
|
t.Run("owner can update", func(t *testing.T) {
|
||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
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(`
|
_, err := owner.Do(`
|
||||||
mutation UpdateDocument($input: UpdateDocumentInput!) {
|
mutation UpdateDocument($input: UpdateDocumentInput!) {
|
||||||
@@ -890,8 +819,8 @@ func TestDocument_RBAC(t *testing.T) {
|
|||||||
t.Run("admin can update", func(t *testing.T) {
|
t.Run("admin can update", func(t *testing.T) {
|
||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
admin := testutil.NewClientInOrg(t, testutil.RoleAdmin, owner)
|
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(`
|
_, err := admin.Do(`
|
||||||
mutation UpdateDocument($input: UpdateDocumentInput!) {
|
mutation UpdateDocument($input: UpdateDocumentInput!) {
|
||||||
@@ -911,8 +840,8 @@ func TestDocument_RBAC(t *testing.T) {
|
|||||||
t.Run("viewer cannot update", func(t *testing.T) {
|
t.Run("viewer cannot update", func(t *testing.T) {
|
||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
|
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(`
|
_, err := viewer.Do(`
|
||||||
mutation UpdateDocument($input: UpdateDocumentInput!) {
|
mutation UpdateDocument($input: UpdateDocumentInput!) {
|
||||||
@@ -933,8 +862,8 @@ func TestDocument_RBAC(t *testing.T) {
|
|||||||
t.Run("delete", func(t *testing.T) {
|
t.Run("delete", func(t *testing.T) {
|
||||||
t.Run("owner can delete", func(t *testing.T) {
|
t.Run("owner can delete", func(t *testing.T) {
|
||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
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(`
|
_, err := owner.Do(`
|
||||||
mutation DeleteDocument($input: DeleteDocumentInput!) {
|
mutation DeleteDocument($input: DeleteDocumentInput!) {
|
||||||
@@ -951,8 +880,8 @@ func TestDocument_RBAC(t *testing.T) {
|
|||||||
t.Run("admin can delete", func(t *testing.T) {
|
t.Run("admin can delete", func(t *testing.T) {
|
||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
admin := testutil.NewClientInOrg(t, testutil.RoleAdmin, owner)
|
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(`
|
_, err := admin.Do(`
|
||||||
mutation DeleteDocument($input: DeleteDocumentInput!) {
|
mutation DeleteDocument($input: DeleteDocumentInput!) {
|
||||||
@@ -969,8 +898,8 @@ func TestDocument_RBAC(t *testing.T) {
|
|||||||
t.Run("viewer cannot delete", func(t *testing.T) {
|
t.Run("viewer cannot delete", func(t *testing.T) {
|
||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
|
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(`
|
_, err := viewer.Do(`
|
||||||
mutation DeleteDocument($input: DeleteDocumentInput!) {
|
mutation DeleteDocument($input: DeleteDocumentInput!) {
|
||||||
@@ -988,8 +917,8 @@ func TestDocument_RBAC(t *testing.T) {
|
|||||||
t.Run("read", func(t *testing.T) {
|
t.Run("read", func(t *testing.T) {
|
||||||
t.Run("owner can read", func(t *testing.T) {
|
t.Run("owner can read", func(t *testing.T) {
|
||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
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 {
|
var result struct {
|
||||||
Node *struct {
|
Node *struct {
|
||||||
@@ -1012,8 +941,8 @@ func TestDocument_RBAC(t *testing.T) {
|
|||||||
t.Run("admin can read", func(t *testing.T) {
|
t.Run("admin can read", func(t *testing.T) {
|
||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
admin := testutil.NewClientInOrg(t, testutil.RoleAdmin, owner)
|
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 {
|
var result struct {
|
||||||
Node *struct {
|
Node *struct {
|
||||||
@@ -1036,8 +965,8 @@ func TestDocument_RBAC(t *testing.T) {
|
|||||||
t.Run("viewer can read", func(t *testing.T) {
|
t.Run("viewer can read", func(t *testing.T) {
|
||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
|
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 {
|
var result struct {
|
||||||
Node *struct {
|
Node *struct {
|
||||||
@@ -1062,7 +991,6 @@ func TestDocument_RBAC(t *testing.T) {
|
|||||||
func TestDocument_MaxLength_Validation(t *testing.T) {
|
func TestDocument_MaxLength_Validation(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
approverProfileID := factory.CreateUser(owner)
|
|
||||||
|
|
||||||
longTitle := strings.Repeat("a", 1001)
|
longTitle := strings.Repeat("a", 1001)
|
||||||
|
|
||||||
@@ -1080,7 +1008,6 @@ func TestDocument_MaxLength_Validation(t *testing.T) {
|
|||||||
_, err := owner.Do(query, map[string]any{
|
_, err := owner.Do(query, map[string]any{
|
||||||
"input": map[string]any{
|
"input": map[string]any{
|
||||||
"organizationId": owner.GetOrganizationID().String(),
|
"organizationId": owner.GetOrganizationID().String(),
|
||||||
"approverIds": []string{approverProfileID},
|
|
||||||
"title": longTitle,
|
"title": longTitle,
|
||||||
"content": "Test content",
|
"content": "Test content",
|
||||||
"documentType": "POLICY",
|
"documentType": "POLICY",
|
||||||
@@ -1092,7 +1019,7 @@ func TestDocument_MaxLength_Validation(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("update", func(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 := `
|
query := `
|
||||||
mutation UpdateDocument($input: UpdateDocumentInput!) {
|
mutation UpdateDocument($input: UpdateDocumentInput!) {
|
||||||
@@ -1116,10 +1043,9 @@ func TestDocument_MaxLength_Validation(t *testing.T) {
|
|||||||
func TestDocument_Pagination(t *testing.T) {
|
func TestDocument_Pagination(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
approverProfileID := factory.CreateUser(owner)
|
|
||||||
|
|
||||||
for i := range 5 {
|
for i := range 5 {
|
||||||
factory.NewDocument(owner, approverProfileID).
|
factory.NewDocument(owner).
|
||||||
WithTitle(fmt.Sprintf("Pagination Document %d", i)).
|
WithTitle(fmt.Sprintf("Pagination Document %d", i)).
|
||||||
Create()
|
Create()
|
||||||
}
|
}
|
||||||
@@ -1265,8 +1191,7 @@ func TestDocument_TenantIsolation(t *testing.T) {
|
|||||||
org1Owner := testutil.NewClient(t, testutil.RoleOwner)
|
org1Owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
org2Owner := testutil.NewClient(t, testutil.RoleOwner)
|
org2Owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
|
|
||||||
approverProfileID := factory.CreateUser(org1Owner)
|
documentID := factory.NewDocument(org1Owner).WithTitle("Org1 Document").Create()
|
||||||
documentID := factory.NewDocument(org1Owner, approverProfileID).WithTitle("Org1 Document").Create()
|
|
||||||
|
|
||||||
t.Run("cannot read document from another organization", func(t *testing.T) {
|
t.Run("cannot read document from another organization", func(t *testing.T) {
|
||||||
query := `
|
query := `
|
||||||
@@ -1372,10 +1297,9 @@ func TestDocument_TenantIsolation(t *testing.T) {
|
|||||||
func TestDocument_Ordering(t *testing.T) {
|
func TestDocument_Ordering(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
approverProfileID := factory.CreateUser(owner)
|
|
||||||
|
|
||||||
factory.NewDocument(owner, approverProfileID).WithTitle("AAA Order Test").Create()
|
factory.NewDocument(owner).WithTitle("AAA Order Test").Create()
|
||||||
factory.NewDocument(owner, approverProfileID).WithTitle("ZZZ Order Test").Create()
|
factory.NewDocument(owner).WithTitle("ZZZ Order Test").Create()
|
||||||
|
|
||||||
t.Run("order by created_at descending", func(t *testing.T) {
|
t.Run("order by created_at descending", func(t *testing.T) {
|
||||||
query := `
|
query := `
|
||||||
|
|||||||
@@ -23,10 +23,52 @@ import (
|
|||||||
"go.probo.inc/probo/e2e/internal/testutil"
|
"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
|
// 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) {
|
func createTestDocument(t *testing.T, owner *testutil.Client) (docID string, docVersionID string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
profileID := factory.CreateUser(owner)
|
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
mutation CreateDocument($input: CreateDocumentInput!) {
|
mutation CreateDocument($input: CreateDocumentInput!) {
|
||||||
@@ -69,7 +111,6 @@ func createTestDocument(t *testing.T, owner *testutil.Client) (docID string, doc
|
|||||||
"organizationId": owner.GetOrganizationID().String(),
|
"organizationId": owner.GetOrganizationID().String(),
|
||||||
"title": "Test Document",
|
"title": "Test Document",
|
||||||
"content": "Initial content",
|
"content": "Initial content",
|
||||||
"approverIds": []string{profileID},
|
|
||||||
"documentType": "POLICY",
|
"documentType": "POLICY",
|
||||||
"classification": "INTERNAL",
|
"classification": "INTERNAL",
|
||||||
},
|
},
|
||||||
@@ -83,79 +124,143 @@ func createTestDocument(t *testing.T, owner *testutil.Client) (docID string, doc
|
|||||||
return docID, docVersionID
|
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) {
|
func TestDocumentVersion_PublishVersion(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
|
|
||||||
docID, _ := createTestDocument(t, owner)
|
docID, _ := createTestDocument(t, owner)
|
||||||
|
approveTestDocument(t, owner, docID)
|
||||||
|
|
||||||
|
// After approval, the version is auto-published.
|
||||||
|
// Verify the version status by querying.
|
||||||
query := `
|
query := `
|
||||||
mutation PublishDocumentVersion($input: PublishDocumentVersionInput!) {
|
query GetDocument($id: ID!) {
|
||||||
publishDocumentVersion(input: $input) {
|
node(id: $id) {
|
||||||
documentVersion {
|
... on Document {
|
||||||
id
|
versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
|
||||||
status
|
edges {
|
||||||
version
|
node {
|
||||||
changelog
|
id
|
||||||
}
|
status
|
||||||
document {
|
version
|
||||||
id
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
PublishDocumentVersion struct {
|
Node struct {
|
||||||
DocumentVersion struct {
|
Versions struct {
|
||||||
ID string `json:"id"`
|
Edges []struct {
|
||||||
Status string `json:"status"`
|
Node struct {
|
||||||
Version int `json:"version"`
|
ID string `json:"id"`
|
||||||
Changelog string `json:"changelog"`
|
Status string `json:"status"`
|
||||||
} `json:"documentVersion"`
|
Version int `json:"version"`
|
||||||
Document struct {
|
} `json:"node"`
|
||||||
ID string `json:"id"`
|
} `json:"edges"`
|
||||||
} `json:"document"`
|
} `json:"versions"`
|
||||||
} `json:"publishDocumentVersion"`
|
} `json:"node"`
|
||||||
}
|
}
|
||||||
|
|
||||||
err := owner.Execute(query, map[string]any{
|
err := owner.Execute(query, map[string]any{"id": docID}, &result)
|
||||||
"input": map[string]any{
|
|
||||||
"documentId": docID,
|
|
||||||
"changelog": "Initial release",
|
|
||||||
},
|
|
||||||
}, &result)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, result.Node.Versions.Edges)
|
||||||
|
|
||||||
assert.Equal(t, "PUBLISHED", result.PublishDocumentVersion.DocumentVersion.Status)
|
assert.Equal(t, "PUBLISHED", result.Node.Versions.Edges[0].Node.Status)
|
||||||
assert.Equal(t, 1, result.PublishDocumentVersion.DocumentVersion.Version)
|
assert.Equal(t, 1, result.Node.Versions.Edges[0].Node.Version)
|
||||||
assert.Equal(t, "Initial release", result.PublishDocumentVersion.DocumentVersion.Changelog)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDocumentVersion_CreateDraft(t *testing.T) {
|
func TestDocumentVersion_CreateDraft(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
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)
|
docID, _ := createTestDocument(t, owner)
|
||||||
|
approveTestDocument(t, owner, docID)
|
||||||
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)
|
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
mutation CreateDraftDocumentVersion($input: CreateDraftDocumentVersionInput!) {
|
mutation CreateDraftDocumentVersion($input: CreateDraftDocumentVersionInput!) {
|
||||||
@@ -181,7 +286,7 @@ func TestDocumentVersion_CreateDraft(t *testing.T) {
|
|||||||
} `json:"createDraftDocumentVersion"`
|
} `json:"createDraftDocumentVersion"`
|
||||||
}
|
}
|
||||||
|
|
||||||
err = owner.Execute(query, map[string]any{
|
err := owner.Execute(query, map[string]any{
|
||||||
"input": map[string]any{
|
"input": map[string]any{
|
||||||
"documentID": docID,
|
"documentID": docID,
|
||||||
},
|
},
|
||||||
@@ -232,36 +337,44 @@ func TestDocumentVersion_RequestSignature(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
|
|
||||||
// Create and publish a document
|
// Create and approve a document (auto-publishes on approval)
|
||||||
docID, _ := createTestDocument(t, owner)
|
docID, _ := createTestDocument(t, owner)
|
||||||
|
approveTestDocument(t, owner, docID)
|
||||||
|
|
||||||
publishQuery := `
|
// Get the published version ID
|
||||||
mutation PublishDocumentVersion($input: PublishDocumentVersionInput!) {
|
versionQuery := `
|
||||||
publishDocumentVersion(input: $input) {
|
query GetVersions($id: ID!) {
|
||||||
documentVersion {
|
node(id: $id) {
|
||||||
id
|
... on Document {
|
||||||
|
versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
var publishResult struct {
|
var versionResult struct {
|
||||||
PublishDocumentVersion struct {
|
Node struct {
|
||||||
DocumentVersion struct {
|
Versions struct {
|
||||||
ID string `json:"id"`
|
Edges []struct {
|
||||||
} `json:"documentVersion"`
|
Node struct {
|
||||||
} `json:"publishDocumentVersion"`
|
ID string `json:"id"`
|
||||||
|
} `json:"node"`
|
||||||
|
} `json:"edges"`
|
||||||
|
} `json:"versions"`
|
||||||
|
} `json:"node"`
|
||||||
}
|
}
|
||||||
|
|
||||||
err := owner.Execute(publishQuery, map[string]any{
|
err := owner.Execute(versionQuery, map[string]any{"id": docID}, &versionResult)
|
||||||
"input": map[string]any{
|
|
||||||
"documentId": docID,
|
|
||||||
"changelog": "Initial release",
|
|
||||||
},
|
|
||||||
}, &publishResult)
|
|
||||||
require.NoError(t, err)
|
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
|
// Create a person to sign
|
||||||
signerProfileID := factory.CreateUser(owner)
|
signerProfileID := factory.CreateUser(owner)
|
||||||
@@ -318,15 +431,15 @@ func TestDocumentVersion_BulkPublish(t *testing.T) {
|
|||||||
// Create multiple documents
|
// Create multiple documents
|
||||||
docID1, _ := createTestDocument(t, owner)
|
docID1, _ := createTestDocument(t, owner)
|
||||||
docID2, _ := createTestDocument(t, owner)
|
docID2, _ := createTestDocument(t, owner)
|
||||||
|
approveTestDocument(t, owner, docID1)
|
||||||
|
approveTestDocument(t, owner, docID2)
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
mutation BulkPublishDocumentVersions($input: BulkPublishDocumentVersionsInput!) {
|
mutation BulkPublishDocumentVersions($input: BulkPublishDocumentVersionsInput!) {
|
||||||
bulkPublishDocumentVersions(input: $input) {
|
bulkPublishDocumentVersions(input: $input) {
|
||||||
documentVersionEdges {
|
documentVersions {
|
||||||
node {
|
id
|
||||||
id
|
status
|
||||||
status
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -334,12 +447,10 @@ func TestDocumentVersion_BulkPublish(t *testing.T) {
|
|||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
BulkPublishDocumentVersions struct {
|
BulkPublishDocumentVersions struct {
|
||||||
DocumentVersionEdges []struct {
|
DocumentVersions []struct {
|
||||||
Node struct {
|
ID string `json:"id"`
|
||||||
ID string `json:"id"`
|
Status string `json:"status"`
|
||||||
Status string `json:"status"`
|
} `json:"documentVersions"`
|
||||||
} `json:"node"`
|
|
||||||
} `json:"documentVersionEdges"`
|
|
||||||
} `json:"bulkPublishDocumentVersions"`
|
} `json:"bulkPublishDocumentVersions"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,9 +462,9 @@ func TestDocumentVersion_BulkPublish(t *testing.T) {
|
|||||||
}, &result)
|
}, &result)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, 2, len(result.BulkPublishDocumentVersions.DocumentVersionEdges))
|
assert.Equal(t, 2, len(result.BulkPublishDocumentVersions.DocumentVersions))
|
||||||
for _, edge := range result.BulkPublishDocumentVersions.DocumentVersionEdges {
|
for _, dv := range result.BulkPublishDocumentVersions.DocumentVersions {
|
||||||
assert.Equal(t, "PUBLISHED", edge.Node.Status)
|
assert.Equal(t, "PUBLISHED", dv.Status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,26 +472,9 @@ func TestDocumentVersion_BulkRequestSignatures(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
|
|
||||||
// Create and publish a document
|
// Create and approve a document (auto-publishes on approval)
|
||||||
docID, _ := createTestDocument(t, owner)
|
docID, _ := createTestDocument(t, owner)
|
||||||
|
approveTestDocument(t, owner, docID)
|
||||||
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)
|
|
||||||
|
|
||||||
// Create multiple signers
|
// Create multiple signers
|
||||||
signer1ProfileID := factory.CreateUser(owner)
|
signer1ProfileID := factory.CreateUser(owner)
|
||||||
@@ -410,7 +504,7 @@ func TestDocumentVersion_BulkRequestSignatures(t *testing.T) {
|
|||||||
} `json:"bulkRequestSignatures"`
|
} `json:"bulkRequestSignatures"`
|
||||||
}
|
}
|
||||||
|
|
||||||
err = owner.Execute(query, map[string]any{
|
err := owner.Execute(query, map[string]any{
|
||||||
"input": map[string]any{
|
"input": map[string]any{
|
||||||
"documentIds": []string{docID},
|
"documentIds": []string{docID},
|
||||||
"signatoryIds": []string{signer1ProfileID, signer2ProfileID},
|
"signatoryIds": []string{signer1ProfileID, signer2ProfileID},
|
||||||
|
|||||||
@@ -370,7 +370,6 @@ func TestControlDocumentMapping_CreateDelete(t *testing.T) {
|
|||||||
controlID := createControlResult.CreateControl.ControlEdge.Node.ID
|
controlID := createControlResult.CreateControl.ControlEdge.Node.ID
|
||||||
|
|
||||||
// Create a document
|
// Create a document
|
||||||
profileID := factory.CreateUser(owner)
|
|
||||||
var createDocumentResult struct {
|
var createDocumentResult struct {
|
||||||
CreateDocument struct {
|
CreateDocument struct {
|
||||||
DocumentEdge struct {
|
DocumentEdge struct {
|
||||||
@@ -395,7 +394,6 @@ func TestControlDocumentMapping_CreateDelete(t *testing.T) {
|
|||||||
"organizationId": owner.GetOrganizationID().String(),
|
"organizationId": owner.GetOrganizationID().String(),
|
||||||
"title": "Document for Control Mapping",
|
"title": "Document for Control Mapping",
|
||||||
"content": "Document content",
|
"content": "Document content",
|
||||||
"approverIds": []string{profileID},
|
|
||||||
"documentType": "POLICY",
|
"documentType": "POLICY",
|
||||||
"classification": "INTERNAL",
|
"classification": "INTERNAL",
|
||||||
},
|
},
|
||||||
@@ -760,7 +758,6 @@ func TestRiskDocumentMapping_CreateDelete(t *testing.T) {
|
|||||||
riskID := createRiskResult.CreateRisk.RiskEdge.Node.ID
|
riskID := createRiskResult.CreateRisk.RiskEdge.Node.ID
|
||||||
|
|
||||||
// Create a document
|
// Create a document
|
||||||
profileID := factory.CreateUser(owner)
|
|
||||||
var createDocumentResult struct {
|
var createDocumentResult struct {
|
||||||
CreateDocument struct {
|
CreateDocument struct {
|
||||||
DocumentEdge struct {
|
DocumentEdge struct {
|
||||||
@@ -785,7 +782,6 @@ func TestRiskDocumentMapping_CreateDelete(t *testing.T) {
|
|||||||
"organizationId": owner.GetOrganizationID().String(),
|
"organizationId": owner.GetOrganizationID().String(),
|
||||||
"title": "Document for Risk Mapping",
|
"title": "Document for Risk Mapping",
|
||||||
"content": "Document content",
|
"content": "Document content",
|
||||||
"approverIds": []string{profileID},
|
|
||||||
"documentType": "POLICY",
|
"documentType": "POLICY",
|
||||||
"classification": "INTERNAL",
|
"classification": "INTERNAL",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -824,7 +824,7 @@ func (b *MeetingBuilder) Create() string {
|
|||||||
return CreateMeeting(b.client, b.attrs)
|
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()
|
c.T.Helper()
|
||||||
|
|
||||||
var a Attrs
|
var a Attrs
|
||||||
@@ -844,7 +844,6 @@ func CreateDocument(c *testutil.Client, approverID string, attrs ...Attrs) strin
|
|||||||
|
|
||||||
input := map[string]any{
|
input := map[string]any{
|
||||||
"organizationId": c.GetOrganizationID().String(),
|
"organizationId": c.GetOrganizationID().String(),
|
||||||
"approverIds": []string{approverID},
|
|
||||||
"title": a.getString("title", SafeName("Document")),
|
"title": a.getString("title", SafeName("Document")),
|
||||||
"content": a.getString("content", "Document content"),
|
"content": a.getString("content", "Document content"),
|
||||||
"documentType": a.getString("documentType", "POLICY"),
|
"documentType": a.getString("documentType", "POLICY"),
|
||||||
@@ -868,13 +867,12 @@ func CreateDocument(c *testutil.Client, approverID string, attrs ...Attrs) strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DocumentBuilder struct {
|
type DocumentBuilder struct {
|
||||||
client *testutil.Client
|
client *testutil.Client
|
||||||
approverID string
|
attrs Attrs
|
||||||
attrs Attrs
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDocument(c *testutil.Client, approverID string) *DocumentBuilder {
|
func NewDocument(c *testutil.Client) *DocumentBuilder {
|
||||||
return &DocumentBuilder{client: c, approverID: approverID, attrs: Attrs{}}
|
return &DocumentBuilder{client: c, attrs: Attrs{}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *DocumentBuilder) WithTitle(title string) *DocumentBuilder {
|
func (b *DocumentBuilder) WithTitle(title string) *DocumentBuilder {
|
||||||
@@ -898,7 +896,7 @@ func (b *DocumentBuilder) WithClassification(classification string) *DocumentBui
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *DocumentBuilder) Create() string {
|
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 {
|
func CreateProcessingActivity(c *testutil.Client, attrs ...Attrs) string {
|
||||||
|
|||||||
@@ -212,6 +212,7 @@ const (
|
|||||||
subjectConfirmEmail = "Confirm your email address"
|
subjectConfirmEmail = "Confirm your email address"
|
||||||
subjectPasswordReset = "Reset your password"
|
subjectPasswordReset = "Reset your password"
|
||||||
subjectInvitation = "Invitation to join %s on Probo"
|
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"
|
subjectDocumentSigning = "Action Required – Please review and sign %s compliance documents"
|
||||||
subjectDocumentExport = "Your document export is ready"
|
subjectDocumentExport = "Your document export is ready"
|
||||||
subjectFrameworkExport = "Your framework 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"))
|
passwordResetTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/password-reset.txt.tmpl"))
|
||||||
invitationHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/invitation.html.tmpl"))
|
invitationHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/invitation.html.tmpl"))
|
||||||
invitationTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/invitation.txt.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"))
|
documentSigningHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/document-signing.html.tmpl"))
|
||||||
documentSigningTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/document-signing.txt.tmpl"))
|
documentSigningTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/document-signing.txt.tmpl"))
|
||||||
documentExportHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/document-export.html.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
|
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(
|
func (p *Presenter) RenderDocumentSigning(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
signingURLPath string,
|
signingURLPath string,
|
||||||
@@ -478,7 +514,7 @@ func (p *Presenter) RenderMagicLink(ctx context.Context, magicLinkUrlPath string
|
|||||||
return fmt.Sprintf(subjectMagicLink, organizationName), textBody, htmlBody, err
|
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)
|
vars, err := p.getCommonVariables(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", nil, fmt.Errorf("cannot get common variables: %w", err)
|
return "", "", nil, fmt.Errorf("cannot get common variables: %w", err)
|
||||||
@@ -487,15 +523,15 @@ func (p *Presenter) RenderElectronicSignatureCertificate(ctx context.Context, si
|
|||||||
data := struct {
|
data := struct {
|
||||||
*CommonVariables
|
*CommonVariables
|
||||||
SignerName string
|
SignerName string
|
||||||
DocumentType string
|
DocumentName string
|
||||||
}{
|
}{
|
||||||
CommonVariables: vars,
|
CommonVariables: vars,
|
||||||
SignerName: signerName,
|
SignerName: signerName,
|
||||||
DocumentType: documentType,
|
DocumentName: documentName,
|
||||||
}
|
}
|
||||||
|
|
||||||
textBody, htmlBody, err = renderEmail(electronicSignatureCertificateTextTemplate, electronicSignatureCertificateHTMLTemplate, data)
|
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) {
|
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 MailingListUpdates from "../src/MailingListUpdates";
|
||||||
import ConfirmEmail from "../src/ConfirmEmail";
|
import ConfirmEmail from "../src/ConfirmEmail";
|
||||||
import DocumentExport from "../src/DocumentExport";
|
import DocumentExport from "../src/DocumentExport";
|
||||||
|
import DocumentApproval from "../src/DocumentApproval";
|
||||||
import DocumentSigning from "../src/DocumentSigning";
|
import DocumentSigning from "../src/DocumentSigning";
|
||||||
import FrameworkExport from "../src/FrameworkExport";
|
import FrameworkExport from "../src/FrameworkExport";
|
||||||
import Invitation from "../src/Invitation";
|
import Invitation from "../src/Invitation";
|
||||||
@@ -39,6 +40,10 @@ const templates: TemplateConfig[] = [
|
|||||||
name: "invitation",
|
name: "invitation",
|
||||||
render: () => Invitation(),
|
render: () => Invitation(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "document-approval",
|
||||||
|
render: () => DocumentApproval(),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "document-signing",
|
name: "document-signing",
|
||||||
render: () => DocumentSigning(),
|
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 = () => {
|
export const ElectronicSignatureCertificate = () => {
|
||||||
return (
|
return (
|
||||||
<EmailLayout
|
<EmailLayout
|
||||||
subject={`Your signed ${"{{.DocumentType}}"} — Certificate of Completion`}
|
subject={`Your signed ${"{{.DocumentName}}"} — Certificate of Completion`}
|
||||||
>
|
>
|
||||||
<Text style={bodyText}>
|
<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
|
electronically. A Certificate of Completion is attached to this email
|
||||||
as a PDF document.
|
as a PDF document.
|
||||||
</Text>
|
</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}},
|
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.
|
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.
|
// AuthorizationAttributes returns the authorization attributes for policy evaluation.
|
||||||
func (d *Document) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (map[string]string, error) {
|
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 (
|
||||||
var documentStatus DocumentStatus
|
organizationID gid.GID
|
||||||
if err := conn.QueryRow(ctx, q, d.ID).Scan(&organizationID, &documentStatus); err != nil {
|
documentStatus DocumentStatus
|
||||||
|
latestVersionStatus string
|
||||||
|
lastQuorumStatus string
|
||||||
|
)
|
||||||
|
if err := conn.QueryRow(ctx, q, d.ID).Scan(&organizationID, &documentStatus, &latestVersionStatus, &lastQuorumStatus); err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, ErrResourceNotFound
|
return nil, ErrResourceNotFound
|
||||||
}
|
}
|
||||||
@@ -73,8 +108,10 @@ func (d *Document) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (m
|
|||||||
}
|
}
|
||||||
|
|
||||||
return map[string]string{
|
return map[string]string{
|
||||||
"organization_id": organizationID.String(),
|
"organization_id": organizationID.String(),
|
||||||
"document_status": documentStatus.String(),
|
"document_status": documentStatus.String(),
|
||||||
|
"version_status": latestVersionStatus,
|
||||||
|
"last_quorum_status": lastQuorumStatus,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -888,3 +925,56 @@ SELECT EXISTS (
|
|||||||
|
|
||||||
return signed, nil
|
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 (
|
import (
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
"go.probo.inc/probo/pkg/gid"
|
||||||
"go.probo.inc/probo/pkg/mail"
|
"go.probo.inc/probo/pkg/mail"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ type (
|
|||||||
trustCenterVisibilities []TrustCenterVisibility
|
trustCenterVisibilities []TrustCenterVisibility
|
||||||
published *bool
|
published *bool
|
||||||
userEmail *mail.Addr
|
userEmail *mail.Addr
|
||||||
|
approverIdentityID *gid.GID
|
||||||
documentTypes []DocumentType
|
documentTypes []DocumentType
|
||||||
status []DocumentStatus
|
status []DocumentStatus
|
||||||
}
|
}
|
||||||
@@ -58,6 +60,11 @@ func (f *DocumentFilter) WithUserEmail(userEmail *mail.Addr) *DocumentFilter {
|
|||||||
return f
|
return f
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *DocumentFilter) WithApproverIdentityID(identityID *gid.GID) *DocumentFilter {
|
||||||
|
f.approverIdentityID = identityID
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
func (f *DocumentFilter) WithDocumentTypes(documentTypes []DocumentType) *DocumentFilter {
|
func (f *DocumentFilter) WithDocumentTypes(documentTypes []DocumentType) *DocumentFilter {
|
||||||
f.documentTypes = documentTypes
|
f.documentTypes = documentTypes
|
||||||
return f
|
return f
|
||||||
@@ -98,6 +105,7 @@ func (f *DocumentFilter) SQLArguments() pgx.NamedArgs {
|
|||||||
"trust_center_visibilities": visibilities,
|
"trust_center_visibilities": visibilities,
|
||||||
"published": f.published,
|
"published": f.published,
|
||||||
"user_email": f.userEmail,
|
"user_email": f.userEmail,
|
||||||
|
"approver_identity_id": f.approverIdentityID,
|
||||||
"document_types": documentTypes,
|
"document_types": documentTypes,
|
||||||
"document_status": status,
|
"document_status": status,
|
||||||
}
|
}
|
||||||
@@ -142,6 +150,19 @@ func (f *DocumentFilter) SQLFragment() string {
|
|||||||
)
|
)
|
||||||
END
|
END
|
||||||
AND
|
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
|
CASE
|
||||||
WHEN @document_types::document_type[] IS NOT NULL THEN
|
WHEN @document_types::document_type[] IS NOT NULL THEN
|
||||||
document_type = ANY(@document_types::document_type[])
|
document_type = ANY(@document_types::document_type[])
|
||||||
|
|||||||
@@ -50,18 +50,44 @@ type (
|
|||||||
// AuthorizationAttributes returns the authorization attributes for policy evaluation.
|
// AuthorizationAttributes returns the authorization attributes for policy evaluation.
|
||||||
func (dv *DocumentVersion) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (map[string]string, error) {
|
func (dv *DocumentVersion) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (map[string]string, error) {
|
||||||
q := `
|
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
|
SELECT
|
||||||
dv.organization_id,
|
document_version.organization_id,
|
||||||
d.status
|
document.status,
|
||||||
FROM document_versions dv
|
document_version.version_status,
|
||||||
INNER JOIN documents d ON d.id = dv.document_id
|
COALESCE(lq.status, '')
|
||||||
WHERE dv.id = $1
|
FROM document_version
|
||||||
LIMIT 1;
|
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 (
|
||||||
var documentStatus DocumentStatus
|
organizationID gid.GID
|
||||||
if err := conn.QueryRow(ctx, q, dv.ID).Scan(&organizationID, &documentStatus); err != nil {
|
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) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, ErrResourceNotFound
|
return nil, ErrResourceNotFound
|
||||||
}
|
}
|
||||||
@@ -69,8 +95,10 @@ LIMIT 1;
|
|||||||
}
|
}
|
||||||
|
|
||||||
return map[string]string{
|
return map[string]string{
|
||||||
"organization_id": organizationID.String(),
|
"organization_id": organizationID.String(),
|
||||||
"document_status": documentStatus.String(),
|
"document_status": documentStatus.String(),
|
||||||
|
"version_status": documentVersionStatus.String(),
|
||||||
|
"last_quorum_status": lastQuorumStatus,
|
||||||
}, nil
|
}, 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 (
|
import (
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
"go.probo.inc/probo/pkg/gid"
|
||||||
"go.probo.inc/probo/pkg/mail"
|
"go.probo.inc/probo/pkg/mail"
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
DocumentVersionFilter struct {
|
DocumentVersionFilter struct {
|
||||||
userEmail *mail.Addr
|
statuses []DocumentVersionStatus
|
||||||
|
userEmail *mail.Addr
|
||||||
|
approverIdentityID *gid.GID
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,29 +32,65 @@ func NewDocumentVersionFilter() *DocumentVersionFilter {
|
|||||||
return &DocumentVersionFilter{}
|
return &DocumentVersionFilter{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *DocumentVersionFilter) WithStatuses(statuses ...DocumentVersionStatus) *DocumentVersionFilter {
|
||||||
|
f.statuses = statuses
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
func (f *DocumentVersionFilter) WithUserEmail(userEmail *mail.Addr) *DocumentVersionFilter {
|
func (f *DocumentVersionFilter) WithUserEmail(userEmail *mail.Addr) *DocumentVersionFilter {
|
||||||
f.userEmail = userEmail
|
f.userEmail = userEmail
|
||||||
return f
|
return f
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *DocumentVersionFilter) WithApproverIdentityID(identityID *gid.GID) *DocumentVersionFilter {
|
||||||
|
f.approverIdentityID = identityID
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
func (f *DocumentVersionFilter) SQLArguments() pgx.StrictNamedArgs {
|
func (f *DocumentVersionFilter) SQLArguments() pgx.StrictNamedArgs {
|
||||||
|
var filterStatuses []string
|
||||||
|
for _, s := range f.statuses {
|
||||||
|
filterStatuses = append(filterStatuses, s.String())
|
||||||
|
}
|
||||||
|
|
||||||
return pgx.StrictNamedArgs{
|
return pgx.StrictNamedArgs{
|
||||||
"user_email": f.userEmail,
|
"filter_statuses": filterStatuses,
|
||||||
|
"user_email": f.userEmail,
|
||||||
|
"approver_identity_id": f.approverIdentityID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *DocumentVersionFilter) SQLFragment() string {
|
func (f *DocumentVersionFilter) SQLFragment() string {
|
||||||
return `
|
return `
|
||||||
(
|
(
|
||||||
@user_email::text IS NULL
|
(
|
||||||
OR EXISTS (
|
@filter_statuses::text[] IS NULL
|
||||||
SELECT 1
|
OR document_versions.status::text = ANY(@filter_statuses::text[])
|
||||||
FROM document_version_signatures dvs
|
)
|
||||||
INNER JOIN iam_membership_profiles p ON dvs.signed_by_profile_id = p.id
|
AND
|
||||||
INNER JOIN identities i ON p.identity_id = i.id
|
(
|
||||||
WHERE dvs.document_version_id = document_versions.id
|
@user_email::text IS NULL
|
||||||
AND i.email_address = @user_email::CITEXT
|
OR EXISTS (
|
||||||
AND dvs.state IN ('REQUESTED', 'SIGNED')
|
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"`
|
OrganizationID gid.GID `db:"organization_id"`
|
||||||
Status ElectronicSignatureStatus `db:"status"`
|
Status ElectronicSignatureStatus `db:"status"`
|
||||||
DocumentType ElectronicSignatureDocumentType `db:"document_type"`
|
DocumentType ElectronicSignatureDocumentType `db:"document_type"`
|
||||||
|
DocumentName *string `db:"document_name"`
|
||||||
FileID gid.GID `db:"file_id"`
|
FileID gid.GID `db:"file_id"`
|
||||||
SignerEmail string `db:"signer_email"`
|
SignerEmail string `db:"signer_email"`
|
||||||
ConsentText string `db:"consent_text"`
|
ConsentText string `db:"consent_text"`
|
||||||
@@ -83,11 +84,11 @@ func (es *ElectronicSignature) Insert(
|
|||||||
) error {
|
) error {
|
||||||
q := `
|
q := `
|
||||||
INSERT INTO electronic_signatures (
|
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,
|
signer_email, consent_text, seal_version, attempt_count, max_attempts,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
) VALUES (
|
) 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,
|
@signer_email, @consent_text, @seal_version, @attempt_count, @max_attempts,
|
||||||
@created_at, @updated_at
|
@created_at, @updated_at
|
||||||
)
|
)
|
||||||
@@ -98,6 +99,7 @@ INSERT INTO electronic_signatures (
|
|||||||
"organization_id": es.OrganizationID,
|
"organization_id": es.OrganizationID,
|
||||||
"status": es.Status,
|
"status": es.Status,
|
||||||
"document_type": es.DocumentType,
|
"document_type": es.DocumentType,
|
||||||
|
"document_name": es.DocumentName,
|
||||||
"file_id": es.FileID,
|
"file_id": es.FileID,
|
||||||
"signer_email": es.SignerEmail,
|
"signer_email": es.SignerEmail,
|
||||||
"consent_text": es.ConsentText,
|
"consent_text": es.ConsentText,
|
||||||
@@ -184,7 +186,7 @@ func (es *ElectronicSignature) LoadByID(
|
|||||||
) error {
|
) error {
|
||||||
q := `
|
q := `
|
||||||
SELECT
|
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_email, consent_text, signer_full_name, signer_ip_address,
|
||||||
signer_user_agent, file_hash, seal, seal_version, tsa_token, signed_at,
|
signer_user_agent, file_hash, seal, seal_version, tsa_token, signed_at,
|
||||||
certificate_file_id, certificate_processing_started_at,
|
certificate_file_id, certificate_processing_started_at,
|
||||||
@@ -222,7 +224,7 @@ func (es *ElectronicSignature) LoadNextAcceptedForUpdateSkipLocked(
|
|||||||
) error {
|
) error {
|
||||||
q := `
|
q := `
|
||||||
SELECT
|
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_email, consent_text, signer_full_name, signer_ip_address,
|
||||||
signer_user_agent, file_hash, seal, seal_version, tsa_token, signed_at,
|
signer_user_agent, file_hash, seal, seal_version, tsa_token, signed_at,
|
||||||
certificate_file_id, certificate_processing_started_at,
|
certificate_file_id, certificate_processing_started_at,
|
||||||
@@ -258,7 +260,7 @@ func (es *ElectronicSignature) LoadNextCompletedWithoutCertificateForUpdate(
|
|||||||
) error {
|
) error {
|
||||||
q := `
|
q := `
|
||||||
SELECT
|
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_email, consent_text, signer_full_name, signer_ip_address,
|
||||||
signer_user_agent, file_hash, seal, seal_version, tsa_token, signed_at,
|
signer_user_agent, file_hash, seal, seal_version, tsa_token, signed_at,
|
||||||
certificate_file_id, certificate_processing_started_at,
|
certificate_file_id, certificate_processing_started_at,
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ const (
|
|||||||
ElectronicSignatureDocumentTypeSLA ElectronicSignatureDocumentType = "SLA"
|
ElectronicSignatureDocumentTypeSLA ElectronicSignatureDocumentType = "SLA"
|
||||||
ElectronicSignatureDocumentTypeTOS ElectronicSignatureDocumentType = "TOS"
|
ElectronicSignatureDocumentTypeTOS ElectronicSignatureDocumentType = "TOS"
|
||||||
ElectronicSignatureDocumentTypePrivacyPolicy ElectronicSignatureDocumentType = "PRIVACY_POLICY"
|
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"
|
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."
|
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,
|
ElectronicSignatureDocumentTypeSLA,
|
||||||
ElectronicSignatureDocumentTypeTOS,
|
ElectronicSignatureDocumentTypeTOS,
|
||||||
ElectronicSignatureDocumentTypePrivacyPolicy,
|
ElectronicSignatureDocumentTypePrivacyPolicy,
|
||||||
|
ElectronicSignatureDocumentTypeGovernance,
|
||||||
|
ElectronicSignatureDocumentTypePolicy,
|
||||||
|
ElectronicSignatureDocumentTypeProcedure,
|
||||||
|
ElectronicSignatureDocumentTypePlan,
|
||||||
|
ElectronicSignatureDocumentTypeRegister,
|
||||||
|
ElectronicSignatureDocumentTypeRecord,
|
||||||
|
ElectronicSignatureDocumentTypeReport,
|
||||||
|
ElectronicSignatureDocumentTypeTemplate,
|
||||||
ElectronicSignatureDocumentTypeOther,
|
ElectronicSignatureDocumentTypeOther,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,10 +87,26 @@ func (dt *ElectronicSignatureDocumentType) UnmarshalText(data []byte) error {
|
|||||||
*dt = ElectronicSignatureDocumentTypeTOS
|
*dt = ElectronicSignatureDocumentTypeTOS
|
||||||
case ElectronicSignatureDocumentTypePrivacyPolicy.String():
|
case ElectronicSignatureDocumentTypePrivacyPolicy.String():
|
||||||
*dt = ElectronicSignatureDocumentTypePrivacyPolicy
|
*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():
|
case ElectronicSignatureDocumentTypeOther.String():
|
||||||
*dt = ElectronicSignatureDocumentTypeOther
|
*dt = ElectronicSignatureDocumentTypeOther
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("invalid ElectronicSignatureDocumentType value: %q", val)
|
return fmt.Errorf("cannot unmarshal ElectronicSignatureDocumentType: invalid value %q", val)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -87,7 +119,7 @@ func (dt ElectronicSignatureDocumentType) String() string {
|
|||||||
func (dt *ElectronicSignatureDocumentType) Scan(value any) error {
|
func (dt *ElectronicSignatureDocumentType) Scan(value any) error {
|
||||||
val, ok := value.(string)
|
val, ok := value.(string)
|
||||||
if !ok {
|
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))
|
return dt.UnmarshalText([]byte(val))
|
||||||
@@ -113,6 +145,22 @@ func (dt ElectronicSignatureDocumentType) DisplayName() string {
|
|||||||
return "Terms of Service"
|
return "Terms of Service"
|
||||||
case ElectronicSignatureDocumentTypePrivacyPolicy:
|
case ElectronicSignatureDocumentTypePrivacyPolicy:
|
||||||
return "Privacy Policy"
|
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:
|
default:
|
||||||
return string(dt)
|
return string(dt)
|
||||||
}
|
}
|
||||||
@@ -135,11 +183,50 @@ func (dt ElectronicSignatureDocumentType) ConsentText() (string, error) {
|
|||||||
docAgreement = "I agree to these Terms of Service."
|
docAgreement = "I agree to these Terms of Service."
|
||||||
case ElectronicSignatureDocumentTypePrivacyPolicy:
|
case ElectronicSignatureDocumentTypePrivacyPolicy:
|
||||||
docAgreement = "I agree to this Privacy Policy."
|
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:
|
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:
|
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
|
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
|
MailingListUpdateEntityType uint16 = 66
|
||||||
FindingEntityType uint16 = 67
|
FindingEntityType uint16 = 67
|
||||||
AuditLogEntryEntityType uint16 = 68
|
AuditLogEntryEntityType uint16 = 68
|
||||||
|
DocumentVersionApprovalQuorumEntityType uint16 = 69
|
||||||
|
DocumentVersionApprovalDecisionEntityType uint16 = 70
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewEntityFromID(id gid.GID) (any, bool) {
|
func NewEntityFromID(id gid.GID) (any, bool) {
|
||||||
@@ -226,6 +228,10 @@ func NewEntityFromID(id gid.GID) (any, bool) {
|
|||||||
return &MailingListUpdate{ID: id}, true
|
return &MailingListUpdate{ID: id}, true
|
||||||
case AuditLogEntryEntityType:
|
case AuditLogEntryEntityType:
|
||||||
return &AuditLogEntry{ID: id}, true
|
return &AuditLogEntry{ID: id}, true
|
||||||
|
case DocumentVersionApprovalDecisionEntityType:
|
||||||
|
return &DocumentVersionApprovalDecision{ID: id}, true
|
||||||
|
case DocumentVersionApprovalQuorumEntityType:
|
||||||
|
return &DocumentVersionApprovalQuorum{ID: id}, true
|
||||||
default:
|
default:
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -559,150 +559,6 @@ WHERE
|
|||||||
return nil
|
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(
|
func (p *MembershipProfiles) LoadByDocumentVersionID(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
conn pg.Conn,
|
conn pg.Conn,
|
||||||
@@ -711,7 +567,19 @@ func (p *MembershipProfiles) LoadByDocumentVersionID(
|
|||||||
cursor *page.Cursor[MembershipProfileOrderField],
|
cursor *page.Cursor[MembershipProfileOrderField],
|
||||||
) error {
|
) error {
|
||||||
q := `
|
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
|
SELECT
|
||||||
mp.id,
|
mp.id,
|
||||||
mp.identity_id,
|
mp.identity_id,
|
||||||
@@ -747,13 +615,9 @@ WITH profiles AS (
|
|||||||
mp.updated_at
|
mp.updated_at
|
||||||
FROM
|
FROM
|
||||||
iam_membership_profiles mp
|
iam_membership_profiles mp
|
||||||
|
INNER JOIN version_approvers va ON va.approver_id = mp.id
|
||||||
WHERE
|
WHERE
|
||||||
mp.%s
|
mp.%s
|
||||||
AND mp.id IN (
|
|
||||||
SELECT approver_profile_id
|
|
||||||
FROM document_version_approvers
|
|
||||||
WHERE document_version_id = @document_version_id
|
|
||||||
)
|
|
||||||
AND %s
|
AND %s
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
@@ -797,7 +661,7 @@ INNER JOIN identities i ON i.id = p.identity_id
|
|||||||
|
|
||||||
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
|
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, scope.SQLArguments())
|
||||||
maps.Copy(args, cursor.SQLArguments())
|
maps.Copy(args, cursor.SQLArguments())
|
||||||
|
|
||||||
@@ -823,19 +687,26 @@ func (p *MembershipProfiles) CountByDocumentVersionID(
|
|||||||
documentVersionID gid.GID,
|
documentVersionID gid.GID,
|
||||||
) (int, error) {
|
) (int, error) {
|
||||||
q := `
|
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
|
SELECT
|
||||||
COUNT(*)
|
COUNT(DISTINCT mp.id)
|
||||||
FROM
|
FROM
|
||||||
iam_membership_profiles mp
|
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
|
WHERE
|
||||||
mp.%s
|
mp.%s
|
||||||
AND dva.document_version_id = @document_version_id
|
|
||||||
`
|
`
|
||||||
|
|
||||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||||
|
|
||||||
args := pgx.StrictNamedArgs{"document_version_id": documentVersionID}
|
args := pgx.StrictNamedArgs{"version_id": documentVersionID}
|
||||||
maps.Copy(args, scope.SQLArguments())
|
maps.Copy(args, scope.SQLArguments())
|
||||||
|
|
||||||
var count int
|
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>
|
<span class="classification">{{.Classification | classificationString}}</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{{- if gt (len .Approvers) 0}}
|
||||||
<tr>
|
<tr>
|
||||||
<td>Approver{{- if gt (len .Approvers) 1}}s{{- end}}</td>
|
<td>Approver{{- if gt (len .Approvers) 1}}s{{- end}}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -400,6 +401,7 @@
|
|||||||
{{- end}}
|
{{- end}}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{{- end}}
|
||||||
<tr>
|
<tr>
|
||||||
<td>Version:</td>
|
<td>Version:</td>
|
||||||
<td>{{.Version}}</td>
|
<td>{{.Version}}</td>
|
||||||
|
|||||||
@@ -315,8 +315,11 @@ func (w *CompletionCertificateWorker) generateCertificate(
|
|||||||
}
|
}
|
||||||
emailPresenter := emails.NewPresenterFromConfig(w.fileManager, presenterCfg, ref.UnrefOrZero(signature.SignerFullName))
|
emailPresenter := emails.NewPresenterFromConfig(w.fileManager, presenterCfg, ref.UnrefOrZero(signature.SignerFullName))
|
||||||
|
|
||||||
docTypeName := signature.DocumentType.DisplayName()
|
docName := ref.UnrefOrZero(signature.DocumentName)
|
||||||
subject, textBody, htmlBody, err := emailPresenter.RenderElectronicSignatureCertificate(ctx, ref.UnrefOrZero(signature.SignerFullName), docTypeName)
|
if docName == "" {
|
||||||
|
docName = signature.DocumentType.DisplayName()
|
||||||
|
}
|
||||||
|
subject, textBody, htmlBody, err := emailPresenter.RenderElectronicSignatureCertificate(ctx, ref.UnrefOrZero(signature.SignerFullName), docName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("cannot render email: %w", err)
|
return nil, nil, fmt.Errorf("cannot render email: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ type (
|
|||||||
CreateSignatureRequest struct {
|
CreateSignatureRequest struct {
|
||||||
OrganizationID gid.GID
|
OrganizationID gid.GID
|
||||||
DocumentType coredata.ElectronicSignatureDocumentType
|
DocumentType coredata.ElectronicSignatureDocumentType
|
||||||
|
DocumentName *string
|
||||||
FileID gid.GID
|
FileID gid.GID
|
||||||
SignerEmail mail.Addr
|
SignerEmail mail.Addr
|
||||||
ConsentText string // optional; required when DocumentType == OTHER
|
ConsentText string // optional; required when DocumentType == OTHER
|
||||||
@@ -61,6 +62,18 @@ type (
|
|||||||
SignerUA string
|
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 {
|
RecordEventRequest struct {
|
||||||
SignatureID gid.GID
|
SignatureID gid.GID
|
||||||
EventType coredata.ElectronicSignatureEventType
|
EventType coredata.ElectronicSignatureEventType
|
||||||
@@ -161,6 +174,7 @@ func (s *Service) CreateSignature(
|
|||||||
OrganizationID: req.OrganizationID,
|
OrganizationID: req.OrganizationID,
|
||||||
Status: coredata.ElectronicSignatureStatusPending,
|
Status: coredata.ElectronicSignatureStatusPending,
|
||||||
DocumentType: req.DocumentType,
|
DocumentType: req.DocumentType,
|
||||||
|
DocumentName: req.DocumentName,
|
||||||
FileID: stampedFileID,
|
FileID: stampedFileID,
|
||||||
SignerEmail: req.SignerEmail.String(),
|
SignerEmail: req.SignerEmail.String(),
|
||||||
ConsentText: consentText,
|
ConsentText: consentText,
|
||||||
@@ -178,6 +192,59 @@ func (s *Service) CreateSignature(
|
|||||||
return sig, nil
|
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(
|
func (s *Service) createStampedDocument(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
conn pg.Conn,
|
conn pg.Conn,
|
||||||
|
|||||||
@@ -187,15 +187,21 @@ const (
|
|||||||
ActionDocumentSendSigningNotifications = "core:document:send-signing-notifications"
|
ActionDocumentSendSigningNotifications = "core:document:send-signing-notifications"
|
||||||
|
|
||||||
// DocumentVersion actions
|
// DocumentVersion actions
|
||||||
ActionDocumentVersionGet = "core:document-version:get"
|
ActionDocumentVersionGet = "core:document-version:get"
|
||||||
ActionDocumentVersionList = "core:document-version:list"
|
ActionDocumentVersionList = "core:document-version:list"
|
||||||
ActionDocumentVersionExportPDF = "core:document-version:export-pdf"
|
ActionDocumentVersionExportPDF = "core:document-version:export-pdf"
|
||||||
ActionDocumentVersionExportSignable = "core:document-version:export-signable-pdf"
|
ActionDocumentVersionExportSignable = "core:document-version:export-signable-pdf"
|
||||||
ActionDocumentVersionSign = "core:document-version:sign"
|
ActionDocumentVersionSign = "core:document-version:sign"
|
||||||
ActionDocumentVersionUpdate = "core:document-version:update"
|
ActionDocumentVersionUpdate = "core:document-version:update"
|
||||||
ActionDocumentVersionDeleteDraft = "core:document-version:delete-draft"
|
ActionDocumentVersionDeleteDraft = "core:document-version:delete-draft"
|
||||||
ActionDocumentVersionPublish = "core:document-version:publish"
|
ActionDocumentVersionRequestApproval = "core:document-version:request-approval"
|
||||||
ActionDocumentVersionExport = "core:document-version:export"
|
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
|
// DocumentVersionSignature actions
|
||||||
ActionDocumentVersionSignatureRequest = "core:document-version-signature:request"
|
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"
|
"archive/zip"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -76,7 +77,6 @@ type (
|
|||||||
OrganizationID gid.GID
|
OrganizationID gid.GID
|
||||||
Title string
|
Title string
|
||||||
Content string
|
Content string
|
||||||
ApproverIDs []gid.GID
|
|
||||||
Classification coredata.DocumentClassification
|
Classification coredata.DocumentClassification
|
||||||
DocumentType coredata.DocumentType
|
DocumentType coredata.DocumentType
|
||||||
TrustCenterVisibility *coredata.TrustCenterVisibility
|
TrustCenterVisibility *coredata.TrustCenterVisibility
|
||||||
@@ -85,7 +85,6 @@ type (
|
|||||||
UpdateDocumentRequest struct {
|
UpdateDocumentRequest struct {
|
||||||
DocumentID gid.GID
|
DocumentID gid.GID
|
||||||
Title *string
|
Title *string
|
||||||
ApproverIDs []gid.GID
|
|
||||||
Classification *coredata.DocumentClassification
|
Classification *coredata.DocumentClassification
|
||||||
DocumentType *coredata.DocumentType
|
DocumentType *coredata.DocumentType
|
||||||
TrustCenterVisibility *coredata.TrustCenterVisibility
|
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.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
|
||||||
v.Check(cdr.Title, "title", validator.Required(), validator.SafeTextNoNewLine(TitleMaxLength))
|
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.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.Classification, "classification", validator.Required(), validator.OneOfSlice(coredata.DocumentClassifications()))
|
||||||
v.Check(cdr.DocumentType, "document_type", validator.Required(), validator.OneOfSlice(coredata.DocumentTypes()))
|
v.Check(cdr.DocumentType, "document_type", validator.Required(), validator.OneOfSlice(coredata.DocumentTypes()))
|
||||||
v.Check(cdr.TrustCenterVisibility, "trust_center_visibility", validator.OneOfSlice(coredata.TrustCenterVisibilities()))
|
v.Check(cdr.TrustCenterVisibility, "trust_center_visibility", validator.OneOfSlice(coredata.TrustCenterVisibilities()))
|
||||||
@@ -139,9 +134,6 @@ func (udr *UpdateDocumentRequest) Validate() error {
|
|||||||
|
|
||||||
v.Check(udr.DocumentID, "document_id", validator.Required(), validator.GID(coredata.DocumentEntityType))
|
v.Check(udr.DocumentID, "document_id", validator.Required(), validator.GID(coredata.DocumentEntityType))
|
||||||
v.Check(udr.Title, "title", validator.SafeTextNoNewLine(TitleMaxLength))
|
v.Check(udr.Title, "title", validator.SafeTextNoNewLine(TitleMaxLength))
|
||||||
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.Classification, "classification", validator.OneOfSlice(coredata.DocumentClassifications()))
|
||||||
v.Check(udr.DocumentType, "document_type", validator.OneOfSlice(coredata.DocumentTypes()))
|
v.Check(udr.DocumentType, "document_type", validator.OneOfSlice(coredata.DocumentTypes()))
|
||||||
v.Check(udr.TrustCenterVisibility, "trust_center_visibility", validator.OneOfSlice(coredata.TrustCenterVisibilities()))
|
v.Check(udr.TrustCenterVisibility, "trust_center_visibility", validator.OneOfSlice(coredata.TrustCenterVisibilities()))
|
||||||
@@ -244,57 +236,6 @@ func (s *DocumentService) GetByIDs(
|
|||||||
return documents, nil
|
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(
|
func (s *DocumentService) ListVersionApprovers(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
documentVersionID gid.GID,
|
documentVersionID gid.GID,
|
||||||
@@ -602,54 +543,18 @@ func (s *DocumentService) Create(
|
|||||||
return fmt.Errorf("cannot load organization: %w", err)
|
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
|
document.OrganizationID = organization.ID
|
||||||
|
|
||||||
if err := document.Insert(ctx, conn, s.svc.scope); err != nil {
|
if err := document.Insert(ctx, conn, s.svc.scope); err != nil {
|
||||||
return fmt.Errorf("cannot insert document: %w", err)
|
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
|
documentVersion.OrganizationID = organization.ID
|
||||||
|
|
||||||
if err := documentVersion.Insert(ctx, conn, s.svc.scope); err != nil {
|
if err := documentVersion.Insert(ctx, conn, s.svc.scope); err != nil {
|
||||||
return fmt.Errorf("cannot create document version: %w", err)
|
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
|
return nil
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -870,28 +775,6 @@ func (s *DocumentService) UpdateVersion(
|
|||||||
return fmt.Errorf("cannot update document version: %w", err)
|
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
|
return nil
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -1132,23 +1015,6 @@ func (s *DocumentService) CreateDraft(
|
|||||||
return fmt.Errorf("cannot create draft: %w", err)
|
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
|
return nil
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -1460,6 +1326,36 @@ func (s *DocumentService) IsSigned(
|
|||||||
return signed, nil
|
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(
|
func (s *DocumentService) CountForOrganizationID(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
organizationID gid.GID,
|
organizationID gid.GID,
|
||||||
@@ -1656,33 +1552,6 @@ func (s *DocumentService) Update(
|
|||||||
document.TrustCenterVisibility = *req.TrustCenterVisibility
|
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
|
document.UpdatedAt = now
|
||||||
|
|
||||||
if err := document.Update(ctx, tx, s.svc.scope); err != nil {
|
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 {
|
if err := draftVersion.Update(ctx, tx, s.svc.scope); err != nil {
|
||||||
return fmt.Errorf("cannot update draft version: %w", err)
|
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
|
return nil
|
||||||
@@ -2014,25 +1864,53 @@ func exportDocumentPDF(
|
|||||||
return nil, fmt.Errorf("cannot load document: %w", err)
|
return nil, fmt.Errorf("cannot load document: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
versionApprovers := &coredata.DocumentVersionApprovers{}
|
// Only show approvers from the last approved quorum in the export.
|
||||||
if err := versionApprovers.LoadByDocumentVersionID(ctx, conn, scope, documentVersionID); err != nil {
|
var approverNames []string
|
||||||
return nil, fmt.Errorf("cannot load document version approvers: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
approverProfiles := coredata.MembershipProfiles{}
|
lastQuorum := &coredata.DocumentVersionApprovalQuorum{}
|
||||||
if err := approverProfiles.LoadByIDs(ctx, conn, scope, versionApprovers.ApproverProfileIDs()); err != nil {
|
if err := lastQuorum.LoadLastByDocumentVersionID(ctx, conn, scope, documentVersionID); err != nil {
|
||||||
return nil, fmt.Errorf("cannot load document approver profiles: %w", err)
|
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))
|
approverProfileIDs := make([]gid.GID, 0, len(*approvedDecisions))
|
||||||
for _, p := range approverProfiles {
|
for _, d := range *approvedDecisions {
|
||||||
profileByID[p.ID] = p
|
approverProfileIDs = append(approverProfileIDs, d.ApproverID)
|
||||||
}
|
}
|
||||||
|
|
||||||
approverNames := make([]string, 0, len(*versionApprovers))
|
if len(approverProfileIDs) > 0 {
|
||||||
for _, a := range *versionApprovers {
|
approverProfiles := coredata.MembershipProfiles{}
|
||||||
if p, ok := profileByID[a.ApproverProfileID]; ok {
|
if err := approverProfiles.LoadByIDs(ctx, conn, scope, approverProfileIDs); err != nil {
|
||||||
approverNames = append(approverNames, p.FullName)
|
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,
|
ActionDocumentSendSigningNotifications,
|
||||||
ActionDocumentVersionUpdate,
|
ActionDocumentVersionUpdate,
|
||||||
ActionDocumentVersionPublish,
|
ActionDocumentVersionPublish,
|
||||||
|
ActionDocumentVersionRequestApproval,
|
||||||
|
ActionDocumentVersionApprove,
|
||||||
|
ActionDocumentVersionReject,
|
||||||
|
ActionDocumentVersionAddApprover,
|
||||||
|
ActionDocumentVersionRemoveApprover,
|
||||||
ActionDocumentVersionDeleteDraft,
|
ActionDocumentVersionDeleteDraft,
|
||||||
ActionDocumentVersionSignatureRequest,
|
ActionDocumentVersionSignatureRequest,
|
||||||
ActionDocumentVersionCancelSignature,
|
ActionDocumentVersionCancelSignature,
|
||||||
@@ -42,6 +47,31 @@ var (
|
|||||||
organizationCondition,
|
organizationCondition,
|
||||||
policy.Equals("resource.document_status", "ACTIVE"),
|
policy.Equals("resource.document_status", "ACTIVE"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Deny requesting approval when a pending quorum exists
|
||||||
|
documentRequestApprovalNoPendingQuorum = policy.Deny(
|
||||||
|
ActionDocumentVersionRequestApproval,
|
||||||
|
).WithSID("document-request-approval-no-pending-quorum").When(
|
||||||
|
organizationCondition,
|
||||||
|
policy.Equals("resource.last_quorum_status", "PENDING"),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Deny requesting approval when the version is already published
|
||||||
|
documentRequestApprovalNotPublished = policy.Deny(
|
||||||
|
ActionDocumentVersionRequestApproval,
|
||||||
|
).WithSID("document-request-approval-not-published").When(
|
||||||
|
organizationCondition,
|
||||||
|
policy.Equals("resource.version_status", "PUBLISHED"),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Deny adding/removing approvers when there is no pending quorum
|
||||||
|
documentApproverRequiresPendingQuorum = policy.Deny(
|
||||||
|
ActionDocumentVersionAddApprover,
|
||||||
|
ActionDocumentVersionRemoveApprover,
|
||||||
|
).WithSID("document-approver-requires-pending-quorum").When(
|
||||||
|
organizationCondition,
|
||||||
|
policy.NotEquals("resource.last_quorum_status", "PENDING"),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// OwnerPolicy defines permissions for organization owners.
|
// OwnerPolicy defines permissions for organization owners.
|
||||||
@@ -50,6 +80,11 @@ var OwnerPolicy = policy.NewPolicy(
|
|||||||
"Probo Owner",
|
"Probo Owner",
|
||||||
documentWriteActiveOnly,
|
documentWriteActiveOnly,
|
||||||
documentUnarchiveArchivedOnly,
|
documentUnarchiveArchivedOnly,
|
||||||
|
|
||||||
|
documentRequestApprovalNoPendingQuorum,
|
||||||
|
documentRequestApprovalNotPublished,
|
||||||
|
|
||||||
|
documentApproverRequiresPendingQuorum,
|
||||||
policy.Allow("core:*").WithSID("full-core-access").When(organizationCondition),
|
policy.Allow("core:*").WithSID("full-core-access").When(organizationCondition),
|
||||||
).WithDescription("Full probo access for organization owners")
|
).WithDescription("Full probo access for organization owners")
|
||||||
|
|
||||||
@@ -59,6 +94,11 @@ var AdminPolicy = policy.NewPolicy(
|
|||||||
"Probo Admin",
|
"Probo Admin",
|
||||||
documentWriteActiveOnly,
|
documentWriteActiveOnly,
|
||||||
documentUnarchiveArchivedOnly,
|
documentUnarchiveArchivedOnly,
|
||||||
|
|
||||||
|
documentRequestApprovalNoPendingQuorum,
|
||||||
|
documentRequestApprovalNotPublished,
|
||||||
|
|
||||||
|
documentApproverRequiresPendingQuorum,
|
||||||
policy.Allow("core:*").WithSID("full-core-access").When(organizationCondition),
|
policy.Allow("core:*").WithSID("full-core-access").When(organizationCondition),
|
||||||
).WithDescription("Probo admin access - can manage core entities")
|
).WithDescription("Probo admin access - can manage core entities")
|
||||||
|
|
||||||
@@ -66,6 +106,7 @@ var AdminPolicy = policy.NewPolicy(
|
|||||||
var ViewerPolicy = policy.NewPolicy(
|
var ViewerPolicy = policy.NewPolicy(
|
||||||
"probo:viewer",
|
"probo:viewer",
|
||||||
"Probo Viewer",
|
"Probo Viewer",
|
||||||
|
documentWriteActiveOnly,
|
||||||
policy.Allow(
|
policy.Allow(
|
||||||
ActionOrganizationGet,
|
ActionOrganizationGet,
|
||||||
ActionOrganizationGetLogoUrl,
|
ActionOrganizationGetLogoUrl,
|
||||||
@@ -88,6 +129,7 @@ var ViewerPolicy = policy.NewPolicy(
|
|||||||
ActionDocumentGet, ActionDocumentList,
|
ActionDocumentGet, ActionDocumentList,
|
||||||
ActionDocumentVersionGet, ActionDocumentVersionList,
|
ActionDocumentVersionGet, ActionDocumentVersionList,
|
||||||
ActionDocumentVersionSignatureGet, ActionDocumentVersionSignatureList,
|
ActionDocumentVersionSignatureGet, ActionDocumentVersionSignatureList,
|
||||||
|
ActionDocumentVersionApprovalList,
|
||||||
ActionRiskGet, ActionRiskList,
|
ActionRiskGet, ActionRiskList,
|
||||||
ActionAssetGet, ActionAssetList,
|
ActionAssetGet, ActionAssetList,
|
||||||
ActionDatumGet, ActionDatumList,
|
ActionDatumGet, ActionDatumList,
|
||||||
@@ -123,6 +165,10 @@ var ViewerPolicy = policy.NewPolicy(
|
|||||||
ActionDocumentVersionExportPDF, ActionDocumentVersionExportSignable, ActionDocumentVersionSign,
|
ActionDocumentVersionExportPDF, ActionDocumentVersionExportSignable, ActionDocumentVersionSign,
|
||||||
).WithSID("document-signing").When(organizationCondition),
|
).WithSID("document-signing").When(organizationCondition),
|
||||||
|
|
||||||
|
policy.Allow(
|
||||||
|
ActionDocumentVersionApprove, ActionDocumentVersionReject,
|
||||||
|
).WithSID("document-approval").When(organizationCondition),
|
||||||
|
|
||||||
policy.Allow(
|
policy.Allow(
|
||||||
ActionProcessingActivityExport,
|
ActionProcessingActivityExport,
|
||||||
ActionDataProtectionImpactAssessmentExport,
|
ActionDataProtectionImpactAssessmentExport,
|
||||||
@@ -155,6 +201,7 @@ var AuditorPolicy = policy.NewPolicy(
|
|||||||
ActionDocumentGet, ActionDocumentList,
|
ActionDocumentGet, ActionDocumentList,
|
||||||
ActionDocumentVersionGet, ActionDocumentVersionList,
|
ActionDocumentVersionGet, ActionDocumentVersionList,
|
||||||
ActionDocumentVersionSignatureGet, ActionDocumentVersionSignatureList,
|
ActionDocumentVersionSignatureGet, ActionDocumentVersionSignatureList,
|
||||||
|
ActionDocumentVersionApprovalList,
|
||||||
ActionRiskGet, ActionRiskList,
|
ActionRiskGet, ActionRiskList,
|
||||||
ActionAssetGet, ActionAssetList,
|
ActionAssetGet, ActionAssetList,
|
||||||
ActionDatumGet, ActionDatumList,
|
ActionDatumGet, ActionDatumList,
|
||||||
@@ -184,6 +231,7 @@ var AuditorPolicy = policy.NewPolicy(
|
|||||||
var EmployeePolicy = policy.NewPolicy(
|
var EmployeePolicy = policy.NewPolicy(
|
||||||
"probo:employee",
|
"probo:employee",
|
||||||
"Probo Employee",
|
"Probo Employee",
|
||||||
|
documentWriteActiveOnly,
|
||||||
policy.Allow(
|
policy.Allow(
|
||||||
ActionOrganizationGet,
|
ActionOrganizationGet,
|
||||||
ActionOrganizationGetLogoUrl,
|
ActionOrganizationGetLogoUrl,
|
||||||
@@ -198,7 +246,14 @@ var EmployeePolicy = policy.NewPolicy(
|
|||||||
ActionDocumentVersionSign,
|
ActionDocumentVersionSign,
|
||||||
ActionDocumentVersionExportSignable,
|
ActionDocumentVersionExportSignable,
|
||||||
).WithSID("document-version-signing").When(organizationCondition),
|
).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.
|
// ProboPolicySet returns the PolicySet for the probo service.
|
||||||
func ProboPolicySet() *iam.PolicySet {
|
func ProboPolicySet() *iam.PolicySet {
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ type (
|
|||||||
Organizations *OrganizationService
|
Organizations *OrganizationService
|
||||||
Vendors *VendorService
|
Vendors *VendorService
|
||||||
Documents *DocumentService
|
Documents *DocumentService
|
||||||
|
DocumentApprovals *DocumentApprovalService
|
||||||
Controls *ControlService
|
Controls *ControlService
|
||||||
Risks *RiskService
|
Risks *RiskService
|
||||||
VendorComplianceReports *VendorComplianceReportService
|
VendorComplianceReports *VendorComplianceReportService
|
||||||
@@ -212,6 +213,12 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService {
|
|||||||
invitationTokenValidity: s.invitationTokenValidity,
|
invitationTokenValidity: s.invitationTokenValidity,
|
||||||
tokenSecret: s.tokenSecret,
|
tokenSecret: s.tokenSecret,
|
||||||
}
|
}
|
||||||
|
tenantService.DocumentApprovals = &DocumentApprovalService{
|
||||||
|
svc: tenantService,
|
||||||
|
html2pdfConverter: s.html2pdfConverter,
|
||||||
|
invitationTokenValidity: s.invitationTokenValidity,
|
||||||
|
tokenSecret: s.tokenSecret,
|
||||||
|
}
|
||||||
tenantService.Organizations = &OrganizationService{
|
tenantService.Organizations = &OrganizationService{
|
||||||
svc: tenantService,
|
svc: tenantService,
|
||||||
fileValidator: filevalidation.NewValidator(
|
fileValidator: filevalidation.NewValidator(
|
||||||
|
|||||||
@@ -1623,7 +1623,7 @@ input ApplicabilityStatementOrder
|
|||||||
}
|
}
|
||||||
|
|
||||||
input DocumentVersionFilter {
|
input DocumentVersionFilter {
|
||||||
status: DocumentVersionStatus
|
statuses: [DocumentVersionStatus!]
|
||||||
}
|
}
|
||||||
|
|
||||||
# Input Types for Filtering
|
# Input Types for Filtering
|
||||||
@@ -2441,13 +2441,6 @@ type Document implements Node {
|
|||||||
classification: DocumentClassification!
|
classification: DocumentClassification!
|
||||||
currentPublishedVersion: Int
|
currentPublishedVersion: Int
|
||||||
trustCenterVisibility: TrustCenterVisibility!
|
trustCenterVisibility: TrustCenterVisibility!
|
||||||
approvers(
|
|
||||||
first: Int
|
|
||||||
after: CursorKey
|
|
||||||
last: Int
|
|
||||||
before: CursorKey
|
|
||||||
orderBy: ProfileOrder
|
|
||||||
): ProfileConnection! @goField(forceResolver: true)
|
|
||||||
organization: Organization! @goField(forceResolver: true)
|
organization: Organization! @goField(forceResolver: true)
|
||||||
|
|
||||||
versions(
|
versions(
|
||||||
@@ -2477,16 +2470,17 @@ type Document implements Node {
|
|||||||
permission(action: String!): Boolean! @goField(forceResolver: true)
|
permission(action: String!): Boolean! @goField(forceResolver: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
type SignableDocument
|
type EmployeeDocument
|
||||||
@goModel(
|
@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!
|
id: ID!
|
||||||
title: String!
|
title: String!
|
||||||
description: String
|
description: String
|
||||||
documentType: DocumentType!
|
documentType: DocumentType!
|
||||||
classification: DocumentClassification!
|
classification: DocumentClassification!
|
||||||
signed: Boolean! @goField(forceResolver: true)
|
signed: Boolean @goField(forceResolver: true)
|
||||||
|
approvalState: DocumentVersionApprovalDecisionState @goField(forceResolver: true)
|
||||||
|
|
||||||
versions(
|
versions(
|
||||||
first: Int
|
first: Int
|
||||||
@@ -2494,13 +2488,26 @@ type SignableDocument
|
|||||||
last: Int
|
last: Int
|
||||||
before: CursorKey
|
before: CursorKey
|
||||||
orderBy: DocumentVersionOrder
|
orderBy: DocumentVersionOrder
|
||||||
filter: DocumentVersionFilter
|
): EmployeeDocumentVersionConnection! @goField(forceResolver: true)
|
||||||
): DocumentVersionConnection! @goField(forceResolver: true)
|
|
||||||
|
|
||||||
createdAt: Datetime!
|
createdAt: Datetime!
|
||||||
updatedAt: 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 {
|
type Meeting implements Node {
|
||||||
id: ID!
|
id: ID!
|
||||||
name: String!
|
name: String!
|
||||||
@@ -2952,9 +2959,20 @@ type Viewer {
|
|||||||
last: Int
|
last: Int
|
||||||
before: CursorKey
|
before: CursorKey
|
||||||
orderBy: DocumentOrder
|
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 {
|
type TrustCenterConnection {
|
||||||
@@ -3226,20 +3244,36 @@ type EvidenceEdge {
|
|||||||
node: Evidence!
|
node: Evidence!
|
||||||
}
|
}
|
||||||
|
|
||||||
type SignableDocumentConnection
|
type EmployeeDocumentConnection
|
||||||
@goModel(
|
@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!
|
pageInfo: PageInfo!
|
||||||
}
|
}
|
||||||
|
|
||||||
type SignableDocumentEdge
|
type EmployeeDocumentEdge
|
||||||
@goModel(
|
@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!
|
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
|
type DocumentConnection
|
||||||
@@ -3762,6 +3796,9 @@ type Mutation {
|
|||||||
bulkPublishDocumentVersions(
|
bulkPublishDocumentVersions(
|
||||||
input: BulkPublishDocumentVersionsInput!
|
input: BulkPublishDocumentVersionsInput!
|
||||||
): BulkPublishDocumentVersionsPayload!
|
): BulkPublishDocumentVersionsPayload!
|
||||||
|
requestDocumentVersionApproval(
|
||||||
|
input: RequestDocumentVersionApprovalInput!
|
||||||
|
): RequestDocumentVersionApprovalPayload!
|
||||||
bulkDeleteDocuments(
|
bulkDeleteDocuments(
|
||||||
input: BulkDeleteDocumentsInput!
|
input: BulkDeleteDocumentsInput!
|
||||||
): BulkDeleteDocumentsPayload!
|
): BulkDeleteDocumentsPayload!
|
||||||
@@ -3797,6 +3834,18 @@ type Mutation {
|
|||||||
input: CancelSignatureRequestInput!
|
input: CancelSignatureRequestInput!
|
||||||
): CancelSignatureRequestPayload!
|
): CancelSignatureRequestPayload!
|
||||||
signDocument(input: SignDocumentInput!): SignDocumentPayload!
|
signDocument(input: SignDocumentInput!): SignDocumentPayload!
|
||||||
|
addDocumentVersionApprover(
|
||||||
|
input: AddDocumentVersionApproverInput!
|
||||||
|
): AddDocumentVersionApproverPayload!
|
||||||
|
removeDocumentVersionApprover(
|
||||||
|
input: RemoveDocumentVersionApproverInput!
|
||||||
|
): RemoveDocumentVersionApproverPayload!
|
||||||
|
approveDocumentVersion(
|
||||||
|
input: ApproveDocumentVersionInput!
|
||||||
|
): ApproveDocumentVersionPayload!
|
||||||
|
rejectDocumentVersion(
|
||||||
|
input: RejectDocumentVersionInput!
|
||||||
|
): RejectDocumentVersionPayload!
|
||||||
|
|
||||||
exportDocumentVersionPDF(
|
exportDocumentVersionPDF(
|
||||||
input: ExportDocumentVersionPDFInput!
|
input: ExportDocumentVersionPDFInput!
|
||||||
@@ -4409,7 +4458,6 @@ input CreateDocumentInput {
|
|||||||
organizationId: ID!
|
organizationId: ID!
|
||||||
title: String!
|
title: String!
|
||||||
content: String!
|
content: String!
|
||||||
approverIds: [ID!]!
|
|
||||||
documentType: DocumentType!
|
documentType: DocumentType!
|
||||||
classification: DocumentClassification!
|
classification: DocumentClassification!
|
||||||
trustCenterVisibility: TrustCenterVisibility
|
trustCenterVisibility: TrustCenterVisibility
|
||||||
@@ -4419,7 +4467,6 @@ input UpdateDocumentInput {
|
|||||||
id: ID!
|
id: ID!
|
||||||
title: String
|
title: String
|
||||||
content: String
|
content: String
|
||||||
approverIds: [ID!]
|
|
||||||
documentType: DocumentType
|
documentType: DocumentType
|
||||||
classification: DocumentClassification
|
classification: DocumentClassification
|
||||||
trustCenterVisibility: TrustCenterVisibility
|
trustCenterVisibility: TrustCenterVisibility
|
||||||
@@ -5324,6 +5371,14 @@ type DocumentVersion implements Node {
|
|||||||
filter: DocumentVersionSignatureFilter
|
filter: DocumentVersionSignatureFilter
|
||||||
): DocumentVersionSignatureConnection! @goField(forceResolver: true)
|
): DocumentVersionSignatureConnection! @goField(forceResolver: true)
|
||||||
|
|
||||||
|
approvalQuorums(
|
||||||
|
first: Int
|
||||||
|
after: CursorKey
|
||||||
|
last: Int
|
||||||
|
before: CursorKey
|
||||||
|
orderBy: DocumentVersionApprovalQuorumOrder
|
||||||
|
): DocumentVersionApprovalQuorumConnection! @goField(forceResolver: true)
|
||||||
|
|
||||||
signed: Boolean! @goField(forceResolver: true)
|
signed: Boolean! @goField(forceResolver: true)
|
||||||
|
|
||||||
publishedAt: Datetime
|
publishedAt: Datetime
|
||||||
@@ -5398,6 +5453,170 @@ type DocumentVersionSignature implements Node {
|
|||||||
permission(action: String!): Boolean! @goField(forceResolver: true)
|
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 {
|
input RequestSignatureInput {
|
||||||
documentVersionId: ID!
|
documentVersionId: ID!
|
||||||
signatoryId: ID!
|
signatoryId: ID!
|
||||||
@@ -5416,16 +5635,6 @@ type BulkRequestSignaturesPayload {
|
|||||||
documentVersionSignatureEdges: [DocumentVersionSignatureEdge!]!
|
documentVersionSignatureEdges: [DocumentVersionSignatureEdge!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
input BulkPublishDocumentVersionsInput {
|
|
||||||
documentIds: [ID!]!
|
|
||||||
changelog: String!
|
|
||||||
}
|
|
||||||
|
|
||||||
type BulkPublishDocumentVersionsPayload {
|
|
||||||
documentVersionEdges: [DocumentVersionEdge!]!
|
|
||||||
documentEdges: [DocumentEdge!]!
|
|
||||||
}
|
|
||||||
|
|
||||||
input BulkDeleteDocumentsInput {
|
input BulkDeleteDocumentsInput {
|
||||||
documentIds: [ID!]!
|
documentIds: [ID!]!
|
||||||
}
|
}
|
||||||
@@ -5461,14 +5670,34 @@ type BulkExportDocumentsPayload {
|
|||||||
exportJobId: ID!
|
exportJobId: ID!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input RequestDocumentVersionApprovalInput {
|
||||||
|
documentId: ID!
|
||||||
|
approverIds: [ID!]!
|
||||||
|
changelog: String
|
||||||
|
}
|
||||||
|
|
||||||
|
type RequestDocumentVersionApprovalPayload {
|
||||||
|
approvalQuorum: DocumentVersionApprovalQuorum!
|
||||||
|
}
|
||||||
|
|
||||||
input PublishDocumentVersionInput {
|
input PublishDocumentVersionInput {
|
||||||
documentId: ID!
|
documentId: ID!
|
||||||
changelog: String
|
changelog: String
|
||||||
}
|
}
|
||||||
|
|
||||||
type PublishDocumentVersionPayload {
|
type PublishDocumentVersionPayload {
|
||||||
documentVersion: DocumentVersion!
|
|
||||||
document: Document!
|
document: Document!
|
||||||
|
documentVersion: DocumentVersion!
|
||||||
|
}
|
||||||
|
|
||||||
|
input BulkPublishDocumentVersionsInput {
|
||||||
|
documentIds: [ID!]!
|
||||||
|
changelog: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type BulkPublishDocumentVersionsPayload {
|
||||||
|
documentVersions: [DocumentVersion!]!
|
||||||
|
documents: [Document!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateDraftDocumentVersionPayload {
|
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)
|
return nil, types.ListControlDocumentsOutput{}, fmt.Errorf("failed to list control documents: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
approverIDsMap := make(map[gid.GID][]gid.GID)
|
return nil, types.NewListControlDocumentsOutput(docPage), nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Resolver) ListControlAuditsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListControlAuditsInput) (*mcp.CallToolResult, types.ListControlAuditsOutput, error) {
|
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))
|
panic(fmt.Errorf("cannot list organization documents: %w", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
approverIDsMap := make(map[gid.GID][]gid.GID)
|
return nil, types.NewListDocumentsOutput(docPage), nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Resolver) GetDocumentTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetDocumentInput) (*mcp.CallToolResult, types.GetDocumentOutput, error) {
|
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))
|
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{
|
return nil, types.GetDocumentOutput{
|
||||||
Document: types.NewDocument(document, profileIDs(approverPage)),
|
Document: types.NewDocument(document),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2107,7 +2084,6 @@ func (r *Resolver) AddDocumentTool(ctx context.Context, req *mcp.CallToolRequest
|
|||||||
OrganizationID: input.OrganizationID,
|
OrganizationID: input.OrganizationID,
|
||||||
Title: input.Title,
|
Title: input.Title,
|
||||||
Content: input.Content,
|
Content: input.Content,
|
||||||
ApproverIDs: input.ApproverIds,
|
|
||||||
Classification: input.Classification,
|
Classification: input.Classification,
|
||||||
DocumentType: input.DocumentType,
|
DocumentType: input.DocumentType,
|
||||||
TrustCenterVisibility: trustCenterVisibility,
|
TrustCenterVisibility: trustCenterVisibility,
|
||||||
@@ -2117,7 +2093,7 @@ func (r *Resolver) AddDocumentTool(ctx context.Context, req *mcp.CallToolRequest
|
|||||||
panic(fmt.Errorf("cannot create document: %w", err))
|
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) {
|
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{
|
probo.UpdateDocumentRequest{
|
||||||
DocumentID: input.ID,
|
DocumentID: input.ID,
|
||||||
Title: input.Title,
|
Title: input.Title,
|
||||||
ApproverIDs: input.ApproverIds,
|
|
||||||
Classification: input.Classification,
|
Classification: input.Classification,
|
||||||
DocumentType: input.DocumentType,
|
DocumentType: input.DocumentType,
|
||||||
TrustCenterVisibility: input.TrustCenterVisibility,
|
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))
|
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{
|
return nil, types.UpdateDocumentOutput{
|
||||||
Document: types.NewDocument(document, profileIDs(approverPage)),
|
Document: types.NewDocument(document),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2172,16 +2142,7 @@ func (r *Resolver) ListDocumentVersionsTool(ctx context.Context, req *mcp.CallTo
|
|||||||
panic(fmt.Errorf("cannot list document versions: %w", err))
|
panic(fmt.Errorf("cannot list document versions: %w", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
approverIDsMap := make(map[gid.GID][]gid.GID)
|
return nil, types.NewListDocumentVersionsOutput(versionPage), nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Resolver) GetDocumentVersionTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetDocumentVersionInput) (*mcp.CallToolResult, types.GetDocumentVersionOutput, error) {
|
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))
|
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{
|
return nil, types.GetDocumentVersionOutput{
|
||||||
DocumentVersion: types.NewDocumentVersion(version, profileIDs(approverPage)),
|
DocumentVersion: types.NewDocumentVersion(version),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2214,13 +2170,8 @@ func (r *Resolver) CreateDraftDocumentVersionTool(ctx context.Context, req *mcp.
|
|||||||
panic(fmt.Errorf("cannot create draft document version: %w", err))
|
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{
|
return nil, types.CreateDraftDocumentVersionOutput{
|
||||||
DocumentVersion: types.NewDocumentVersion(draftVersion, profileIDs(approverPage)),
|
DocumentVersion: types.NewDocumentVersion(draftVersion),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2240,13 +2191,8 @@ func (r *Resolver) UpdateDocumentVersionTool(ctx context.Context, req *mcp.CallT
|
|||||||
panic(fmt.Errorf("cannot update document version: %w", err))
|
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{
|
return nil, types.UpdateDocumentVersionOutput{
|
||||||
DocumentVersion: types.NewDocumentVersion(documentVersion, profileIDs(versionApproverPage)),
|
DocumentVersion: types.NewDocumentVersion(documentVersion),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2262,19 +2208,9 @@ func (r *Resolver) PublishDocumentVersionTool(ctx context.Context, req *mcp.Call
|
|||||||
panic(fmt.Errorf("cannot publish document version: %w", err))
|
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{
|
return nil, types.PublishDocumentVersionOutput{
|
||||||
Document: types.NewDocument(document, profileIDs(docApproverPage)),
|
Document: types.NewDocument(document),
|
||||||
DocumentVersion: types.NewDocumentVersion(documentVersion, profileIDs(versionApproverPage)),
|
DocumentVersion: types.NewDocumentVersion(documentVersion),
|
||||||
}, nil
|
}, 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)
|
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{
|
return nil, types.ArchiveDocumentOutput{
|
||||||
Document: types.NewDocument(document, profileIDs(approverPage)),
|
Document: types.NewDocument(document),
|
||||||
}, nil
|
}, 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)
|
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{
|
return nil, types.UnarchiveDocumentOutput{
|
||||||
Document: types.NewDocument(document, profileIDs(approverPage)),
|
Document: types.NewDocument(document),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3374,3 +3300,27 @@ func (r *Resolver) GetAuditLogEntryTool(ctx context.Context, req *mcp.CallToolRe
|
|||||||
AuditLogEntry: types.NewAuditLogEntry(entry),
|
AuditLogEntry: types.NewAuditLogEntry(entry),
|
||||||
}, nil
|
}, 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:
|
required:
|
||||||
- id
|
- id
|
||||||
- organization_id
|
- organization_id
|
||||||
- approver_ids
|
|
||||||
- title
|
- title
|
||||||
- document_type
|
- document_type
|
||||||
- classification
|
- classification
|
||||||
@@ -5199,11 +5198,6 @@ components:
|
|||||||
organization_id:
|
organization_id:
|
||||||
$ref: "#/components/schemas/GID"
|
$ref: "#/components/schemas/GID"
|
||||||
description: Organization ID
|
description: Organization ID
|
||||||
approver_ids:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: "#/components/schemas/GID"
|
|
||||||
description: Approver IDs
|
|
||||||
title:
|
title:
|
||||||
type: string
|
type: string
|
||||||
description: Document title
|
description: Document title
|
||||||
@@ -5246,7 +5240,6 @@ components:
|
|||||||
- organization_id
|
- organization_id
|
||||||
- document_id
|
- document_id
|
||||||
- title
|
- title
|
||||||
- approver_ids
|
|
||||||
- version_number
|
- version_number
|
||||||
- classification
|
- classification
|
||||||
- content
|
- content
|
||||||
@@ -5267,11 +5260,6 @@ components:
|
|||||||
title:
|
title:
|
||||||
type: string
|
type: string
|
||||||
description: Document version title
|
description: Document version title
|
||||||
approver_ids:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: "#/components/schemas/GID"
|
|
||||||
description: Approver IDs
|
|
||||||
version_number:
|
version_number:
|
||||||
type: integer
|
type: integer
|
||||||
description: Version number
|
description: Version number
|
||||||
@@ -5418,7 +5406,6 @@ components:
|
|||||||
- organization_id
|
- organization_id
|
||||||
- title
|
- title
|
||||||
- content
|
- content
|
||||||
- approver_ids
|
|
||||||
- classification
|
- classification
|
||||||
- document_type
|
- document_type
|
||||||
properties:
|
properties:
|
||||||
@@ -5431,11 +5418,6 @@ components:
|
|||||||
content:
|
content:
|
||||||
type: string
|
type: string
|
||||||
description: Document content
|
description: Document content
|
||||||
approver_ids:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: "#/components/schemas/GID"
|
|
||||||
description: Approver IDs
|
|
||||||
classification:
|
classification:
|
||||||
$ref: "#/components/schemas/DocumentClassification"
|
$ref: "#/components/schemas/DocumentClassification"
|
||||||
description: Document classification
|
description: Document classification
|
||||||
@@ -5468,11 +5450,6 @@ components:
|
|||||||
title:
|
title:
|
||||||
type: string
|
type: string
|
||||||
description: Document title
|
description: Document title
|
||||||
approver_ids:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: "#/components/schemas/GID"
|
|
||||||
description: Approver IDs
|
|
||||||
classification:
|
classification:
|
||||||
$ref: "#/components/schemas/DocumentClassification"
|
$ref: "#/components/schemas/DocumentClassification"
|
||||||
description: Document classification
|
description: Document classification
|
||||||
@@ -5639,7 +5616,7 @@ components:
|
|||||||
description: Document ID
|
description: Document ID
|
||||||
changelog:
|
changelog:
|
||||||
type: string
|
type: string
|
||||||
description: Changelog
|
description: Changelog for this version
|
||||||
|
|
||||||
PublishDocumentVersionOutput:
|
PublishDocumentVersionOutput:
|
||||||
type: object
|
type: object
|
||||||
@@ -5652,6 +5629,32 @@ components:
|
|||||||
document_version:
|
document_version:
|
||||||
$ref: "#/components/schemas/DocumentVersion"
|
$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:
|
DeleteDocumentInput:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
@@ -7476,6 +7479,14 @@ tools:
|
|||||||
$ref: "#/components/schemas/PublishDocumentVersionInput"
|
$ref: "#/components/schemas/PublishDocumentVersionInput"
|
||||||
outputSchema:
|
outputSchema:
|
||||||
$ref: "#/components/schemas/PublishDocumentVersionOutput"
|
$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
|
- name: deleteDocument
|
||||||
description: Delete a document
|
description: Delete a document
|
||||||
hints:
|
hints:
|
||||||
|
|||||||
@@ -16,15 +16,13 @@ package types
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"go.probo.inc/probo/pkg/coredata"
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
"go.probo.inc/probo/pkg/gid"
|
|
||||||
"go.probo.inc/probo/pkg/page"
|
"go.probo.inc/probo/pkg/page"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewDocument(d *coredata.Document, approverIDs []gid.GID) *Document {
|
func NewDocument(d *coredata.Document) *Document {
|
||||||
return &Document{
|
return &Document{
|
||||||
ID: d.ID,
|
ID: d.ID,
|
||||||
OrganizationID: d.OrganizationID,
|
OrganizationID: d.OrganizationID,
|
||||||
ApproverIds: approverIDs,
|
|
||||||
Title: d.Title,
|
Title: d.Title,
|
||||||
DocumentType: d.DocumentType,
|
DocumentType: d.DocumentType,
|
||||||
Classification: d.Classification,
|
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))
|
documents := make([]*Document, 0, len(documentPage.Data))
|
||||||
for _, d := range documentPage.Data {
|
for _, d := range documentPage.Data {
|
||||||
documents = append(documents, NewDocument(d, approverIDsMap[d.ID]))
|
documents = append(documents, NewDocument(d))
|
||||||
}
|
}
|
||||||
|
|
||||||
var nextCursor *page.CursorKey
|
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))
|
documents := make([]*Document, 0, len(documentPage.Data))
|
||||||
for _, d := range documentPage.Data {
|
for _, d := range documentPage.Data {
|
||||||
documents = append(documents, NewDocument(d, approverIDsMap[d.ID]))
|
documents = append(documents, NewDocument(d))
|
||||||
}
|
}
|
||||||
|
|
||||||
var nextCursor *page.CursorKey
|
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{
|
return AddDocumentOutput{
|
||||||
Document: NewDocument(doc, docApproverIDs),
|
Document: NewDocument(doc),
|
||||||
DocumentVersion: NewDocumentVersion(docVersion, versionApproverIDs),
|
DocumentVersion: NewDocumentVersion(docVersion),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDocumentVersion(dv *coredata.DocumentVersion, approverIDs []gid.GID) *DocumentVersion {
|
func NewDocumentVersion(dv *coredata.DocumentVersion) *DocumentVersion {
|
||||||
return &DocumentVersion{
|
return &DocumentVersion{
|
||||||
ID: dv.ID,
|
ID: dv.ID,
|
||||||
OrganizationID: dv.OrganizationID,
|
OrganizationID: dv.OrganizationID,
|
||||||
DocumentID: dv.DocumentID,
|
DocumentID: dv.DocumentID,
|
||||||
Title: dv.Title,
|
Title: dv.Title,
|
||||||
ApproverIds: approverIDs,
|
|
||||||
VersionNumber: dv.VersionNumber,
|
VersionNumber: dv.VersionNumber,
|
||||||
Classification: dv.Classification,
|
Classification: dv.Classification,
|
||||||
Content: dv.Content,
|
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))
|
versions := make([]*DocumentVersion, 0, len(versionPage.Data))
|
||||||
for _, v := range versionPage.Data {
|
for _, v := range versionPage.Data {
|
||||||
versions = append(versions, NewDocumentVersion(v, approverIDsMap[v.ID]))
|
versions = append(versions, NewDocumentVersion(v))
|
||||||
}
|
}
|
||||||
|
|
||||||
var nextCursor *page.CursorKey
|
var nextCursor *page.CursorKey
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
|
"errors"
|
||||||
"go.gearno.de/kit/pg"
|
"go.gearno.de/kit/pg"
|
||||||
"go.probo.inc/probo/pkg/coredata"
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
"go.probo.inc/probo/pkg/docgen"
|
"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)
|
return fmt.Errorf("cannot load latest published document version: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load approvers
|
lastQuorum := &coredata.DocumentVersionApprovalQuorum{}
|
||||||
docApprovers := &coredata.DocumentApprovers{}
|
if err := lastQuorum.LoadLastByDocumentVersionID(ctx, conn, s.svc.scope, version.ID); err != nil {
|
||||||
if err := docApprovers.LoadByDocumentID(ctx, conn, s.svc.scope, documentID); err != nil {
|
if !errors.Is(err, coredata.ErrResourceNotFound) {
|
||||||
return fmt.Errorf("cannot load document approvers: %w", err)
|
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{}
|
approverProfileIDs := make([]gid.GID, 0, len(*approvedDecisions))
|
||||||
if err := profiles.LoadByIDs(ctx, conn, s.svc.scope, docApprovers.ApproverProfileIDs()); err != nil {
|
for _, d := range *approvedDecisions {
|
||||||
return fmt.Errorf("cannot load document approver profiles: %w", err)
|
approverProfileIDs = append(approverProfileIDs, d.ApproverID)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, p := range profiles {
|
if len(approverProfileIDs) > 0 {
|
||||||
approverNames = append(approverNames, p.FullName)
|
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 {
|
if err := organization.LoadByID(ctx, conn, s.svc.scope, document.OrganizationID); err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user