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:
Sacha Al Himdani
2026-03-27 17:22:54 +01:00
parent 4a2d308da0
commit 999171a626
78 changed files with 6483 additions and 1600 deletions

View File

@@ -1,5 +1,5 @@
import { useTranslate } from "@probo/i18n";
import { Avatar, Badge, Button, Field, IconCrossLargeX, Option, Select } from "@probo/ui";
import { Badge, Button, Field, IconCrossLargeX, Option, Select } from "@probo/ui";
import { type ComponentProps, Suspense, useState } from "react";
import { type Control, Controller, type FieldValues, type Path } from "react-hook-form";
@@ -104,10 +104,6 @@ function PeopleMultiSelectWithQuery<T extends FieldValues = FieldValues>(
>
{availablePeople.map(person => (
<Option key={person.id} value={person.id} className="flex gap-2">
<Avatar
name={person.fullName}
size="s"
/>
<div className="flex flex-col">
<span>{person.fullName}</span>
{person.emailAddress && (
@@ -125,10 +121,6 @@ function PeopleMultiSelectWithQuery<T extends FieldValues = FieldValues>(
<div className="flex flex-wrap gap-2">
{selectedPeople.map(person => (
<Badge key={person.id} variant="neutral" className="flex items-center gap-2">
<Avatar
name={person.fullName}
size="s"
/>
<span>{person.fullName}</span>
{!props.disabled && (
<Button

View File

@@ -5,7 +5,6 @@ import { useFormWithSchema } from "../useFormWithSchema";
export const documentSchema = z.object({
title: z.string().min(1, "Title is required"),
content: z.string().min(1, "Content is required"),
approverIds: z.array(z.string()).min(1, "At least one approver is required"),
documentType: z.enum(["OTHER", "GOVERNANCE", "POLICY", "PROCEDURE", "PLAN", "REGISTER", "RECORD", "REPORT", "TEMPLATE"]),
classification: z.enum(["PUBLIC", "INTERNAL", "CONFIDENTIAL", "SECRET"]),
});

View File

@@ -24,7 +24,7 @@ const documentFragment = graphql`
latestPublishedVersion: versions(
first: 1
orderBy: { field: CREATED_AT, direction: DESC }
filter: { status: PUBLISHED }
filter: { statuses: [PUBLISHED] }
) {
edges {
node {

View File

@@ -1,17 +1,18 @@
import { useTranslate } from "@probo/i18n";
import { Breadcrumb, Button, IconCheckmark1, PageHeader, TabBadge, TabLink, Tabs } from "@probo/ui";
import { Breadcrumb, Button, IconUpload, PageHeader, TabBadge, TabLink, Tabs } from "@probo/ui";
import { useCallback, useRef, useState } from "react";
import { type PreloadedQuery, usePreloadedQuery } from "react-relay";
import { Outlet, useParams } from "react-router";
import { graphql } from "relay-runtime";
import type { DocumentLayoutQuery } from "#/__generated__/core/DocumentLayoutQuery.graphql";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { DocumentActionsDropdownn } from "./_components/DocumentActionsDropdown";
import { DocumentLayoutDrawer } from "./_components/DocumentLayoutDrawer";
import { DocumentTitleForm } from "./_components/DocumentTitleForm";
import { DocumentVersionsDropdown } from "./_components/DocumentVersionsDropdown";
import { PublishDialog, type PublishDialogRef } from "./_components/PublishDialog";
export const documentLayoutQuery = graphql`
query DocumentLayoutQuery($documentId: ID! $versionId: ID! $versionSpecified: Boolean!) {
@@ -29,6 +30,20 @@ export const documentLayoutQuery = graphql`
signedSignatures: signatures(first: 0 filter: { states: [SIGNED], activeContract: true }) {
totalCount
}
approvalQuorums(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
edges {
node {
id
status
decisions(first: 0) {
totalCount
}
approvedDecisions: decisions(first: 0 filter: { states: [APPROVED] }) {
totalCount
}
}
}
}
}
}
document: node(id: $documentId) {
@@ -38,6 +53,7 @@ export const documentLayoutQuery = graphql`
title
status
canPublish: permission(action: "core:document-version:publish")
...PublishDialog_documentFragment
controlInfo: controls(first: 0) {
totalCount
}
@@ -58,6 +74,20 @@ export const documentLayoutQuery = graphql`
signedSignatures: signatures(first: 0 filter: { states: [SIGNED], activeContract: true }) {
totalCount
}
approvalQuorums(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
edges {
node {
id
status
decisions(first: 0) {
totalCount
}
approvedDecisions: decisions(first: 0 filter: { states: [APPROVED] }) {
totalCount
}
}
}
}
}
}
}
@@ -66,18 +96,6 @@ export const documentLayoutQuery = graphql`
}
`;
const publishDocumentVersionMutation = graphql`
mutation DocumentLayout_publishVersionMutation(
$input: PublishDocumentVersionInput!
) {
publishDocumentVersion(input: $input) {
document {
id
}
}
}
`;
export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQuery>; onRefetch: () => void }) {
const { queryRef, onRefetch } = props;
@@ -86,6 +104,14 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
const { __ } = useTranslate();
const publishDialogRef = useRef<PublishDialogRef>(null);
const [approvalRequestedAt, setApprovalRequestedAt] = useState(0);
const handlePublishOrApproval = useCallback(() => {
onRefetch();
setApprovalRequestedAt(Date.now());
}, [onRefetch]);
const { document, version } = usePreloadedQuery<DocumentLayoutQuery>(documentLayoutQuery, queryRef);
if (document.__typename !== "Document" || (version && version.__typename !== "DocumentVersion")) {
throw new Error("invalid node type");
@@ -99,23 +125,10 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
// It is ok to cas as NonNullable here since we know we have either version or lastVersion
const currentVersion = version ?? lastVersion as NonNullable<typeof version | typeof lastVersion>;
const isDraft = currentVersion.status === "DRAFT";
const [publishDocumentVersion, isPublishing] = useMutationWithToasts(
publishDocumentVersionMutation,
{
successMessage: __("Document published successfully."),
errorMessage: __("Failed to publish document"),
},
);
const handlePublish = async () => {
await publishDocumentVersion({
variables: {
input: { documentId: document.id },
},
onSuccess: onRefetch,
});
};
const isPublished = currentVersion.status === "PUBLISHED";
const lastQuorum = currentVersion.approvalQuorums?.edges?.[0]?.node ?? null;
const hasApprovals = lastQuorum != null;
const hasPendingApproval = lastQuorum?.status === "PENDING";
const urlPrefix = versionId
? `/organizations/${organizationId}/documents/${document.id}/versions/${versionId}`
@@ -140,9 +153,8 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
<div className="flex gap-2">
{isDraft && document.canPublish && (
<Button
onClick={() => void handlePublish()}
icon={IconCheckmark1}
disabled={isPublishing}
icon={IconUpload}
onClick={() => publishDialogRef.current?.open()}
>
{__("Publish")}
</Button>
@@ -166,7 +178,17 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
{__("Controls")}
<TabBadge>{document.controlInfo.totalCount}</TabBadge>
</TabLink>
{!isDraft && (
{hasApprovals && (
<TabLink to={`${urlPrefix}/approvals`}>
{__("Approvals")}
<TabBadge>
{lastQuorum?.status === "REJECTED"
? __("Rejected")
: `${lastQuorum?.approvedDecisions.totalCount ?? 0}/${lastQuorum?.decisions.totalCount ?? 0}`}
</TabBadge>
</TabLink>
)}
{isPublished && (
<TabLink to={`${urlPrefix}/signatures`}>
{__("Signatures")}
<TabBadge>
@@ -178,10 +200,18 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
)}
</Tabs>
<Outlet />
<Outlet context={{ onRefetch, approvalRequestedAt }} />
</div>
<DocumentLayoutDrawer documentFragmentRef={document} versionFragmentRef={currentVersion} />
<PublishDialog
ref={publishDialogRef}
documentId={document.id}
documentFragmentRef={document}
hasPendingApproval={hasPendingApproval}
onSuccess={handlePublishOrApproval}
/>
</>
);
}

View File

@@ -20,7 +20,6 @@ import type { CreateDocumentDialogMutation } from "#/__generated__/core/CreateDo
import { ControlledField } from "#/components/form/ControlledField";
import { DocumentClassificationOptions } from "#/components/form/DocumentClassificationOptions";
import { DocumentTypeOptions } from "#/components/form/DocumentTypeOptions";
import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField";
import { documentSchema, useDocumentForm } from "#/hooks/forms/useDocumentForm";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import { useOrganizationId } from "#/hooks/useOrganizationId";
@@ -148,18 +147,6 @@ export function CreateDocumentDialog({ trigger, connection }: Props) {
</ControlledField>
</PropertyRow>
<PropertyRow
id="approverIds"
label={__("Approvers")}
error={errors.approverIds?.message}
>
<PeopleMultiSelectField
name="approverIds"
control={control}
organizationId={organizationId}
placeholder={__("Add approvers...")}
/>
</PropertyRow>
</div>
</DialogContent>
<DialogFooter>

View File

@@ -1,6 +1,6 @@
import { documentClassifications, documentTypes, formatDate, getDocumentClassificationLabel, getDocumentTypeLabel } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { Avatar, Badge, Button, Drawer, IconCheckmark1, IconCrossLargeX, IconPencil, PropertyRow } from "@probo/ui";
import { Badge, Button, Drawer, IconCheckmark1, IconCrossLargeX, IconPencil, PropertyRow } from "@probo/ui";
import { useState } from "react";
import { useFragment } from "react-relay";
import { graphql } from "relay-runtime";
@@ -12,10 +12,8 @@ import type { DocumentLayoutDrawerMutation } from "#/__generated__/core/Document
import { ControlledField } from "#/components/form/ControlledField";
import { DocumentClassificationOptions } from "#/components/form/DocumentClassificationOptions";
import { DocumentTypeOptions } from "#/components/form/DocumentTypeOptions";
import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import { useOrganizationId } from "#/hooks/useOrganizationId";
const documentFragment = graphql`
fragment DocumentLayoutDrawer_documentFragment on Document {
@@ -24,14 +22,6 @@ const documentFragment = graphql`
status
archivedAt
canUpdate: permission(action: "core:document:update")
approvers(first: 100) {
edges {
node {
id
fullName
}
}
}
}
`;
@@ -53,21 +43,12 @@ const updateDocumentMutation = graphql`
id
documentType
classification
approvers(first: 100) {
edges {
node {
id
fullName
}
}
}
}
}
}
`;
const schema = z.object({
approverIds: z.array(z.string()).min(1, "At least one approver is required"),
documentType: z.enum(documentTypes),
classification: z.enum(documentClassifications),
});
@@ -78,10 +59,8 @@ export function DocumentLayoutDrawer(props: {
}) {
const { documentFragmentRef, versionFragmentRef } = props;
const organizationId = useOrganizationId();
const { __ } = useTranslate();
const [isEditingApprover, setIsEditingApprover] = useState(false);
const [isEditingType, setIsEditingType] = useState(false);
const [isEditingClassification, setIsEditingClassification] = useState(false);
@@ -91,13 +70,10 @@ export function DocumentLayoutDrawer(props: {
const isDraft = version.status === "DRAFT";
const canEdit = document.canUpdate;
const approvers = document.approvers.edges.map(e => e.node);
const { control, handleSubmit, reset } = useFormWithSchema(
schema,
{
defaultValues: {
approverIds: approvers.map(a => a.id),
documentType: document.documentType,
classification: version.classification,
},
@@ -113,20 +89,6 @@ export function DocumentLayoutDrawer(props: {
},
);
const handleUpdateApprover = async (data: { approverIds: string[] }) => {
await updateDocument({
variables: {
input: {
id: document.id,
approverIds: data.approverIds,
},
},
onSuccess: () => {
setIsEditingApprover(false);
},
});
};
const handleUpdateDocumentType = async (data: {
documentType: (typeof documentTypes)[number];
}) => {
@@ -164,42 +126,6 @@ export function DocumentLayoutDrawer(props: {
<div className="text-base text-txt-primary font-medium mb-4">
{__("Properties")}
</div>
<PropertyRow label={__("Approvers")}>
{isEditingApprover
? (
<EditablePropertyContent
onSave={() => void handleSubmit(handleUpdateApprover)()}
onCancel={() => {
setIsEditingApprover(false);
reset();
}}
disabled={isUpdatingDocument}
>
<PeopleMultiSelectField
name="approverIds"
control={control}
organizationId={organizationId}
selectedPeople={approvers}
placeholder={__("Add approvers...")}
/>
</EditablePropertyContent>
)
: (
<ReadOnlyPropertyContent
onEdit={() => setIsEditingApprover(true)}
canEdit={canEdit}
>
<div className="flex flex-wrap gap-2">
{approvers.map(approver => (
<Badge key={approver.id} variant="highlight" size="md" className="gap-2">
<Avatar name={approver.fullName} />
{approver.fullName}
</Badge>
))}
</div>
</ReadOnlyPropertyContent>
)}
</PropertyRow>
<PropertyRow label={__("Type")}>
{isEditingType
? (

View File

@@ -1,7 +1,7 @@
import { documentTypes, getDocumentTypeLabel, sprintf } from "@probo/helpers";
import { useList } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import { Button, Card, Checkbox, IconArchive, IconArrowDown, IconCheckmark1, IconCrossLargeX, IconSignature, IconTrashCan, Option, Select, Tbody, Th, Thead, Tr, useConfirm } from "@probo/ui";
import { Button, Card, Checkbox, IconArchive, IconArrowDown, IconCrossLargeX, IconSignature, IconTrashCan, IconUpload, Option, Select, Tbody, Th, Thead, Tr, useConfirm } from "@probo/ui";
import { type ComponentProps, use, useEffect, useRef, useState, useTransition } from "react";
import { usePaginationFragment } from "react-relay";
import { ConnectionHandler, graphql } from "relay-runtime";
@@ -295,7 +295,7 @@ export function DocumentList(props: {
onChange={() => reset(documents.map(d => d.id))}
/>
</Th>
<SortableTh field="TITLE" className="min-w-0" onOrderChange={handleOrderChange}>
<SortableTh field="TITLE" className="min-w-0 pr-12" onOrderChange={handleOrderChange}>
{__("Name")}
</SortableTh>
<Th className="w-24">{__("Status")}</Th>
@@ -306,13 +306,14 @@ export function DocumentList(props: {
<Th className="w-32">{__("Classification")}</Th>
<Th className="w-60">{__("Approvers")}</Th>
<Th className="w-60">{__("Last update")}</Th>
<Th className="w-20">{__("Approvals")}</Th>
<Th className="w-20">{__("Signatures")}</Th>
{hasAnyAction && <Th className="w-18"></Th>}
</Tr>
)
: (
<Tr>
<Th colspan={10} compact>
<Th colspan={hasAnyAction ? 11 : 10} compact>
<div className="flex justify-between items-center h-8">
<div className="flex gap-2 items-center">
{sprintf(__("%s documents selected"), selection.length)}
@@ -357,7 +358,10 @@ export function DocumentList(props: {
<>
{canUpdateAny && (
<PublishDocumentsDialog documentIds={selection} onSave={clear}>
<Button icon={IconCheckmark1} className="py-0.5 px-2 text-xs h-6 min-h-6">
<Button
icon={IconUpload}
className="py-0.5 px-2 text-xs h-6 min-h-6"
>
{__("Publish")}
</Button>
</PublishDocumentsDialog>

View File

@@ -17,20 +17,32 @@ const fragment = graphql`
classification
updatedAt
canDelete: permission(action: "core:document:delete")
approvers(first: 100) {
edges {
node {
id
fullName
}
}
}
lastVersion: versions(first: 1 orderBy: { field: CREATED_AT direction: DESC }) {
recentVersions: versions(first: 2 orderBy: { field: CREATED_AT direction: DESC }) {
edges {
node {
id
status
version
approvalQuorums(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
edges {
node {
status
decisions(first: 20) {
totalCount
edges {
node {
approver {
fullName
}
}
}
}
approvedDecisions: decisions(first: 0 filter: { states: [APPROVED] }) {
totalCount
}
}
}
}
signatures(first: 0 filter: { activeContract: true }) {
totalCount
}
@@ -74,11 +86,22 @@ export function DocumentListItem(props: {
fragment,
fragmentRef,
);
const lastVersion = document.lastVersion.edges[0].node;
const lastVersion = document.recentVersions.edges[0].node;
const approverQuorum = lastVersion.approvalQuorums?.edges?.[0]?.node
?? document.recentVersions.edges[1]?.node.approvalQuorums?.edges?.[0]?.node;
const isDraft = lastVersion.status === "DRAFT";
const { __ } = useTranslate();
const statusVariant = {
DRAFT: "neutral",
PUBLISHED: "success",
} as const;
const statusLabel = {
DRAFT: __("Draft"),
PUBLISHED: __("Published"),
} as const;
const [deleteDocument] = useMutationWithToasts<DocumentListItem_deleteMutation>(
deleteDocumentMutation,
{
@@ -119,8 +142,8 @@ export function DocumentListItem(props: {
<div className="flex gap-4 items-center">{document.title}</div>
</Td>
<Td className="w-24">
<Badge variant={isDraft ? "neutral" : "success"}>
{isDraft ? __("Draft") : __("Published")}
<Badge variant={statusVariant[lastVersion.status]}>
{statusLabel[lastVersion.status]}
</Badge>
</Td>
<Td className="w-20">
@@ -134,9 +157,24 @@ export function DocumentListItem(props: {
{getDocumentClassificationLabel(__, document.classification)}
</Td>
<Td className="w-60">
{document.approvers.edges.map(({ node }) => node.fullName).join(", ")}
{(() => {
const decisions = approverQuorum?.decisions;
if (!decisions?.edges.length) return "—";
const names = decisions.edges.map(e => e.node.approver.fullName).join(", ");
return decisions.totalCount > 20 ? `${names}...` : names;
})()}
</Td>
<Td className="w-60">{formatDate(document.updatedAt)}</Td>
<Td className="w-20">
{(() => {
const lastQuorum = lastVersion.approvalQuorums?.edges?.[0]?.node;
return lastQuorum
? lastQuorum.status === "REJECTED"
? __("Rejected")
: `${lastQuorum.approvedDecisions.totalCount}/${lastQuorum.decisions.totalCount}`
: "—";
})()}
</Td>
<Td className="w-20">
{lastVersion.signedSignatures.totalCount}
/

View File

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

View File

@@ -1,21 +1,22 @@
import { sprintf } from "@probo/helpers";
import { formatError, sprintf } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Breadcrumb,
Button,
Dialog,
DialogContent,
DialogFooter,
Field,
IconWarning,
Textarea,
useDialogRef,
useToast,
} from "@probo/ui";
import { type ReactNode } from "react";
import { useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import { z } from "zod";
import type { PublishDocumentsDialogMutation } from "#/__generated__/core/PublishDocumentsDialogMutation.graphql";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
type Props = {
documentIds: string[];
@@ -28,11 +29,12 @@ const documentsPublishMutation = graphql`
$input: BulkPublishDocumentVersionsInput!
) {
bulkPublishDocumentVersions(input: $input) {
documentEdges {
node {
id
...DocumentListItemFragment
}
documentVersions {
id
}
documents {
id
...DocumentListItemFragment
}
}
}
@@ -44,49 +46,56 @@ export function PublishDocumentsDialog({
onSave,
}: Props) {
const { __ } = useTranslate();
const { toast } = useToast();
const dialogRef = useDialogRef();
const schema = z.object({
changelog: z.string().min(1, __("Changelog is required")),
});
const [publishMutation]
= useMutationWithToasts<PublishDocumentsDialogMutation>(
documentsPublishMutation,
{
successMessage: (response) => {
const actualPublishedCount
= response.bulkPublishDocumentVersions.documentEdges.length;
return sprintf(__("%s documents published"), actualPublishedCount);
},
errorMessage: sprintf(
__("Failed to publish %s documents"),
documentIds.length,
),
},
);
const [publishMutation, isPublishing] = useMutation<PublishDocumentsDialogMutation>(documentsPublishMutation);
const {
handleSubmit,
register,
formState: { isSubmitting, errors },
formState: { errors },
} = useFormWithSchema(schema, {
defaultValues: {
changelog: "",
},
});
const onSubmit = async (data: z.infer<typeof schema>) => {
await publishMutation({
const onSubmit = (data: z.infer<typeof schema>) => {
publishMutation({
variables: {
input: {
documentIds,
changelog: data.changelog,
},
},
onSuccess: () => {
dialogRef.current?.close();
onSave();
onCompleted(_, errors) {
if (errors?.length) {
toast({
title: __("Error"),
description: formatError(__("Failed to publish documents"), errors),
variant: "error",
});
} else {
toast({
title: __("Success"),
description: sprintf(__("%s documents published"), documentIds.length),
variant: "success",
});
dialogRef.current?.close();
onSave();
}
},
onError(error) {
toast({
title: __("Error"),
description: error.message,
variant: "error",
});
},
});
};
@@ -96,22 +105,37 @@ export function PublishDocumentsDialog({
className="max-w-xl"
ref={dialogRef}
trigger={children}
title={<Breadcrumb items={[__("Documents"), __("Publish documents")]} />}
title={__("Publish documents")}
>
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
<DialogContent padded>
<Field
id="changelog"
aria-label={__("Changelog")}
required
variant="title"
placeholder={__("Changelog")}
{...register("changelog")}
error={errors.changelog?.message}
/>
<div className="space-y-4">
<div className="flex items-start gap-2 rounded-lg bg-bg-warning/10 border border-border-warning p-3">
<IconWarning size={16} className="text-txt-warning shrink-0 mt-0.5" />
<p className="text-sm text-txt-warning">
{__("This will publish the selected documents directly without requiring approval.")}
</p>
</div>
<div>
<label htmlFor="changelog" className="text-sm font-medium text-txt-primary mb-1 block">
{__("Changelog")}
</label>
<Textarea
id="changelog"
aria-label={__("Changelog")}
required
autogrow
placeholder={__("Describe what changed in this version...")}
{...register("changelog")}
/>
{errors.changelog?.message && (
<p className="text-xs text-txt-danger mt-1">{errors.changelog.message}</p>
)}
</div>
</div>
</DialogContent>
<DialogFooter>
<Button type="submit" disabled={isSubmitting}>
<Button type="submit" disabled={isPublishing}>
{sprintf(__("Publish %s documents"), documentIds.length)}
</Button>
</DialogFooter>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { sprintf } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { Avatar, Badge, Button, IconCircleCheck, IconClock } from "@probo/ui";
import { Badge, Button, IconCircleCheck, IconClock } from "@probo/ui";
import { useFragment } from "react-relay";
import { type DataID, graphql } from "relay-runtime";
@@ -53,7 +53,6 @@ export function DocumentSignatureListItem(props: {
return (
<div className="flex gap-3 items-center py-3">
<Avatar size="l" name={signature.signedBy.fullName} />
<div className="space-y-1">
<div className="text-sm text-txt-primary font-medium">
{signature.signedBy.fullName}

View File

@@ -1,5 +1,5 @@
import { useTranslate } from "@probo/i18n";
import { Avatar, Button } from "@probo/ui";
import { Button } from "@probo/ui";
import { useFragment } from "react-relay";
import { type DataID, graphql } from "relay-runtime";
@@ -77,7 +77,6 @@ export function DocumentSignaturePlaceholder(props: {
return (
<div className="flex gap-3 items-center py-3">
<Avatar size="l" name={person.fullName} />
<div className="space-y-1">
<div className="text-sm text-txt-primary font-medium">
{person.fullName}

View File

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

View File

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

View File

@@ -35,7 +35,7 @@ export const employeeDocumentSignaturePageQuery = graphql`
`;
const documentFragment = graphql`
fragment EmployeeDocumentSignaturePageDocumentFragment on SignableDocument {
fragment EmployeeDocumentSignaturePageDocumentFragment on EmployeeDocument {
id
title
# eslint-disable-next-line relay/unused-fields
@@ -159,7 +159,7 @@ function DocumentSignatureContent({
description: __("Document signed successfully"),
variant: "success",
});
void navigate(`/organizations/${organizationId}/employee`);
void navigate(`/organizations/${organizationId}/employee/signatures`);
},
onError: (error) => {
toast({
@@ -256,7 +256,7 @@ function DocumentSignatureContent({
isSigning={isSigning}
onSign={handleSign}
onBack={() =>
void navigate(`/organizations/${organizationId}/employee`)}
void navigate(`/organizations/${organizationId}/employee/signatures`)}
/>
)
: null}

View File

@@ -4,7 +4,6 @@ import { useParams } from "react-router";
import type { EmployeeDocumentSignaturePageQuery } from "#/__generated__/core/EmployeeDocumentSignaturePageQuery.graphql";
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
import { CoreRelayProvider } from "#/providers/CoreRelayProvider";
import {
EmployeeDocumentSignaturePage,
@@ -39,8 +38,6 @@ function EmployeeDocumentSignaturePageQueryLoader() {
export default function EmployeeDocumentSignaturePageLoader() {
return (
<CoreRelayProvider>
<EmployeeDocumentSignaturePageQueryLoader />
</CoreRelayProvider>
<EmployeeDocumentSignaturePageQueryLoader />
);
}

View File

@@ -1,6 +1,6 @@
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import { Card, PageHeader, Tbody, Th, Thead, Tr } from "@probo/ui";
import { Card, Tbody, Th, Thead, Tr } from "@probo/ui";
import { graphql, type PreloadedQuery, usePreloadedQuery } from "react-relay";
import type { EmployeeDocumentsPageQuery } from "#/__generated__/core/EmployeeDocumentsPageQuery.graphql";
@@ -46,19 +46,18 @@ export function EmployeeDocumentsPage(props: {
usePageTitle(__("Documents"));
return (
<div className="space-y-6">
<PageHeader title={__("Documents")} />
<>
{documents.length > 0
? (
<Card>
<table className="w-full">
<table className="w-full table-fixed">
<Thead>
<Tr>
<Th className="min-w-0 pr-12">{__("Name")}</Th>
<Th className="w-48">{__("Type")}</Th>
<Th className="w-36">{__("Classification")}</Th>
<Th className="w-40">{__("Last update")}</Th>
<Th className="w-32">{__("Signed")}</Th>
<Th className="text-left">{__("Name")}</Th>
<Th className="w-48 text-left">{__("Type")}</Th>
<Th className="w-36 text-left">{__("Classification")}</Th>
<Th className="w-40 text-left">{__("Last update")}</Th>
<Th className="w-32 text-left">{__("Signed")}</Th>
</Tr>
</Thead>
<Tbody>
@@ -85,6 +84,6 @@ export function EmployeeDocumentsPage(props: {
</div>
</Card>
)}
</div>
</>
);
}

View File

@@ -4,7 +4,6 @@ import { useQueryLoader } from "react-relay";
import type { EmployeeDocumentsPageQuery } from "#/__generated__/core/EmployeeDocumentsPageQuery.graphql";
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { CoreRelayProvider } from "#/providers/CoreRelayProvider";
import {
EmployeeDocumentsPage,
@@ -32,10 +31,8 @@ function EmployeeDocumentsPageQueryLoader() {
export default function EmployeeDocumentsPageLoader() {
return (
<CoreRelayProvider>
<Suspense fallback={<PageSkeleton />}>
<EmployeeDocumentsPageQueryLoader />
</Suspense>
</CoreRelayProvider>
<Suspense fallback={<PageSkeleton />}>
<EmployeeDocumentsPageQueryLoader />
</Suspense>
);
}

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ import { graphql, useFragment } from "react-relay";
import type { DocumentRowFragment$key } from "#/__generated__/core/DocumentRowFragment.graphql";
const fragment = graphql`
fragment DocumentRowFragment on SignableDocument {
fragment DocumentRowFragment on EmployeeDocument {
id
title
documentType
@@ -31,8 +31,8 @@ export function DocumentRow({
const { __ } = useTranslate();
return (
<Tr to={`/organizations/${organizationId}/employee/${document.id}`}>
<Td className="min-w-0 pr-12">{document.title}</Td>
<Tr to={`/organizations/${organizationId}/employee/signatures/${document.id}`}>
<Td>{document.title}</Td>
<Td className="w-48">
{getDocumentTypeLabel(__, document.documentType)}
</Td>

View File

@@ -5,7 +5,7 @@ import { graphql, useFragment } from "react-relay";
import type { VersionActionsFragment$key } from "#/__generated__/core/VersionActionsFragment.graphql";
const fragment = graphql`
fragment VersionActionsFragment on DocumentVersion {
fragment VersionActionsFragment on EmployeeDocumentVersion {
id
signed
}

View File

@@ -6,7 +6,7 @@ import { graphql, useFragment } from "react-relay";
import type { VersionRowFragment$key } from "#/__generated__/core/VersionRowFragment.graphql";
const fragment = graphql`
fragment VersionRowFragment on DocumentVersion {
fragment VersionRowFragment on EmployeeDocumentVersion {
# eslint-disable-next-line relay/unused-fields
id
version

View File

@@ -136,18 +136,55 @@ const routes = [
children: [
{
index: true,
loader: ({ params: { organizationId } }) => {
// eslint-disable-next-line
throw redirect(`/organizations/${organizationId}/employee/signatures`);
},
Component: () => null,
},
{
Component: lazy(
() =>
import("./pages/organizations/employee/EmployeeDocumentsPageLoader"),
() => import("./pages/organizations/employee/EmployeeTabsLayout"),
),
children: [
{
path: "signatures",
Component: lazy(
() =>
import("./pages/organizations/employee/EmployeeDocumentsPageLoader"),
),
},
{
path: "approvals",
Component: lazy(
() =>
import("./pages/organizations/employee/EmployeeApprovalsPageLoader"),
),
},
],
},
{
path: ":documentId",
loader: ({ params: { organizationId, documentId } }) => {
// eslint-disable-next-line
throw redirect(`/organizations/${organizationId}/employee/signatures/${documentId}`);
},
Component: () => null,
},
{
path: "signatures/:documentId",
Component: lazy(
() =>
import("./pages/organizations/employee/EmployeeDocumentSignaturePageLoader"),
),
},
{
path: "approvals/:documentId",
Component: lazy(
() =>
import("./pages/organizations/documents/approve/DocumentApprovePageLoader"),
),
},
],
},
{

View File

@@ -40,6 +40,14 @@ const documentTabs = (prefix: string) => {
import("#/pages/organizations/documents/controls/DocumentControlsPageLoader"),
),
},
{
path: `${prefix}approvals`,
Fallback: LinkCardSkeleton,
Component: lazy(
() =>
import("#/pages/organizations/documents/approvals/DocumentApprovalsPageLoader"),
),
},
{
path: `${prefix}signatures`,
Fallback: LinkCardSkeleton,
@@ -61,6 +69,9 @@ export const documentsRoutes = [
path: "documents/:documentId",
Fallback: PageSkeleton,
Component: lazy(() => import("#/pages/organizations/documents/DocumentLayoutLoader")),
children: [...documentTabs(""), ...documentTabs("versions/:versionId/")],
children: [
...documentTabs(""),
...documentTabs("versions/:versionId/"),
],
},
] satisfies AppRoute[];

View File

@@ -30,7 +30,6 @@ import (
func TestDocument_Create(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
approverProfileID := factory.CreateUser(owner)
tests := []struct {
name string
@@ -114,7 +113,6 @@ func TestDocument_Create(t *testing.T) {
input := map[string]any{
"organizationId": owner.GetOrganizationID().String(),
"approverIds": []string{approverProfileID},
}
maps.Copy(input, tt.input)
@@ -150,13 +148,11 @@ func TestDocument_Create(t *testing.T) {
func TestDocument_Create_Validation(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
approverProfileID := factory.CreateUser(owner)
tests := []struct {
name string
input map[string]any
skipOrganization bool
skipApprover bool
wantErrorContains string
}{
{
@@ -170,17 +166,6 @@ func TestDocument_Create_Validation(t *testing.T) {
skipOrganization: true,
wantErrorContains: "organizationId",
},
{
name: "missing approverIds",
input: map[string]any{
"title": "Test Document",
"content": "Test content",
"documentType": "POLICY",
"classification": "INTERNAL",
},
skipApprover: true,
wantErrorContains: "approverIds",
},
{
name: "title with HTML tags",
input: map[string]any{
@@ -281,9 +266,6 @@ func TestDocument_Create_Validation(t *testing.T) {
if !tt.skipOrganization {
input["organizationId"] = owner.GetOrganizationID().String()
}
if !tt.skipApprover {
input["approverIds"] = []string{approverProfileID}
}
maps.Copy(input, tt.input)
_, err := owner.Do(query, map[string]any{"input": input})
@@ -296,7 +278,6 @@ func TestDocument_Create_Validation(t *testing.T) {
func TestDocument_Update(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
approverProfileID := factory.CreateUser(owner)
tests := []struct {
name string
@@ -308,7 +289,7 @@ func TestDocument_Update(t *testing.T) {
{
name: "update title",
setup: func() string {
return factory.NewDocument(owner, approverProfileID).
return factory.NewDocument(owner).
WithTitle("Document to Update").
Create()
},
@@ -324,7 +305,7 @@ func TestDocument_Update(t *testing.T) {
{
name: "update document type",
setup: func() string {
return factory.NewDocument(owner, approverProfileID).
return factory.NewDocument(owner).
WithTitle("Type Test").
WithDocumentType("POLICY").
Create()
@@ -380,8 +361,8 @@ func TestDocument_Update(t *testing.T) {
func TestDocument_Update_Validation(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
approverProfileID := factory.CreateUser(owner)
baseDocumentID := factory.NewDocument(owner, approverProfileID).WithTitle("Validation Test Document").Create()
baseDocumentID := factory.NewDocument(owner).WithTitle("Validation Test Document").Create()
tests := []struct {
name string
@@ -463,10 +444,9 @@ func TestDocument_Update_Validation(t *testing.T) {
func TestDocument_Delete(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
approverProfileID := factory.CreateUser(owner)
t.Run("delete existing document", func(t *testing.T) {
documentID := factory.NewDocument(owner, approverProfileID).WithTitle("Document to Delete").Create()
documentID := factory.NewDocument(owner).WithTitle("Document to Delete").Create()
query := `
mutation DeleteDocument($input: DeleteDocumentInput!) {
@@ -528,11 +508,10 @@ func TestDocument_Delete_Validation(t *testing.T) {
func TestDocument_List(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
approverProfileID := factory.CreateUser(owner)
documentTitles := []string{"Document A", "Document B", "Document C"}
for _, title := range documentTitles {
factory.NewDocument(owner, approverProfileID).WithTitle(title).Create()
factory.NewDocument(owner).WithTitle(title).Create()
}
query := `
@@ -600,7 +579,6 @@ func TestDocument_Query(t *testing.T) {
func TestDocument_Timestamps(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
approverProfileID := factory.CreateUser(owner)
t.Run("createdAt and updatedAt are set on create", func(t *testing.T) {
beforeCreate := time.Now().Add(-time.Second)
@@ -634,7 +612,6 @@ func TestDocument_Timestamps(t *testing.T) {
err := owner.Execute(query, map[string]any{
"input": map[string]any{
"organizationId": owner.GetOrganizationID().String(),
"approverIds": []string{approverProfileID},
"title": "Timestamp Test Document",
"content": "Test content",
"documentType": "POLICY",
@@ -648,7 +625,7 @@ func TestDocument_Timestamps(t *testing.T) {
})
t.Run("updatedAt changes on update", func(t *testing.T) {
documentID := factory.NewDocument(owner, approverProfileID).WithTitle("Timestamp Update Test").Create()
documentID := factory.NewDocument(owner).WithTitle("Timestamp Update Test").Create()
getQuery := `
query($id: ID!) {
@@ -713,50 +690,8 @@ func TestDocument_Timestamps(t *testing.T) {
func TestDocument_SubResolvers(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
approverProfileID := factory.CreateUser(owner)
documentID := factory.NewDocument(owner, approverProfileID).WithTitle("SubResolver Test Document").Create()
t.Run("approvers sub-resolver", func(t *testing.T) {
query := `
query($id: ID!) {
node(id: $id) {
... on Document {
id
approvers {
totalCount
edges {
node {
id
fullName
}
}
}
}
}
}
`
var result struct {
Node struct {
ID string `json:"id"`
Approvers struct {
TotalCount int `json:"totalCount"`
Edges []struct {
Node struct {
ID string `json:"id"`
FullName string `json:"fullName"`
} `json:"node"`
} `json:"edges"`
} `json:"approvers"`
} `json:"node"`
}
err := owner.Execute(query, map[string]any{"id": documentID}, &result)
require.NoError(t, err)
assert.Equal(t, 1, result.Node.Approvers.TotalCount)
require.Len(t, result.Node.Approvers.Edges, 1)
assert.Equal(t, approverProfileID, result.Node.Approvers.Edges[0].Node.ID)
})
documentID := factory.NewDocument(owner).WithTitle("SubResolver Test Document").Create()
t.Run("organization sub-resolver", func(t *testing.T) {
query := `
@@ -796,7 +731,6 @@ func TestDocument_RBAC(t *testing.T) {
t.Run("create", func(t *testing.T) {
t.Run("owner can create", func(t *testing.T) {
owner := testutil.NewClient(t, testutil.RoleOwner)
approverProfileID := factory.CreateUser(owner)
_, err := owner.Do(`
mutation CreateDocument($input: CreateDocumentInput!) {
@@ -807,7 +741,6 @@ func TestDocument_RBAC(t *testing.T) {
`, map[string]any{
"input": map[string]any{
"organizationId": owner.GetOrganizationID().String(),
"approverIds": []string{approverProfileID},
"title": "RBAC Test Document",
"content": "Test content",
"documentType": "POLICY",
@@ -820,7 +753,6 @@ func TestDocument_RBAC(t *testing.T) {
t.Run("admin can create", func(t *testing.T) {
owner := testutil.NewClient(t, testutil.RoleOwner)
admin := testutil.NewClientInOrg(t, testutil.RoleAdmin, owner)
approverProfileID := factory.CreateUser(owner)
_, err := admin.Do(`
mutation CreateDocument($input: CreateDocumentInput!) {
@@ -831,7 +763,6 @@ func TestDocument_RBAC(t *testing.T) {
`, map[string]any{
"input": map[string]any{
"organizationId": admin.GetOrganizationID().String(),
"approverIds": []string{approverProfileID},
"title": "RBAC Test Document",
"content": "Test content",
"documentType": "POLICY",
@@ -844,7 +775,6 @@ func TestDocument_RBAC(t *testing.T) {
t.Run("viewer cannot create", func(t *testing.T) {
owner := testutil.NewClient(t, testutil.RoleOwner)
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
approverProfileID := factory.CreateUser(owner)
_, err := viewer.Do(`
mutation CreateDocument($input: CreateDocumentInput!) {
@@ -855,7 +785,6 @@ func TestDocument_RBAC(t *testing.T) {
`, map[string]any{
"input": map[string]any{
"organizationId": viewer.GetOrganizationID().String(),
"approverIds": []string{approverProfileID},
"title": "RBAC Test Document",
"content": "Test content",
"documentType": "POLICY",
@@ -869,8 +798,8 @@ func TestDocument_RBAC(t *testing.T) {
t.Run("update", func(t *testing.T) {
t.Run("owner can update", func(t *testing.T) {
owner := testutil.NewClient(t, testutil.RoleOwner)
approverProfileID := factory.CreateUser(owner)
documentID := factory.NewDocument(owner, approverProfileID).WithTitle("RBAC Update Test").Create()
documentID := factory.NewDocument(owner).WithTitle("RBAC Update Test").Create()
_, err := owner.Do(`
mutation UpdateDocument($input: UpdateDocumentInput!) {
@@ -890,8 +819,8 @@ func TestDocument_RBAC(t *testing.T) {
t.Run("admin can update", func(t *testing.T) {
owner := testutil.NewClient(t, testutil.RoleOwner)
admin := testutil.NewClientInOrg(t, testutil.RoleAdmin, owner)
approverProfileID := factory.CreateUser(owner)
documentID := factory.NewDocument(owner, approverProfileID).WithTitle("RBAC Update Test").Create()
documentID := factory.NewDocument(owner).WithTitle("RBAC Update Test").Create()
_, err := admin.Do(`
mutation UpdateDocument($input: UpdateDocumentInput!) {
@@ -911,8 +840,8 @@ func TestDocument_RBAC(t *testing.T) {
t.Run("viewer cannot update", func(t *testing.T) {
owner := testutil.NewClient(t, testutil.RoleOwner)
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
approverProfileID := factory.CreateUser(owner)
documentID := factory.NewDocument(owner, approverProfileID).WithTitle("RBAC Update Test").Create()
documentID := factory.NewDocument(owner).WithTitle("RBAC Update Test").Create()
_, err := viewer.Do(`
mutation UpdateDocument($input: UpdateDocumentInput!) {
@@ -933,8 +862,8 @@ func TestDocument_RBAC(t *testing.T) {
t.Run("delete", func(t *testing.T) {
t.Run("owner can delete", func(t *testing.T) {
owner := testutil.NewClient(t, testutil.RoleOwner)
approverProfileID := factory.CreateUser(owner)
documentID := factory.NewDocument(owner, approverProfileID).WithTitle("RBAC Delete Test").Create()
documentID := factory.NewDocument(owner).WithTitle("RBAC Delete Test").Create()
_, err := owner.Do(`
mutation DeleteDocument($input: DeleteDocumentInput!) {
@@ -951,8 +880,8 @@ func TestDocument_RBAC(t *testing.T) {
t.Run("admin can delete", func(t *testing.T) {
owner := testutil.NewClient(t, testutil.RoleOwner)
admin := testutil.NewClientInOrg(t, testutil.RoleAdmin, owner)
approverProfileID := factory.CreateUser(owner)
documentID := factory.NewDocument(owner, approverProfileID).WithTitle("RBAC Delete Test").Create()
documentID := factory.NewDocument(owner).WithTitle("RBAC Delete Test").Create()
_, err := admin.Do(`
mutation DeleteDocument($input: DeleteDocumentInput!) {
@@ -969,8 +898,8 @@ func TestDocument_RBAC(t *testing.T) {
t.Run("viewer cannot delete", func(t *testing.T) {
owner := testutil.NewClient(t, testutil.RoleOwner)
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
approverProfileID := factory.CreateUser(owner)
documentID := factory.NewDocument(owner, approverProfileID).WithTitle("RBAC Delete Test").Create()
documentID := factory.NewDocument(owner).WithTitle("RBAC Delete Test").Create()
_, err := viewer.Do(`
mutation DeleteDocument($input: DeleteDocumentInput!) {
@@ -988,8 +917,8 @@ func TestDocument_RBAC(t *testing.T) {
t.Run("read", func(t *testing.T) {
t.Run("owner can read", func(t *testing.T) {
owner := testutil.NewClient(t, testutil.RoleOwner)
approverProfileID := factory.CreateUser(owner)
documentID := factory.NewDocument(owner, approverProfileID).WithTitle("RBAC Read Test").Create()
documentID := factory.NewDocument(owner).WithTitle("RBAC Read Test").Create()
var result struct {
Node *struct {
@@ -1012,8 +941,8 @@ func TestDocument_RBAC(t *testing.T) {
t.Run("admin can read", func(t *testing.T) {
owner := testutil.NewClient(t, testutil.RoleOwner)
admin := testutil.NewClientInOrg(t, testutil.RoleAdmin, owner)
approverProfileID := factory.CreateUser(owner)
documentID := factory.NewDocument(owner, approverProfileID).WithTitle("RBAC Read Test").Create()
documentID := factory.NewDocument(owner).WithTitle("RBAC Read Test").Create()
var result struct {
Node *struct {
@@ -1036,8 +965,8 @@ func TestDocument_RBAC(t *testing.T) {
t.Run("viewer can read", func(t *testing.T) {
owner := testutil.NewClient(t, testutil.RoleOwner)
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
approverProfileID := factory.CreateUser(owner)
documentID := factory.NewDocument(owner, approverProfileID).WithTitle("RBAC Read Test").Create()
documentID := factory.NewDocument(owner).WithTitle("RBAC Read Test").Create()
var result struct {
Node *struct {
@@ -1062,7 +991,6 @@ func TestDocument_RBAC(t *testing.T) {
func TestDocument_MaxLength_Validation(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
approverProfileID := factory.CreateUser(owner)
longTitle := strings.Repeat("a", 1001)
@@ -1080,7 +1008,6 @@ func TestDocument_MaxLength_Validation(t *testing.T) {
_, err := owner.Do(query, map[string]any{
"input": map[string]any{
"organizationId": owner.GetOrganizationID().String(),
"approverIds": []string{approverProfileID},
"title": longTitle,
"content": "Test content",
"documentType": "POLICY",
@@ -1092,7 +1019,7 @@ func TestDocument_MaxLength_Validation(t *testing.T) {
})
t.Run("update", func(t *testing.T) {
documentID := factory.NewDocument(owner, approverProfileID).WithTitle("Max Length Test").Create()
documentID := factory.NewDocument(owner).WithTitle("Max Length Test").Create()
query := `
mutation UpdateDocument($input: UpdateDocumentInput!) {
@@ -1116,10 +1043,9 @@ func TestDocument_MaxLength_Validation(t *testing.T) {
func TestDocument_Pagination(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
approverProfileID := factory.CreateUser(owner)
for i := range 5 {
factory.NewDocument(owner, approverProfileID).
factory.NewDocument(owner).
WithTitle(fmt.Sprintf("Pagination Document %d", i)).
Create()
}
@@ -1265,8 +1191,7 @@ func TestDocument_TenantIsolation(t *testing.T) {
org1Owner := testutil.NewClient(t, testutil.RoleOwner)
org2Owner := testutil.NewClient(t, testutil.RoleOwner)
approverProfileID := factory.CreateUser(org1Owner)
documentID := factory.NewDocument(org1Owner, approverProfileID).WithTitle("Org1 Document").Create()
documentID := factory.NewDocument(org1Owner).WithTitle("Org1 Document").Create()
t.Run("cannot read document from another organization", func(t *testing.T) {
query := `
@@ -1372,10 +1297,9 @@ func TestDocument_TenantIsolation(t *testing.T) {
func TestDocument_Ordering(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
approverProfileID := factory.CreateUser(owner)
factory.NewDocument(owner, approverProfileID).WithTitle("AAA Order Test").Create()
factory.NewDocument(owner, approverProfileID).WithTitle("ZZZ Order Test").Create()
factory.NewDocument(owner).WithTitle("AAA Order Test").Create()
factory.NewDocument(owner).WithTitle("ZZZ Order Test").Create()
t.Run("order by created_at descending", func(t *testing.T) {
query := `

View File

@@ -23,10 +23,52 @@ import (
"go.probo.inc/probo/e2e/internal/testutil"
)
// getOwnerProfileID queries the organization profiles and returns the first one (the owner's).
func getOwnerProfileID(t *testing.T, owner *testutil.Client) string {
t.Helper()
query := `
query GetProfiles($orgId: ID!) {
node(id: $orgId) {
... on Organization {
profiles(first: 1) {
edges {
node {
id
}
}
}
}
}
}
`
var result struct {
Node struct {
Profiles struct {
Edges []struct {
Node struct {
ID string `json:"id"`
} `json:"node"`
} `json:"edges"`
} `json:"profiles"`
} `json:"node"`
}
err := owner.Execute(
query,
map[string]any{"orgId": owner.GetOrganizationID().String()},
&result,
)
require.NoError(t, err)
require.NotEmpty(t, result.Node.Profiles.Edges)
return result.Node.Profiles.Edges[0].Node.ID
}
// createTestDocument creates a document and returns its ID and the document version ID
func createTestDocument(t *testing.T, owner *testutil.Client) (docID string, docVersionID string) {
t.Helper()
profileID := factory.CreateUser(owner)
query := `
mutation CreateDocument($input: CreateDocumentInput!) {
@@ -69,7 +111,6 @@ func createTestDocument(t *testing.T, owner *testutil.Client) (docID string, doc
"organizationId": owner.GetOrganizationID().String(),
"title": "Test Document",
"content": "Initial content",
"approverIds": []string{profileID},
"documentType": "POLICY",
"classification": "INTERNAL",
},
@@ -83,79 +124,143 @@ func createTestDocument(t *testing.T, owner *testutil.Client) (docID string, doc
return docID, docVersionID
}
// approveTestDocument requests approval and approves the document so it can be published.
func approveTestDocument(t *testing.T, owner *testutil.Client, docID string) {
t.Helper()
requestQuery := `
mutation RequestApproval($input: RequestDocumentVersionApprovalInput!) {
requestDocumentVersionApproval(input: $input) {
approvalQuorum {
id
}
}
}
`
// Use the owner's profile as the approver
approverID := getOwnerProfileID(t, owner)
_, err := owner.Do(requestQuery, map[string]any{
"input": map[string]any{
"documentId": docID,
"approverIds": []string{approverID},
"changelog": "Test changelog",
},
})
require.NoError(t, err)
// Approve for each approver
approveQuery := `
mutation ApproveDocumentVersion($input: ApproveDocumentVersionInput!) {
approveDocumentVersion(input: $input) {
approvalDecision {
id
state
}
}
}
`
// Get the latest version ID
versionQuery := `
query GetVersions($id: ID!) {
node(id: $id) {
... on Document {
versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
edges {
node {
id
}
}
}
}
}
}
`
var versionResult struct {
Node struct {
Versions struct {
Edges []struct {
Node struct {
ID string `json:"id"`
} `json:"node"`
} `json:"edges"`
} `json:"versions"`
} `json:"node"`
}
err = owner.Execute(versionQuery, map[string]any{"id": docID}, &versionResult)
require.NoError(t, err)
require.NotEmpty(t, versionResult.Node.Versions.Edges)
versionID := versionResult.Node.Versions.Edges[0].Node.ID
_, err = owner.Do(approveQuery, map[string]any{
"input": map[string]any{
"documentVersionId": versionID,
},
})
require.NoError(t, err)
}
func TestDocumentVersion_PublishVersion(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
docID, _ := createTestDocument(t, owner)
approveTestDocument(t, owner, docID)
// After approval, the version is auto-published.
// Verify the version status by querying.
query := `
mutation PublishDocumentVersion($input: PublishDocumentVersionInput!) {
publishDocumentVersion(input: $input) {
documentVersion {
id
status
version
changelog
}
document {
id
query GetDocument($id: ID!) {
node(id: $id) {
... on Document {
versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
edges {
node {
id
status
version
}
}
}
}
}
}
`
var result struct {
PublishDocumentVersion struct {
DocumentVersion struct {
ID string `json:"id"`
Status string `json:"status"`
Version int `json:"version"`
Changelog string `json:"changelog"`
} `json:"documentVersion"`
Document struct {
ID string `json:"id"`
} `json:"document"`
} `json:"publishDocumentVersion"`
Node struct {
Versions struct {
Edges []struct {
Node struct {
ID string `json:"id"`
Status string `json:"status"`
Version int `json:"version"`
} `json:"node"`
} `json:"edges"`
} `json:"versions"`
} `json:"node"`
}
err := owner.Execute(query, map[string]any{
"input": map[string]any{
"documentId": docID,
"changelog": "Initial release",
},
}, &result)
err := owner.Execute(query, map[string]any{"id": docID}, &result)
require.NoError(t, err)
require.NotEmpty(t, result.Node.Versions.Edges)
assert.Equal(t, "PUBLISHED", result.PublishDocumentVersion.DocumentVersion.Status)
assert.Equal(t, 1, result.PublishDocumentVersion.DocumentVersion.Version)
assert.Equal(t, "Initial release", result.PublishDocumentVersion.DocumentVersion.Changelog)
assert.Equal(t, "PUBLISHED", result.Node.Versions.Edges[0].Node.Status)
assert.Equal(t, 1, result.Node.Versions.Edges[0].Node.Version)
}
func TestDocumentVersion_CreateDraft(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
// Create and publish a document first
// Create and approve a document (auto-publishes on approval)
docID, _ := createTestDocument(t, owner)
publishQuery := `
mutation PublishDocumentVersion($input: PublishDocumentVersionInput!) {
publishDocumentVersion(input: $input) {
documentVersion {
id
}
}
}
`
_, err := owner.Do(publishQuery, map[string]any{
"input": map[string]any{
"documentId": docID,
"changelog": "Initial release",
},
})
require.NoError(t, err)
approveTestDocument(t, owner, docID)
query := `
mutation CreateDraftDocumentVersion($input: CreateDraftDocumentVersionInput!) {
@@ -181,7 +286,7 @@ func TestDocumentVersion_CreateDraft(t *testing.T) {
} `json:"createDraftDocumentVersion"`
}
err = owner.Execute(query, map[string]any{
err := owner.Execute(query, map[string]any{
"input": map[string]any{
"documentID": docID,
},
@@ -232,36 +337,44 @@ func TestDocumentVersion_RequestSignature(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
// Create and publish a document
// Create and approve a document (auto-publishes on approval)
docID, _ := createTestDocument(t, owner)
approveTestDocument(t, owner, docID)
publishQuery := `
mutation PublishDocumentVersion($input: PublishDocumentVersionInput!) {
publishDocumentVersion(input: $input) {
documentVersion {
id
// Get the published version ID
versionQuery := `
query GetVersions($id: ID!) {
node(id: $id) {
... on Document {
versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
edges {
node {
id
}
}
}
}
}
}
`
var publishResult struct {
PublishDocumentVersion struct {
DocumentVersion struct {
ID string `json:"id"`
} `json:"documentVersion"`
} `json:"publishDocumentVersion"`
var versionResult struct {
Node struct {
Versions struct {
Edges []struct {
Node struct {
ID string `json:"id"`
} `json:"node"`
} `json:"edges"`
} `json:"versions"`
} `json:"node"`
}
err := owner.Execute(publishQuery, map[string]any{
"input": map[string]any{
"documentId": docID,
"changelog": "Initial release",
},
}, &publishResult)
err := owner.Execute(versionQuery, map[string]any{"id": docID}, &versionResult)
require.NoError(t, err)
require.NotEmpty(t, versionResult.Node.Versions.Edges)
publishedVersionID := publishResult.PublishDocumentVersion.DocumentVersion.ID
publishedVersionID := versionResult.Node.Versions.Edges[0].Node.ID
// Create a person to sign
signerProfileID := factory.CreateUser(owner)
@@ -318,15 +431,15 @@ func TestDocumentVersion_BulkPublish(t *testing.T) {
// Create multiple documents
docID1, _ := createTestDocument(t, owner)
docID2, _ := createTestDocument(t, owner)
approveTestDocument(t, owner, docID1)
approveTestDocument(t, owner, docID2)
query := `
mutation BulkPublishDocumentVersions($input: BulkPublishDocumentVersionsInput!) {
bulkPublishDocumentVersions(input: $input) {
documentVersionEdges {
node {
id
status
}
documentVersions {
id
status
}
}
}
@@ -334,12 +447,10 @@ func TestDocumentVersion_BulkPublish(t *testing.T) {
var result struct {
BulkPublishDocumentVersions struct {
DocumentVersionEdges []struct {
Node struct {
ID string `json:"id"`
Status string `json:"status"`
} `json:"node"`
} `json:"documentVersionEdges"`
DocumentVersions []struct {
ID string `json:"id"`
Status string `json:"status"`
} `json:"documentVersions"`
} `json:"bulkPublishDocumentVersions"`
}
@@ -351,9 +462,9 @@ func TestDocumentVersion_BulkPublish(t *testing.T) {
}, &result)
require.NoError(t, err)
assert.Equal(t, 2, len(result.BulkPublishDocumentVersions.DocumentVersionEdges))
for _, edge := range result.BulkPublishDocumentVersions.DocumentVersionEdges {
assert.Equal(t, "PUBLISHED", edge.Node.Status)
assert.Equal(t, 2, len(result.BulkPublishDocumentVersions.DocumentVersions))
for _, dv := range result.BulkPublishDocumentVersions.DocumentVersions {
assert.Equal(t, "PUBLISHED", dv.Status)
}
}
@@ -361,26 +472,9 @@ func TestDocumentVersion_BulkRequestSignatures(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
// Create and publish a document
// Create and approve a document (auto-publishes on approval)
docID, _ := createTestDocument(t, owner)
publishQuery := `
mutation PublishDocumentVersion($input: PublishDocumentVersionInput!) {
publishDocumentVersion(input: $input) {
documentVersion {
id
}
}
}
`
_, err := owner.Do(publishQuery, map[string]any{
"input": map[string]any{
"documentId": docID,
"changelog": "Initial release",
},
})
require.NoError(t, err)
approveTestDocument(t, owner, docID)
// Create multiple signers
signer1ProfileID := factory.CreateUser(owner)
@@ -410,7 +504,7 @@ func TestDocumentVersion_BulkRequestSignatures(t *testing.T) {
} `json:"bulkRequestSignatures"`
}
err = owner.Execute(query, map[string]any{
err := owner.Execute(query, map[string]any{
"input": map[string]any{
"documentIds": []string{docID},
"signatoryIds": []string{signer1ProfileID, signer2ProfileID},

View File

@@ -370,7 +370,6 @@ func TestControlDocumentMapping_CreateDelete(t *testing.T) {
controlID := createControlResult.CreateControl.ControlEdge.Node.ID
// Create a document
profileID := factory.CreateUser(owner)
var createDocumentResult struct {
CreateDocument struct {
DocumentEdge struct {
@@ -395,7 +394,6 @@ func TestControlDocumentMapping_CreateDelete(t *testing.T) {
"organizationId": owner.GetOrganizationID().String(),
"title": "Document for Control Mapping",
"content": "Document content",
"approverIds": []string{profileID},
"documentType": "POLICY",
"classification": "INTERNAL",
},
@@ -760,7 +758,6 @@ func TestRiskDocumentMapping_CreateDelete(t *testing.T) {
riskID := createRiskResult.CreateRisk.RiskEdge.Node.ID
// Create a document
profileID := factory.CreateUser(owner)
var createDocumentResult struct {
CreateDocument struct {
DocumentEdge struct {
@@ -785,7 +782,6 @@ func TestRiskDocumentMapping_CreateDelete(t *testing.T) {
"organizationId": owner.GetOrganizationID().String(),
"title": "Document for Risk Mapping",
"content": "Document content",
"approverIds": []string{profileID},
"documentType": "POLICY",
"classification": "INTERNAL",
},

View File

@@ -824,7 +824,7 @@ func (b *MeetingBuilder) Create() string {
return CreateMeeting(b.client, b.attrs)
}
func CreateDocument(c *testutil.Client, approverID string, attrs ...Attrs) string {
func CreateDocument(c *testutil.Client, attrs ...Attrs) string {
c.T.Helper()
var a Attrs
@@ -844,7 +844,6 @@ func CreateDocument(c *testutil.Client, approverID string, attrs ...Attrs) strin
input := map[string]any{
"organizationId": c.GetOrganizationID().String(),
"approverIds": []string{approverID},
"title": a.getString("title", SafeName("Document")),
"content": a.getString("content", "Document content"),
"documentType": a.getString("documentType", "POLICY"),
@@ -868,13 +867,12 @@ func CreateDocument(c *testutil.Client, approverID string, attrs ...Attrs) strin
}
type DocumentBuilder struct {
client *testutil.Client
approverID string
attrs Attrs
client *testutil.Client
attrs Attrs
}
func NewDocument(c *testutil.Client, approverID string) *DocumentBuilder {
return &DocumentBuilder{client: c, approverID: approverID, attrs: Attrs{}}
func NewDocument(c *testutil.Client) *DocumentBuilder {
return &DocumentBuilder{client: c, attrs: Attrs{}}
}
func (b *DocumentBuilder) WithTitle(title string) *DocumentBuilder {
@@ -898,7 +896,7 @@ func (b *DocumentBuilder) WithClassification(classification string) *DocumentBui
}
func (b *DocumentBuilder) Create() string {
return CreateDocument(b.client, b.approverID, b.attrs)
return CreateDocument(b.client, b.attrs)
}
func CreateProcessingActivity(c *testutil.Client, attrs ...Attrs) string {

View File

@@ -212,6 +212,7 @@ const (
subjectConfirmEmail = "Confirm your email address"
subjectPasswordReset = "Reset your password"
subjectInvitation = "Invitation to join %s on Probo"
subjectDocumentApproval = "Action Required – Please review and approve %s"
subjectDocumentSigning = "Action Required – Please review and sign %s compliance documents"
subjectDocumentExport = "Your document export is ready"
subjectFrameworkExport = "Your framework export is ready"
@@ -231,6 +232,8 @@ var (
passwordResetTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/password-reset.txt.tmpl"))
invitationHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/invitation.html.tmpl"))
invitationTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/invitation.txt.tmpl"))
documentApprovalHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/document-approval.html.tmpl"))
documentApprovalTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/document-approval.txt.tmpl"))
documentSigningHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/document-signing.html.tmpl"))
documentSigningTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/document-signing.txt.tmpl"))
documentExportHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/document-export.html.tmpl"))
@@ -348,6 +351,39 @@ func (p *Presenter) RenderInvitation(ctx context.Context, invitationURLPath stri
return fmt.Sprintf(subjectInvitation, organizationName), textBody, htmlBody, err
}
func (p *Presenter) RenderDocumentApproval(
ctx context.Context,
approvalURLPath string,
approvalURLQuery url.Values,
organizationName string,
documentName string,
) (subject string, textBody string, htmlBody *string, err error) {
vars, err := p.getCommonVariables(ctx)
if err != nil {
return "", "", nil, fmt.Errorf("cannot get common variables: %w", err)
}
approvalURL := baseurl.MustParse(vars.BaseURL).
AppendPath(approvalURLPath).
WithQueryValues(approvalURLQuery).
MustString()
data := struct {
*CommonVariables
ApprovalUrl string
OrganizationName string
DocumentName string
}{
CommonVariables: vars,
ApprovalUrl: approvalURL,
OrganizationName: organizationName,
DocumentName: documentName,
}
textBody, htmlBody, err = renderEmail(documentApprovalTextTemplate, documentApprovalHTMLTemplate, data)
return fmt.Sprintf(subjectDocumentApproval, documentName), textBody, htmlBody, err
}
func (p *Presenter) RenderDocumentSigning(
ctx context.Context,
signingURLPath string,
@@ -478,7 +514,7 @@ func (p *Presenter) RenderMagicLink(ctx context.Context, magicLinkUrlPath string
return fmt.Sprintf(subjectMagicLink, organizationName), textBody, htmlBody, err
}
func (p *Presenter) RenderElectronicSignatureCertificate(ctx context.Context, signerName string, documentType string) (subject string, textBody string, htmlBody *string, err error) {
func (p *Presenter) RenderElectronicSignatureCertificate(ctx context.Context, signerName string, documentName string) (subject string, textBody string, htmlBody *string, err error) {
vars, err := p.getCommonVariables(ctx)
if err != nil {
return "", "", nil, fmt.Errorf("cannot get common variables: %w", err)
@@ -487,15 +523,15 @@ func (p *Presenter) RenderElectronicSignatureCertificate(ctx context.Context, si
data := struct {
*CommonVariables
SignerName string
DocumentType string
DocumentName string
}{
CommonVariables: vars,
SignerName: signerName,
DocumentType: documentType,
DocumentName: documentName,
}
textBody, htmlBody, err = renderEmail(electronicSignatureCertificateTextTemplate, electronicSignatureCertificateHTMLTemplate, data)
return fmt.Sprintf(subjectElectronicSignatureCertificate, documentType), textBody, htmlBody, err
return fmt.Sprintf(subjectElectronicSignatureCertificate, documentName), textBody, htmlBody, err
}
func (p *Presenter) RenderMailingListSubscription(ctx context.Context, organizationName string, confirmURL string, unsubscribeURL string) (subject string, textBody string, htmlBody *string, err error) {

View File

@@ -7,6 +7,7 @@ import * as React from "react";
import MailingListUpdates from "../src/MailingListUpdates";
import ConfirmEmail from "../src/ConfirmEmail";
import DocumentExport from "../src/DocumentExport";
import DocumentApproval from "../src/DocumentApproval";
import DocumentSigning from "../src/DocumentSigning";
import FrameworkExport from "../src/FrameworkExport";
import Invitation from "../src/Invitation";
@@ -39,6 +40,10 @@ const templates: TemplateConfig[] = [
name: "invitation",
render: () => Invitation(),
},
{
name: "document-approval",
render: () => DocumentApproval(),
},
{
name: "document-signing",
render: () => DocumentSigning(),

View 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;

View File

@@ -8,10 +8,10 @@ import EmailLayout, {
export const ElectronicSignatureCertificate = () => {
return (
<EmailLayout
subject={`Your signed ${"{{.DocumentType}}"} — Certificate of Completion`}
subject={`Your signed ${"{{.DocumentName}}"} — Certificate of Completion`}
>
<Text style={bodyText}>
Your <strong>{"{{.DocumentType}}"}</strong> has been signed
Your <strong>{"{{.DocumentName}}"}</strong> has been signed
electronically. A Certificate of Completion is attached to this email
as a PDF document.
</Text>

View 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

View File

@@ -2,7 +2,7 @@
Hi {{.RecipientFullName}},
Your {{.DocumentType}} has been signed electronically. A Certificate of Completion is attached to this email as a PDF document.
Your {{.DocumentName}} has been signed electronically. A Certificate of Completion is attached to this email as a PDF document.
The certificate contains a complete record of the signing event, including the integrity seal, timestamp, and audit trail.

View File

@@ -61,11 +61,46 @@ func (p Document) CursorKey(orderBy DocumentOrderField) page.CursorKey {
// AuthorizationAttributes returns the authorization attributes for policy evaluation.
func (d *Document) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (map[string]string, error) {
q := `SELECT organization_id, status FROM documents WHERE id = $1 LIMIT 1;`
q := `
WITH document AS (
SELECT id, organization_id, status
FROM documents
WHERE id = $1
LIMIT 1
),
latest_version AS (
SELECT dv.id, dv.document_id, dv.status AS version_status
FROM document_versions dv
INNER JOIN document ON dv.document_id = document.id
ORDER BY dv.created_at DESC
LIMIT 1
),
last_quorum AS (
SELECT
lv.document_id,
q.status::text AS status
FROM document_version_approval_quorums q
INNER JOIN latest_version lv ON lv.id = q.version_id
ORDER BY q.created_at DESC
LIMIT 1
)
SELECT
document.organization_id,
document.status,
COALESCE(lv.version_status::text, ''),
COALESCE(lq.status, '')
FROM document
LEFT JOIN latest_version lv ON lv.document_id = document.id
LEFT JOIN last_quorum lq ON lq.document_id = document.id;
`
var organizationID gid.GID
var documentStatus DocumentStatus
if err := conn.QueryRow(ctx, q, d.ID).Scan(&organizationID, &documentStatus); err != nil {
var (
organizationID gid.GID
documentStatus DocumentStatus
latestVersionStatus string
lastQuorumStatus string
)
if err := conn.QueryRow(ctx, q, d.ID).Scan(&organizationID, &documentStatus, &latestVersionStatus, &lastQuorumStatus); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrResourceNotFound
}
@@ -73,8 +108,10 @@ func (d *Document) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (m
}
return map[string]string{
"organization_id": organizationID.String(),
"document_status": documentStatus.String(),
"organization_id": organizationID.String(),
"document_status": documentStatus.String(),
"version_status": latestVersionStatus,
"last_quorum_status": lastQuorumStatus,
}, nil
}
@@ -888,3 +925,56 @@ SELECT EXISTS (
return signed, nil
}
func (p *Document) GetViewerApprovalStateForLastVersion(
ctx context.Context,
conn pg.Conn,
scope Scoper,
documentID gid.GID,
identityID gid.GID,
) (DocumentVersionApprovalDecisionState, error) {
q := `
WITH viewer_decision AS (
SELECT
dvad.tenant_id,
dvad.state,
dv.version_number,
dvaq.created_at AS quorum_created_at
FROM documents d
INNER JOIN document_versions dv ON dv.document_id = d.id
INNER JOIN document_version_approval_quorums dvaq ON dvaq.version_id = dv.id
INNER JOIN document_version_approval_decisions dvad ON dvad.quorum_id = dvaq.id
INNER JOIN iam_membership_profiles p ON dvad.approver_id = p.id
WHERE d.id = @document_id
AND p.identity_id = @identity_id
)
SELECT state
FROM viewer_decision
WHERE %s
ORDER BY version_number DESC, quorum_created_at DESC
LIMIT 1
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"document_id": documentID,
"identity_id": identityID,
}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return "", fmt.Errorf("cannot query document approval state: %w", err)
}
state, err := pgx.CollectOneRow(rows, pgx.RowTo[DocumentVersionApprovalDecisionState])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return DocumentVersionApprovalDecisionStatePending, nil
}
return "", fmt.Errorf("cannot collect approval state: %w", err)
}
return state, nil
}

View File

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

View File

@@ -16,6 +16,7 @@ package coredata
import (
"github.com/jackc/pgx/v5"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/mail"
)
@@ -25,6 +26,7 @@ type (
trustCenterVisibilities []TrustCenterVisibility
published *bool
userEmail *mail.Addr
approverIdentityID *gid.GID
documentTypes []DocumentType
status []DocumentStatus
}
@@ -58,6 +60,11 @@ func (f *DocumentFilter) WithUserEmail(userEmail *mail.Addr) *DocumentFilter {
return f
}
func (f *DocumentFilter) WithApproverIdentityID(identityID *gid.GID) *DocumentFilter {
f.approverIdentityID = identityID
return f
}
func (f *DocumentFilter) WithDocumentTypes(documentTypes []DocumentType) *DocumentFilter {
f.documentTypes = documentTypes
return f
@@ -98,6 +105,7 @@ func (f *DocumentFilter) SQLArguments() pgx.NamedArgs {
"trust_center_visibilities": visibilities,
"published": f.published,
"user_email": f.userEmail,
"approver_identity_id": f.approverIdentityID,
"document_types": documentTypes,
"document_status": status,
}
@@ -142,6 +150,19 @@ func (f *DocumentFilter) SQLFragment() string {
)
END
AND
CASE
WHEN @approver_identity_id::text IS NULL THEN TRUE
ELSE EXISTS (
SELECT 1
FROM document_versions dv
INNER JOIN document_version_approval_quorums dvaq ON dvaq.version_id = dv.id
INNER JOIN document_version_approval_decisions dvad ON dvad.quorum_id = dvaq.id
INNER JOIN iam_membership_profiles p ON dvad.approver_id = p.id
WHERE dv.document_id = documents.id
AND p.identity_id = @approver_identity_id::text
)
END
AND
CASE
WHEN @document_types::document_type[] IS NOT NULL THEN
document_type = ANY(@document_types::document_type[])

View File

@@ -50,18 +50,44 @@ type (
// AuthorizationAttributes returns the authorization attributes for policy evaluation.
func (dv *DocumentVersion) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (map[string]string, error) {
q := `
WITH document_version AS (
SELECT id, document_id, organization_id, status AS version_status
FROM document_versions
WHERE id = $1
LIMIT 1
),
document AS (
SELECT d.id, d.status
FROM documents d
INNER JOIN document_version ON d.id = document_version.document_id
),
last_quorum AS (
SELECT
q.version_id,
q.status::text AS status
FROM document_version_approval_quorums q
INNER JOIN document_version ON q.version_id = document_version.id
ORDER BY q.created_at DESC
LIMIT 1
)
SELECT
dv.organization_id,
d.status
FROM document_versions dv
INNER JOIN documents d ON d.id = dv.document_id
WHERE dv.id = $1
LIMIT 1;
document_version.organization_id,
document.status,
document_version.version_status,
COALESCE(lq.status, '')
FROM document_version
INNER JOIN document ON document.id = document_version.document_id
LEFT JOIN last_quorum lq ON lq.version_id = document_version.id;
`
var organizationID gid.GID
var documentStatus DocumentStatus
if err := conn.QueryRow(ctx, q, dv.ID).Scan(&organizationID, &documentStatus); err != nil {
var (
organizationID gid.GID
documentStatus DocumentStatus
documentVersionStatus DocumentVersionStatus
lastQuorumStatus string
)
if err := conn.QueryRow(ctx, q, dv.ID).Scan(&organizationID, &documentStatus, &documentVersionStatus, &lastQuorumStatus); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrResourceNotFound
}
@@ -69,8 +95,10 @@ LIMIT 1;
}
return map[string]string{
"organization_id": organizationID.String(),
"document_status": documentStatus.String(),
"organization_id": organizationID.String(),
"document_status": documentStatus.String(),
"version_status": documentVersionStatus.String(),
"last_quorum_status": lastQuorumStatus,
}, nil
}

View 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
}

View 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
)`
}

View 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 (
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
}

View 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
}

View 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
}

View 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
}

View 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
}

View File

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

View File

@@ -16,12 +16,15 @@ package coredata
import (
"github.com/jackc/pgx/v5"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/mail"
)
type (
DocumentVersionFilter struct {
userEmail *mail.Addr
statuses []DocumentVersionStatus
userEmail *mail.Addr
approverIdentityID *gid.GID
}
)
@@ -29,29 +32,65 @@ func NewDocumentVersionFilter() *DocumentVersionFilter {
return &DocumentVersionFilter{}
}
func (f *DocumentVersionFilter) WithStatuses(statuses ...DocumentVersionStatus) *DocumentVersionFilter {
f.statuses = statuses
return f
}
func (f *DocumentVersionFilter) WithUserEmail(userEmail *mail.Addr) *DocumentVersionFilter {
f.userEmail = userEmail
return f
}
func (f *DocumentVersionFilter) WithApproverIdentityID(identityID *gid.GID) *DocumentVersionFilter {
f.approverIdentityID = identityID
return f
}
func (f *DocumentVersionFilter) SQLArguments() pgx.StrictNamedArgs {
var filterStatuses []string
for _, s := range f.statuses {
filterStatuses = append(filterStatuses, s.String())
}
return pgx.StrictNamedArgs{
"user_email": f.userEmail,
"filter_statuses": filterStatuses,
"user_email": f.userEmail,
"approver_identity_id": f.approverIdentityID,
}
}
func (f *DocumentVersionFilter) SQLFragment() string {
return `
(
@user_email::text IS NULL
OR EXISTS (
SELECT 1
FROM document_version_signatures dvs
INNER JOIN iam_membership_profiles p ON dvs.signed_by_profile_id = p.id
INNER JOIN identities i ON p.identity_id = i.id
WHERE dvs.document_version_id = document_versions.id
AND i.email_address = @user_email::CITEXT
AND dvs.state IN ('REQUESTED', 'SIGNED')
(
@filter_statuses::text[] IS NULL
OR document_versions.status::text = ANY(@filter_statuses::text[])
)
AND
(
@user_email::text IS NULL
OR EXISTS (
SELECT 1
FROM document_version_signatures dvs
INNER JOIN iam_membership_profiles p ON dvs.signed_by_profile_id = p.id
INNER JOIN identities i ON p.identity_id = i.id
WHERE dvs.document_version_id = document_versions.id
AND i.email_address = @user_email::CITEXT
AND dvs.state IN ('REQUESTED', 'SIGNED')
)
)
AND
(
@approver_identity_id::text IS NULL
OR EXISTS (
SELECT 1
FROM document_version_approval_quorums dvaq
INNER JOIN document_version_approval_decisions dvad ON dvad.quorum_id = dvaq.id
INNER JOIN iam_membership_profiles p ON dvad.approver_id = p.id
WHERE dvaq.version_id = document_versions.id
AND p.identity_id = @approver_identity_id::text
)
)
)`
}

View File

@@ -35,6 +35,7 @@ type ElectronicSignature struct {
OrganizationID gid.GID `db:"organization_id"`
Status ElectronicSignatureStatus `db:"status"`
DocumentType ElectronicSignatureDocumentType `db:"document_type"`
DocumentName *string `db:"document_name"`
FileID gid.GID `db:"file_id"`
SignerEmail string `db:"signer_email"`
ConsentText string `db:"consent_text"`
@@ -83,11 +84,11 @@ func (es *ElectronicSignature) Insert(
) error {
q := `
INSERT INTO electronic_signatures (
id, tenant_id, organization_id, status, document_type, file_id,
id, tenant_id, organization_id, status, document_type, document_name, file_id,
signer_email, consent_text, seal_version, attempt_count, max_attempts,
created_at, updated_at
) VALUES (
@id, @tenant_id, @organization_id, @status, @document_type, @file_id,
@id, @tenant_id, @organization_id, @status, @document_type, @document_name, @file_id,
@signer_email, @consent_text, @seal_version, @attempt_count, @max_attempts,
@created_at, @updated_at
)
@@ -98,6 +99,7 @@ INSERT INTO electronic_signatures (
"organization_id": es.OrganizationID,
"status": es.Status,
"document_type": es.DocumentType,
"document_name": es.DocumentName,
"file_id": es.FileID,
"signer_email": es.SignerEmail,
"consent_text": es.ConsentText,
@@ -184,7 +186,7 @@ func (es *ElectronicSignature) LoadByID(
) error {
q := `
SELECT
id, tenant_id, organization_id, status, document_type, file_id,
id, tenant_id, organization_id, status, document_type, document_name, file_id,
signer_email, consent_text, signer_full_name, signer_ip_address,
signer_user_agent, file_hash, seal, seal_version, tsa_token, signed_at,
certificate_file_id, certificate_processing_started_at,
@@ -222,7 +224,7 @@ func (es *ElectronicSignature) LoadNextAcceptedForUpdateSkipLocked(
) error {
q := `
SELECT
id, tenant_id, organization_id, status, document_type, file_id,
id, tenant_id, organization_id, status, document_type, document_name, file_id,
signer_email, consent_text, signer_full_name, signer_ip_address,
signer_user_agent, file_hash, seal, seal_version, tsa_token, signed_at,
certificate_file_id, certificate_processing_started_at,
@@ -258,7 +260,7 @@ func (es *ElectronicSignature) LoadNextCompletedWithoutCertificateForUpdate(
) error {
q := `
SELECT
id, tenant_id, organization_id, status, document_type, file_id,
id, tenant_id, organization_id, status, document_type, document_name, file_id,
signer_email, consent_text, signer_full_name, signer_ip_address,
signer_user_agent, file_hash, seal, seal_version, tsa_token, signed_at,
certificate_file_id, certificate_processing_started_at,

View File

@@ -31,6 +31,14 @@ const (
ElectronicSignatureDocumentTypeSLA ElectronicSignatureDocumentType = "SLA"
ElectronicSignatureDocumentTypeTOS ElectronicSignatureDocumentType = "TOS"
ElectronicSignatureDocumentTypePrivacyPolicy ElectronicSignatureDocumentType = "PRIVACY_POLICY"
ElectronicSignatureDocumentTypeGovernance ElectronicSignatureDocumentType = "GOVERNANCE"
ElectronicSignatureDocumentTypePolicy ElectronicSignatureDocumentType = "POLICY"
ElectronicSignatureDocumentTypeProcedure ElectronicSignatureDocumentType = "PROCEDURE"
ElectronicSignatureDocumentTypePlan ElectronicSignatureDocumentType = "PLAN"
ElectronicSignatureDocumentTypeRegister ElectronicSignatureDocumentType = "REGISTER"
ElectronicSignatureDocumentTypeRecord ElectronicSignatureDocumentType = "RECORD"
ElectronicSignatureDocumentTypeReport ElectronicSignatureDocumentType = "REPORT"
ElectronicSignatureDocumentTypeTemplate ElectronicSignatureDocumentType = "TEMPLATE"
ElectronicSignatureDocumentTypeOther ElectronicSignatureDocumentType = "OTHER"
ESignProcessConsentText = "By typing my full name and clicking Accept, I consent to sign this document electronically and agree that my electronic signature has the same legal validity as a handwritten signature."
@@ -45,6 +53,14 @@ func ElectronicSignatureDocumentTypes() []ElectronicSignatureDocumentType {
ElectronicSignatureDocumentTypeSLA,
ElectronicSignatureDocumentTypeTOS,
ElectronicSignatureDocumentTypePrivacyPolicy,
ElectronicSignatureDocumentTypeGovernance,
ElectronicSignatureDocumentTypePolicy,
ElectronicSignatureDocumentTypeProcedure,
ElectronicSignatureDocumentTypePlan,
ElectronicSignatureDocumentTypeRegister,
ElectronicSignatureDocumentTypeRecord,
ElectronicSignatureDocumentTypeReport,
ElectronicSignatureDocumentTypeTemplate,
ElectronicSignatureDocumentTypeOther,
}
}
@@ -71,10 +87,26 @@ func (dt *ElectronicSignatureDocumentType) UnmarshalText(data []byte) error {
*dt = ElectronicSignatureDocumentTypeTOS
case ElectronicSignatureDocumentTypePrivacyPolicy.String():
*dt = ElectronicSignatureDocumentTypePrivacyPolicy
case ElectronicSignatureDocumentTypeGovernance.String():
*dt = ElectronicSignatureDocumentTypeGovernance
case ElectronicSignatureDocumentTypePolicy.String():
*dt = ElectronicSignatureDocumentTypePolicy
case ElectronicSignatureDocumentTypeProcedure.String():
*dt = ElectronicSignatureDocumentTypeProcedure
case ElectronicSignatureDocumentTypePlan.String():
*dt = ElectronicSignatureDocumentTypePlan
case ElectronicSignatureDocumentTypeRegister.String():
*dt = ElectronicSignatureDocumentTypeRegister
case ElectronicSignatureDocumentTypeRecord.String():
*dt = ElectronicSignatureDocumentTypeRecord
case ElectronicSignatureDocumentTypeReport.String():
*dt = ElectronicSignatureDocumentTypeReport
case ElectronicSignatureDocumentTypeTemplate.String():
*dt = ElectronicSignatureDocumentTypeTemplate
case ElectronicSignatureDocumentTypeOther.String():
*dt = ElectronicSignatureDocumentTypeOther
default:
return fmt.Errorf("invalid ElectronicSignatureDocumentType value: %q", val)
return fmt.Errorf("cannot unmarshal ElectronicSignatureDocumentType: invalid value %q", val)
}
return nil
@@ -87,7 +119,7 @@ func (dt ElectronicSignatureDocumentType) String() string {
func (dt *ElectronicSignatureDocumentType) Scan(value any) error {
val, ok := value.(string)
if !ok {
return fmt.Errorf("invalid scan source for ElectronicSignatureDocumentType, expected string got %T", value)
return fmt.Errorf("cannot scan ElectronicSignatureDocumentType: expected string, got %T", value)
}
return dt.UnmarshalText([]byte(val))
@@ -113,6 +145,22 @@ func (dt ElectronicSignatureDocumentType) DisplayName() string {
return "Terms of Service"
case ElectronicSignatureDocumentTypePrivacyPolicy:
return "Privacy Policy"
case ElectronicSignatureDocumentTypeGovernance:
return "Governance Document"
case ElectronicSignatureDocumentTypePolicy:
return "Policy"
case ElectronicSignatureDocumentTypeProcedure:
return "Procedure"
case ElectronicSignatureDocumentTypePlan:
return "Plan"
case ElectronicSignatureDocumentTypeRegister:
return "Register"
case ElectronicSignatureDocumentTypeRecord:
return "Record"
case ElectronicSignatureDocumentTypeReport:
return "Report"
case ElectronicSignatureDocumentTypeTemplate:
return "Template"
default:
return string(dt)
}
@@ -135,11 +183,50 @@ func (dt ElectronicSignatureDocumentType) ConsentText() (string, error) {
docAgreement = "I agree to these Terms of Service."
case ElectronicSignatureDocumentTypePrivacyPolicy:
docAgreement = "I agree to this Privacy Policy."
case ElectronicSignatureDocumentTypeGovernance:
docAgreement = "I acknowledge and agree to this Governance Document."
case ElectronicSignatureDocumentTypePolicy:
docAgreement = "I acknowledge and agree to this Policy."
case ElectronicSignatureDocumentTypeProcedure:
docAgreement = "I acknowledge and agree to this Procedure."
case ElectronicSignatureDocumentTypePlan:
docAgreement = "I acknowledge and agree to this Plan."
case ElectronicSignatureDocumentTypeRegister:
docAgreement = "I acknowledge and agree to this Register."
case ElectronicSignatureDocumentTypeRecord:
docAgreement = "I acknowledge and agree to this Record."
case ElectronicSignatureDocumentTypeReport:
docAgreement = "I acknowledge and agree to this Report."
case ElectronicSignatureDocumentTypeTemplate:
docAgreement = "I acknowledge and agree to this Template."
case ElectronicSignatureDocumentTypeOther:
return "", fmt.Errorf("document type OTHER requires explicit consent text")
return "", fmt.Errorf("cannot get consent text: document type OTHER requires explicit consent text")
default:
return "", fmt.Errorf("unknown document type %q", dt)
return "", fmt.Errorf("cannot get consent text: unknown document type %q", dt)
}
return docAgreement + " " + ESignProcessConsentText, nil
}
func ElectronicSignatureDocumentTypeFromDocumentType(dt DocumentType) ElectronicSignatureDocumentType {
switch dt {
case DocumentTypeGovernance:
return ElectronicSignatureDocumentTypeGovernance
case DocumentTypePolicy:
return ElectronicSignatureDocumentTypePolicy
case DocumentTypeProcedure:
return ElectronicSignatureDocumentTypeProcedure
case DocumentTypePlan:
return ElectronicSignatureDocumentTypePlan
case DocumentTypeRegister:
return ElectronicSignatureDocumentTypeRegister
case DocumentTypeRecord:
return ElectronicSignatureDocumentTypeRecord
case DocumentTypeReport:
return ElectronicSignatureDocumentTypeReport
case DocumentTypeTemplate:
return ElectronicSignatureDocumentTypeTemplate
default:
return ElectronicSignatureDocumentTypeOther
}
}

View File

@@ -92,6 +92,8 @@ const (
MailingListUpdateEntityType uint16 = 66
FindingEntityType uint16 = 67
AuditLogEntryEntityType uint16 = 68
DocumentVersionApprovalQuorumEntityType uint16 = 69
DocumentVersionApprovalDecisionEntityType uint16 = 70
)
func NewEntityFromID(id gid.GID) (any, bool) {
@@ -226,6 +228,10 @@ func NewEntityFromID(id gid.GID) (any, bool) {
return &MailingListUpdate{ID: id}, true
case AuditLogEntryEntityType:
return &AuditLogEntry{ID: id}, true
case DocumentVersionApprovalDecisionEntityType:
return &DocumentVersionApprovalDecision{ID: id}, true
case DocumentVersionApprovalQuorumEntityType:
return &DocumentVersionApprovalQuorum{ID: id}, true
default:
return nil, false
}

View File

@@ -559,150 +559,6 @@ WHERE
return nil
}
func (p *MembershipProfiles) LoadByDocumentID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
documentID gid.GID,
cursor *page.Cursor[MembershipProfileOrderField],
) error {
q := `
WITH profiles AS (
SELECT
mp.id,
mp.identity_id,
mp.organization_id,
mp.source,
mp.state,
mp.full_name,
mp.kind,
mp.additional_email_addresses,
mp.position,
mp.contract_start_date,
mp.contract_end_date,
mp.user_name,
mp.external_id,
mp.nickname,
mp.locale,
mp.timezone,
mp.profile_url,
mp.preferred_language,
mp.given_name,
mp.family_name,
mp.formatted_name,
mp.middle_name,
mp.honorific_prefix,
mp.honorific_suffix,
mp.employee_number,
mp.department,
mp.cost_center,
mp.enterprise_organization,
mp.division,
mp.manager_value,
mp.created_at,
mp.updated_at
FROM
iam_membership_profiles mp
WHERE
mp.%s
AND mp.id IN (
SELECT approver_profile_id
FROM document_approvers
WHERE document_id = @document_id
)
AND %s
)
SELECT
p.id,
p.identity_id,
p.organization_id,
i.email_address,
p.source,
p.state,
p.full_name,
p.kind,
p.additional_email_addresses,
p.position,
p.contract_start_date,
p.contract_end_date,
'' AS organization_name,
p.user_name,
p.external_id,
p.nickname,
p.locale,
p.timezone,
p.profile_url,
p.preferred_language,
p.given_name,
p.family_name,
p.formatted_name,
p.middle_name,
p.honorific_prefix,
p.honorific_suffix,
p.employee_number,
p.department,
p.cost_center,
p.enterprise_organization,
p.division,
p.manager_value,
p.created_at,
p.updated_at
FROM profiles p
INNER JOIN identities i ON i.id = p.identity_id
`
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
args := pgx.NamedArgs{"document_id": documentID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query document approver profiles: %w", err)
}
profiles, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[MembershipProfile])
if err != nil {
return fmt.Errorf("cannot collect document approver profiles: %w", err)
}
*p = profiles
return nil
}
func (p *MembershipProfiles) CountByDocumentID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
documentID gid.GID,
) (int, error) {
q := `
SELECT
COUNT(*)
FROM
iam_membership_profiles mp
INNER JOIN document_approvers da ON mp.id = da.approver_profile_id
WHERE
mp.%s
AND da.document_id = @document_id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"document_id": documentID}
maps.Copy(args, scope.SQLArguments())
var count int
err := conn.QueryRow(ctx, q, args).Scan(&count)
if err != nil {
return 0, fmt.Errorf("cannot query document approver profiles count: %w", err)
}
return count, nil
}
func (p *MembershipProfiles) LoadByDocumentVersionID(
ctx context.Context,
conn pg.Conn,
@@ -711,7 +567,19 @@ func (p *MembershipProfiles) LoadByDocumentVersionID(
cursor *page.Cursor[MembershipProfileOrderField],
) error {
q := `
WITH profiles AS (
WITH latest_quorum AS (
SELECT id
FROM document_version_approval_quorums
WHERE version_id = @version_id
ORDER BY created_at DESC
LIMIT 1
),
version_approvers AS (
SELECT d.approver_id
FROM document_version_approval_decisions d
WHERE d.quorum_id = (SELECT id FROM latest_quorum)
),
profiles AS (
SELECT
mp.id,
mp.identity_id,
@@ -747,13 +615,9 @@ WITH profiles AS (
mp.updated_at
FROM
iam_membership_profiles mp
INNER JOIN version_approvers va ON va.approver_id = mp.id
WHERE
mp.%s
AND mp.id IN (
SELECT approver_profile_id
FROM document_version_approvers
WHERE document_version_id = @document_version_id
)
AND %s
)
SELECT
@@ -797,7 +661,7 @@ INNER JOIN identities i ON i.id = p.identity_id
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
args := pgx.NamedArgs{"document_version_id": documentVersionID}
args := pgx.NamedArgs{"version_id": documentVersionID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
@@ -823,19 +687,26 @@ func (p *MembershipProfiles) CountByDocumentVersionID(
documentVersionID gid.GID,
) (int, error) {
q := `
WITH latest_quorum AS (
SELECT id
FROM document_version_approval_quorums
WHERE version_id = @version_id
ORDER BY created_at DESC
LIMIT 1
)
SELECT
COUNT(*)
COUNT(DISTINCT mp.id)
FROM
iam_membership_profiles mp
INNER JOIN document_version_approvers dva ON mp.id = dva.approver_profile_id
INNER JOIN document_version_approval_decisions dvad ON mp.id = dvad.approver_id
INNER JOIN latest_quorum lq ON lq.id = dvad.quorum_id
WHERE
mp.%s
AND dva.document_version_id = @document_version_id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"document_version_id": documentVersionID}
args := pgx.StrictNamedArgs{"version_id": documentVersionID}
maps.Copy(args, scope.SQLArguments())
var count int

View 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

View File

@@ -386,6 +386,7 @@
<span class="classification">{{.Classification | classificationString}}</span>
</td>
</tr>
{{- if gt (len .Approvers) 0}}
<tr>
<td>Approver{{- if gt (len .Approvers) 1}}s{{- end}}</td>
<td>
@@ -400,6 +401,7 @@
{{- end}}
</td>
</tr>
{{- end}}
<tr>
<td>Version:</td>
<td>{{.Version}}</td>

View File

@@ -315,8 +315,11 @@ func (w *CompletionCertificateWorker) generateCertificate(
}
emailPresenter := emails.NewPresenterFromConfig(w.fileManager, presenterCfg, ref.UnrefOrZero(signature.SignerFullName))
docTypeName := signature.DocumentType.DisplayName()
subject, textBody, htmlBody, err := emailPresenter.RenderElectronicSignatureCertificate(ctx, ref.UnrefOrZero(signature.SignerFullName), docTypeName)
docName := ref.UnrefOrZero(signature.DocumentName)
if docName == "" {
docName = signature.DocumentType.DisplayName()
}
subject, textBody, htmlBody, err := emailPresenter.RenderElectronicSignatureCertificate(ctx, ref.UnrefOrZero(signature.SignerFullName), docName)
if err != nil {
return nil, nil, fmt.Errorf("cannot render email: %w", err)
}

View File

@@ -48,6 +48,7 @@ type (
CreateSignatureRequest struct {
OrganizationID gid.GID
DocumentType coredata.ElectronicSignatureDocumentType
DocumentName *string
FileID gid.GID
SignerEmail mail.Addr
ConsentText string // optional; required when DocumentType == OTHER
@@ -61,6 +62,18 @@ type (
SignerUA string
}
CreateAndAcceptSignatureRequest struct {
OrganizationID gid.GID
DocumentType coredata.ElectronicSignatureDocumentType
DocumentName *string
FileID gid.GID
SignerEmail mail.Addr
SignerFullName string
SignerIPAddr string
SignerUA string
ConsentText string
}
RecordEventRequest struct {
SignatureID gid.GID
EventType coredata.ElectronicSignatureEventType
@@ -161,6 +174,7 @@ func (s *Service) CreateSignature(
OrganizationID: req.OrganizationID,
Status: coredata.ElectronicSignatureStatusPending,
DocumentType: req.DocumentType,
DocumentName: req.DocumentName,
FileID: stampedFileID,
SignerEmail: req.SignerEmail.String(),
ConsentText: consentText,
@@ -178,6 +192,59 @@ func (s *Service) CreateSignature(
return sig, nil
}
func (s *Service) CreateAndAcceptSignature(
ctx context.Context,
conn pg.Conn,
req *CreateAndAcceptSignatureRequest,
) (*coredata.ElectronicSignature, error) {
sig, err := s.CreateSignature(
ctx,
conn,
&CreateSignatureRequest{
OrganizationID: req.OrganizationID,
DocumentType: req.DocumentType,
DocumentName: req.DocumentName,
FileID: req.FileID,
SignerEmail: req.SignerEmail,
ConsentText: req.ConsentText,
},
)
if err != nil {
return nil, fmt.Errorf("cannot create signature: %w", err)
}
now := time.Now()
scope := coredata.NewScopeFromObjectID(req.OrganizationID)
sig.SignerFullName = &req.SignerFullName
sig.SignerIPAddress = &req.SignerIPAddr
sig.SignerUserAgent = &req.SignerUA
sig.SignedAt = &now
sig.Status = coredata.ElectronicSignatureStatusAccepted
sig.UpdatedAt = now
if err := sig.Update(ctx, conn, scope); err != nil {
return nil, fmt.Errorf("cannot accept signature: %w", err)
}
if err := s.recordEvent(
ctx,
conn,
&RecordEventRequest{
SignatureID: sig.ID,
EventType: coredata.ElectronicSignatureEventTypeSignatureAccepted,
EventSource: coredata.ElectronicSignatureEventSourceServer,
ActorEmail: req.SignerEmail,
ActorIPAddr: req.SignerIPAddr,
ActorUA: req.SignerUA,
},
); err != nil {
return nil, fmt.Errorf("cannot record signature event: %w", err)
}
return sig, nil
}
func (s *Service) createStampedDocument(
ctx context.Context,
conn pg.Conn,

View File

@@ -187,15 +187,21 @@ const (
ActionDocumentSendSigningNotifications = "core:document:send-signing-notifications"
// DocumentVersion actions
ActionDocumentVersionGet = "core:document-version:get"
ActionDocumentVersionList = "core:document-version:list"
ActionDocumentVersionExportPDF = "core:document-version:export-pdf"
ActionDocumentVersionExportSignable = "core:document-version:export-signable-pdf"
ActionDocumentVersionSign = "core:document-version:sign"
ActionDocumentVersionUpdate = "core:document-version:update"
ActionDocumentVersionDeleteDraft = "core:document-version:delete-draft"
ActionDocumentVersionPublish = "core:document-version:publish"
ActionDocumentVersionExport = "core:document-version:export"
ActionDocumentVersionGet = "core:document-version:get"
ActionDocumentVersionList = "core:document-version:list"
ActionDocumentVersionExportPDF = "core:document-version:export-pdf"
ActionDocumentVersionExportSignable = "core:document-version:export-signable-pdf"
ActionDocumentVersionSign = "core:document-version:sign"
ActionDocumentVersionUpdate = "core:document-version:update"
ActionDocumentVersionDeleteDraft = "core:document-version:delete-draft"
ActionDocumentVersionRequestApproval = "core:document-version:request-approval"
ActionDocumentVersionApprove = "core:document-version:approve"
ActionDocumentVersionReject = "core:document-version:reject"
ActionDocumentVersionApprovalList = "core:document-version:approval-list"
ActionDocumentVersionAddApprover = "core:document-version:add-approver"
ActionDocumentVersionRemoveApprover = "core:document-version:remove-approver"
ActionDocumentVersionPublish = "core:document-version:publish"
ActionDocumentVersionExport = "core:document-version:export"
// DocumentVersionSignature actions
ActionDocumentVersionSignatureRequest = "core:document-version-signature:request"

View 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
}

View File

@@ -18,6 +18,7 @@ import (
"archive/zip"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
@@ -76,7 +77,6 @@ type (
OrganizationID gid.GID
Title string
Content string
ApproverIDs []gid.GID
Classification coredata.DocumentClassification
DocumentType coredata.DocumentType
TrustCenterVisibility *coredata.TrustCenterVisibility
@@ -85,7 +85,6 @@ type (
UpdateDocumentRequest struct {
DocumentID gid.GID
Title *string
ApproverIDs []gid.GID
Classification *coredata.DocumentClassification
DocumentType *coredata.DocumentType
TrustCenterVisibility *coredata.TrustCenterVisibility
@@ -123,10 +122,6 @@ func (cdr *CreateDocumentRequest) Validate() error {
v.Check(cdr.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
v.Check(cdr.Title, "title", validator.Required(), validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(cdr.Content, "content", validator.Required(), validator.NotEmpty(), validator.MaxLen(documentMaxLength))
v.Check(cdr.ApproverIDs, "approver_ids", validator.Required(), validator.NotEmpty())
for _, id := range cdr.ApproverIDs {
v.Check(id, "approver_ids", validator.Required(), validator.GID(coredata.MembershipProfileEntityType))
}
v.Check(cdr.Classification, "classification", validator.Required(), validator.OneOfSlice(coredata.DocumentClassifications()))
v.Check(cdr.DocumentType, "document_type", validator.Required(), validator.OneOfSlice(coredata.DocumentTypes()))
v.Check(cdr.TrustCenterVisibility, "trust_center_visibility", validator.OneOfSlice(coredata.TrustCenterVisibilities()))
@@ -139,9 +134,6 @@ func (udr *UpdateDocumentRequest) Validate() error {
v.Check(udr.DocumentID, "document_id", validator.Required(), validator.GID(coredata.DocumentEntityType))
v.Check(udr.Title, "title", validator.SafeTextNoNewLine(TitleMaxLength))
for _, id := range udr.ApproverIDs {
v.Check(id, "approver_ids", validator.Required(), validator.GID(coredata.MembershipProfileEntityType))
}
v.Check(udr.Classification, "classification", validator.OneOfSlice(coredata.DocumentClassifications()))
v.Check(udr.DocumentType, "document_type", validator.OneOfSlice(coredata.DocumentTypes()))
v.Check(udr.TrustCenterVisibility, "trust_center_visibility", validator.OneOfSlice(coredata.TrustCenterVisibilities()))
@@ -244,57 +236,6 @@ func (s *DocumentService) GetByIDs(
return documents, nil
}
func (s *DocumentService) ListApprovers(
ctx context.Context,
documentID gid.GID,
cursor *page.Cursor[coredata.MembershipProfileOrderField],
) (*page.Page[*coredata.MembershipProfile, coredata.MembershipProfileOrderField], error) {
var profiles coredata.MembershipProfiles
err := s.svc.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := profiles.LoadByDocumentID(ctx, conn, s.svc.scope, documentID, cursor); err != nil {
return fmt.Errorf("cannot load document approvers: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(profiles, cursor), nil
}
func (s *DocumentService) CountApprovers(
ctx context.Context,
documentID gid.GID,
) (int, error) {
var count int
err := s.svc.pg.WithConn(
ctx,
func(conn pg.Conn) (err error) {
profiles := coredata.MembershipProfiles{}
count, err = profiles.CountByDocumentID(ctx, conn, s.svc.scope, documentID)
if err != nil {
return fmt.Errorf("cannot count document approvers: %w", err)
}
return nil
},
)
if err != nil {
return 0, err
}
return count, nil
}
func (s *DocumentService) ListVersionApprovers(
ctx context.Context,
documentVersionID gid.GID,
@@ -602,54 +543,18 @@ func (s *DocumentService) Create(
return fmt.Errorf("cannot load organization: %w", err)
}
// Validate all approver profiles exist
approverProfiles := coredata.MembershipProfiles{}
if err := approverProfiles.LoadByIDs(ctx, conn, s.svc.scope, req.ApproverIDs); err != nil {
return fmt.Errorf("cannot load approver profiles: %w", err)
}
if len(approverProfiles) != len(req.ApproverIDs) {
return fmt.Errorf("one or more approver profiles not found")
}
document.OrganizationID = organization.ID
if err := document.Insert(ctx, conn, s.svc.scope); err != nil {
return fmt.Errorf("cannot insert document: %w", err)
}
// Insert document approvers
for _, approverID := range req.ApproverIDs {
da := coredata.DocumentApprover{
DocumentID: documentID,
ApproverProfileID: approverID,
OrganizationID: organization.ID,
CreatedAt: now,
}
if err := da.Insert(ctx, conn, s.svc.scope); err != nil {
return fmt.Errorf("cannot insert document approver: %w", err)
}
}
documentVersion.OrganizationID = organization.ID
if err := documentVersion.Insert(ctx, conn, s.svc.scope); err != nil {
return fmt.Errorf("cannot create document version: %w", err)
}
// Insert document version approvers
for _, approverID := range req.ApproverIDs {
dva := coredata.DocumentVersionApprover{
DocumentVersionID: documentVersionID,
ApproverProfileID: approverID,
OrganizationID: organization.ID,
CreatedAt: now,
}
if err := dva.Insert(ctx, conn, s.svc.scope); err != nil {
return fmt.Errorf("cannot insert document version approver: %w", err)
}
}
return nil
},
)
@@ -870,28 +775,6 @@ func (s *DocumentService) UpdateVersion(
return fmt.Errorf("cannot update document version: %w", err)
}
docApprovers := &coredata.DocumentApprovers{}
if err := docApprovers.LoadByDocumentID(ctx, conn, s.svc.scope, document.ID); err != nil {
return fmt.Errorf("cannot load document approvers: %w", err)
}
versionApprovers := &coredata.DocumentVersionApprovers{}
if err := versionApprovers.DeleteByDocumentVersionID(ctx, conn, s.svc.scope, documentVersion.ID); err != nil {
return fmt.Errorf("cannot delete document version approvers: %w", err)
}
for _, da := range *docApprovers {
dva := coredata.DocumentVersionApprover{
DocumentVersionID: documentVersion.ID,
ApproverProfileID: da.ApproverProfileID,
OrganizationID: da.OrganizationID,
CreatedAt: time.Now(),
}
if err := dva.Insert(ctx, conn, s.svc.scope); err != nil {
return fmt.Errorf("cannot insert document version approver: %w", err)
}
}
return nil
},
)
@@ -1132,23 +1015,6 @@ func (s *DocumentService) CreateDraft(
return fmt.Errorf("cannot create draft: %w", err)
}
docApprovers := &coredata.DocumentApprovers{}
if err := docApprovers.LoadByDocumentID(ctx, conn, s.svc.scope, documentID); err != nil {
return fmt.Errorf("cannot load document approvers: %w", err)
}
for _, da := range *docApprovers {
dva := coredata.DocumentVersionApprover{
DocumentVersionID: draftVersionID,
ApproverProfileID: da.ApproverProfileID,
OrganizationID: da.OrganizationID,
CreatedAt: now,
}
if err := dva.Insert(ctx, conn, s.svc.scope); err != nil {
return fmt.Errorf("cannot insert document version approver: %w", err)
}
}
return nil
},
)
@@ -1460,6 +1326,36 @@ func (s *DocumentService) IsSigned(
return signed, nil
}
func (s *DocumentService) GetViewerApprovalState(
ctx context.Context,
documentID gid.GID,
identityID gid.GID,
) (coredata.DocumentVersionApprovalDecisionState, error) {
document := &coredata.Document{}
var state coredata.DocumentVersionApprovalDecisionState
err := s.svc.pg.WithConn(
ctx,
func(conn pg.Conn) error {
var err error
state, err = document.GetViewerApprovalStateForLastVersion(
ctx,
conn,
s.svc.scope,
documentID,
identityID,
)
return err
},
)
if err != nil {
return "", fmt.Errorf("cannot get viewer approval state: %w", err)
}
return state, nil
}
func (s *DocumentService) CountForOrganizationID(
ctx context.Context,
organizationID gid.GID,
@@ -1656,33 +1552,6 @@ func (s *DocumentService) Update(
document.TrustCenterVisibility = *req.TrustCenterVisibility
}
if len(req.ApproverIDs) > 0 {
approverProfiles := coredata.MembershipProfiles{}
if err := approverProfiles.LoadByIDs(ctx, tx, s.svc.scope, req.ApproverIDs); err != nil {
return fmt.Errorf("cannot load approver profiles: %w", err)
}
if len(approverProfiles) != len(req.ApproverIDs) {
return fmt.Errorf("one or more approver profiles not found")
}
docApprovers := &coredata.DocumentApprovers{}
if err := docApprovers.DeleteByDocumentID(ctx, tx, s.svc.scope, req.DocumentID); err != nil {
return fmt.Errorf("cannot delete document approvers: %w", err)
}
for _, approverID := range req.ApproverIDs {
da := coredata.DocumentApprover{
DocumentID: req.DocumentID,
ApproverProfileID: approverID,
OrganizationID: document.OrganizationID,
CreatedAt: now,
}
if err := da.Insert(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot insert document approver: %w", err)
}
}
}
document.UpdatedAt = now
if err := document.Update(ctx, tx, s.svc.scope); err != nil {
@@ -1699,25 +1568,6 @@ func (s *DocumentService) Update(
if err := draftVersion.Update(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot update draft version: %w", err)
}
if len(req.ApproverIDs) > 0 {
versionApprovers := &coredata.DocumentVersionApprovers{}
if err := versionApprovers.DeleteByDocumentVersionID(ctx, tx, s.svc.scope, draftVersion.ID); err != nil {
return fmt.Errorf("cannot delete draft version approvers: %w", err)
}
for _, approverID := range req.ApproverIDs {
dva := coredata.DocumentVersionApprover{
DocumentVersionID: draftVersion.ID,
ApproverProfileID: approverID,
OrganizationID: document.OrganizationID,
CreatedAt: now,
}
if err := dva.Insert(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot insert draft version approver: %w", err)
}
}
}
}
return nil
@@ -2014,25 +1864,53 @@ func exportDocumentPDF(
return nil, fmt.Errorf("cannot load document: %w", err)
}
versionApprovers := &coredata.DocumentVersionApprovers{}
if err := versionApprovers.LoadByDocumentVersionID(ctx, conn, scope, documentVersionID); err != nil {
return nil, fmt.Errorf("cannot load document version approvers: %w", err)
}
// Only show approvers from the last approved quorum in the export.
var approverNames []string
approverProfiles := coredata.MembershipProfiles{}
if err := approverProfiles.LoadByIDs(ctx, conn, scope, versionApprovers.ApproverProfileIDs()); err != nil {
return nil, fmt.Errorf("cannot load document approver profiles: %w", err)
}
lastQuorum := &coredata.DocumentVersionApprovalQuorum{}
if err := lastQuorum.LoadLastByDocumentVersionID(ctx, conn, scope, documentVersionID); err != nil {
if !errors.Is(err, coredata.ErrResourceNotFound) {
return nil, fmt.Errorf("cannot load last approval quorum: %w", err)
}
} else if lastQuorum.Status == coredata.DocumentVersionApprovalQuorumStatusApproved {
approvedDecisions := &coredata.DocumentVersionApprovalDecisions{}
approvedFilter := coredata.NewDocumentVersionApprovalDecisionFilter(
coredata.DocumentVersionApprovalDecisionStates{coredata.DocumentVersionApprovalDecisionStateApproved},
)
if err := approvedDecisions.LoadByQuorumID(
ctx,
conn,
scope,
lastQuorum.ID,
page.NewCursor(
100,
nil,
page.Head,
page.OrderBy[coredata.DocumentVersionApprovalDecisionOrderField]{
Field: coredata.DocumentVersionApprovalDecisionOrderFieldCreatedAt,
Direction: page.OrderDirectionAsc,
},
),
approvedFilter,
); err != nil {
return nil, fmt.Errorf("cannot load approved decisions: %w", err)
}
profileByID := make(map[gid.GID]*coredata.MembershipProfile, len(approverProfiles))
for _, p := range approverProfiles {
profileByID[p.ID] = p
}
approverProfileIDs := make([]gid.GID, 0, len(*approvedDecisions))
for _, d := range *approvedDecisions {
approverProfileIDs = append(approverProfileIDs, d.ApproverID)
}
approverNames := make([]string, 0, len(*versionApprovers))
for _, a := range *versionApprovers {
if p, ok := profileByID[a.ApproverProfileID]; ok {
approverNames = append(approverNames, p.FullName)
if len(approverProfileIDs) > 0 {
approverProfiles := coredata.MembershipProfiles{}
if err := approverProfiles.LoadByIDs(ctx, conn, scope, approverProfileIDs); err != nil {
return nil, fmt.Errorf("cannot load approver profiles: %w", err)
}
approverNames = make([]string, 0, len(approverProfiles))
for _, p := range approverProfiles {
approverNames = append(approverNames, p.FullName)
}
}
}

View File

@@ -29,6 +29,11 @@ var (
ActionDocumentSendSigningNotifications,
ActionDocumentVersionUpdate,
ActionDocumentVersionPublish,
ActionDocumentVersionRequestApproval,
ActionDocumentVersionApprove,
ActionDocumentVersionReject,
ActionDocumentVersionAddApprover,
ActionDocumentVersionRemoveApprover,
ActionDocumentVersionDeleteDraft,
ActionDocumentVersionSignatureRequest,
ActionDocumentVersionCancelSignature,
@@ -42,6 +47,31 @@ var (
organizationCondition,
policy.Equals("resource.document_status", "ACTIVE"),
)
// Deny requesting approval when a pending quorum exists
documentRequestApprovalNoPendingQuorum = policy.Deny(
ActionDocumentVersionRequestApproval,
).WithSID("document-request-approval-no-pending-quorum").When(
organizationCondition,
policy.Equals("resource.last_quorum_status", "PENDING"),
)
// Deny requesting approval when the version is already published
documentRequestApprovalNotPublished = policy.Deny(
ActionDocumentVersionRequestApproval,
).WithSID("document-request-approval-not-published").When(
organizationCondition,
policy.Equals("resource.version_status", "PUBLISHED"),
)
// Deny adding/removing approvers when there is no pending quorum
documentApproverRequiresPendingQuorum = policy.Deny(
ActionDocumentVersionAddApprover,
ActionDocumentVersionRemoveApprover,
).WithSID("document-approver-requires-pending-quorum").When(
organizationCondition,
policy.NotEquals("resource.last_quorum_status", "PENDING"),
)
)
// OwnerPolicy defines permissions for organization owners.
@@ -50,6 +80,11 @@ var OwnerPolicy = policy.NewPolicy(
"Probo Owner",
documentWriteActiveOnly,
documentUnarchiveArchivedOnly,
documentRequestApprovalNoPendingQuorum,
documentRequestApprovalNotPublished,
documentApproverRequiresPendingQuorum,
policy.Allow("core:*").WithSID("full-core-access").When(organizationCondition),
).WithDescription("Full probo access for organization owners")
@@ -59,6 +94,11 @@ var AdminPolicy = policy.NewPolicy(
"Probo Admin",
documentWriteActiveOnly,
documentUnarchiveArchivedOnly,
documentRequestApprovalNoPendingQuorum,
documentRequestApprovalNotPublished,
documentApproverRequiresPendingQuorum,
policy.Allow("core:*").WithSID("full-core-access").When(organizationCondition),
).WithDescription("Probo admin access - can manage core entities")
@@ -66,6 +106,7 @@ var AdminPolicy = policy.NewPolicy(
var ViewerPolicy = policy.NewPolicy(
"probo:viewer",
"Probo Viewer",
documentWriteActiveOnly,
policy.Allow(
ActionOrganizationGet,
ActionOrganizationGetLogoUrl,
@@ -88,6 +129,7 @@ var ViewerPolicy = policy.NewPolicy(
ActionDocumentGet, ActionDocumentList,
ActionDocumentVersionGet, ActionDocumentVersionList,
ActionDocumentVersionSignatureGet, ActionDocumentVersionSignatureList,
ActionDocumentVersionApprovalList,
ActionRiskGet, ActionRiskList,
ActionAssetGet, ActionAssetList,
ActionDatumGet, ActionDatumList,
@@ -123,6 +165,10 @@ var ViewerPolicy = policy.NewPolicy(
ActionDocumentVersionExportPDF, ActionDocumentVersionExportSignable, ActionDocumentVersionSign,
).WithSID("document-signing").When(organizationCondition),
policy.Allow(
ActionDocumentVersionApprove, ActionDocumentVersionReject,
).WithSID("document-approval").When(organizationCondition),
policy.Allow(
ActionProcessingActivityExport,
ActionDataProtectionImpactAssessmentExport,
@@ -155,6 +201,7 @@ var AuditorPolicy = policy.NewPolicy(
ActionDocumentGet, ActionDocumentList,
ActionDocumentVersionGet, ActionDocumentVersionList,
ActionDocumentVersionSignatureGet, ActionDocumentVersionSignatureList,
ActionDocumentVersionApprovalList,
ActionRiskGet, ActionRiskList,
ActionAssetGet, ActionAssetList,
ActionDatumGet, ActionDatumList,
@@ -184,6 +231,7 @@ var AuditorPolicy = policy.NewPolicy(
var EmployeePolicy = policy.NewPolicy(
"probo:employee",
"Probo Employee",
documentWriteActiveOnly,
policy.Allow(
ActionOrganizationGet,
ActionOrganizationGetLogoUrl,
@@ -198,7 +246,14 @@ var EmployeePolicy = policy.NewPolicy(
ActionDocumentVersionSign,
ActionDocumentVersionExportSignable,
).WithSID("document-version-signing").When(organizationCondition),
).WithDescription("Employee access - can sign documents and view internal content")
policy.Allow(
ActionDocumentVersionApprovalList,
ActionDocumentVersionApprove,
ActionDocumentVersionReject,
ActionDocumentVersionExportPDF,
).WithSID("document-version-approval").When(organizationCondition),
).WithDescription("Employee access - can sign documents, approve documents, and view internal content")
// ProboPolicySet returns the PolicySet for the probo service.
func ProboPolicySet() *iam.PolicySet {

View File

@@ -87,6 +87,7 @@ type (
Organizations *OrganizationService
Vendors *VendorService
Documents *DocumentService
DocumentApprovals *DocumentApprovalService
Controls *ControlService
Risks *RiskService
VendorComplianceReports *VendorComplianceReportService
@@ -212,6 +213,12 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService {
invitationTokenValidity: s.invitationTokenValidity,
tokenSecret: s.tokenSecret,
}
tenantService.DocumentApprovals = &DocumentApprovalService{
svc: tenantService,
html2pdfConverter: s.html2pdfConverter,
invitationTokenValidity: s.invitationTokenValidity,
tokenSecret: s.tokenSecret,
}
tenantService.Organizations = &OrganizationService{
svc: tenantService,
fileValidator: filevalidation.NewValidator(

View File

@@ -1623,7 +1623,7 @@ input ApplicabilityStatementOrder
}
input DocumentVersionFilter {
status: DocumentVersionStatus
statuses: [DocumentVersionStatus!]
}
# Input Types for Filtering
@@ -2441,13 +2441,6 @@ type Document implements Node {
classification: DocumentClassification!
currentPublishedVersion: Int
trustCenterVisibility: TrustCenterVisibility!
approvers(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: ProfileOrder
): ProfileConnection! @goField(forceResolver: true)
organization: Organization! @goField(forceResolver: true)
versions(
@@ -2477,16 +2470,17 @@ type Document implements Node {
permission(action: String!): Boolean! @goField(forceResolver: true)
}
type SignableDocument
type EmployeeDocument
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.SignableDocument"
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.EmployeeDocument"
) {
id: ID!
title: String!
description: String
documentType: DocumentType!
classification: DocumentClassification!
signed: Boolean! @goField(forceResolver: true)
signed: Boolean @goField(forceResolver: true)
approvalState: DocumentVersionApprovalDecisionState @goField(forceResolver: true)
versions(
first: Int
@@ -2494,13 +2488,26 @@ type SignableDocument
last: Int
before: CursorKey
orderBy: DocumentVersionOrder
filter: DocumentVersionFilter
): DocumentVersionConnection! @goField(forceResolver: true)
): EmployeeDocumentVersionConnection! @goField(forceResolver: true)
createdAt: Datetime!
updatedAt: Datetime!
}
type EmployeeDocumentVersion
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.EmployeeDocumentVersion"
) {
id: ID!
version: Int!
status: DocumentVersionStatus!
signed: Boolean! @goField(forceResolver: true)
approvalDecision: DocumentVersionApprovalDecision @goField(forceResolver: true)
publishedAt: Datetime
createdAt: Datetime!
updatedAt: Datetime!
}
type Meeting implements Node {
id: ID!
name: String!
@@ -2952,9 +2959,20 @@ type Viewer {
last: Int
before: CursorKey
orderBy: DocumentOrder
): SignableDocumentConnection! @goField(forceResolver: true)
): EmployeeDocumentConnection! @goField(forceResolver: true)
signableDocument(id: ID!): SignableDocument @goField(forceResolver: true)
signableDocument(id: ID!): EmployeeDocument @goField(forceResolver: true)
approvableDocuments(
organizationId: ID!
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: DocumentOrder
): EmployeeDocumentConnection! @goField(forceResolver: true)
approvableDocument(id: ID!): EmployeeDocument @goField(forceResolver: true)
}
type TrustCenterConnection {
@@ -3226,20 +3244,36 @@ type EvidenceEdge {
node: Evidence!
}
type SignableDocumentConnection
type EmployeeDocumentConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.SignableDocumentConnection"
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.EmployeeDocumentConnection"
) {
edges: [SignableDocumentEdge!]!
edges: [EmployeeDocumentEdge!]!
pageInfo: PageInfo!
}
type SignableDocumentEdge
type EmployeeDocumentEdge
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.SignableDocumentEdge"
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.EmployeeDocumentEdge"
) {
cursor: CursorKey!
node: SignableDocument!
node: EmployeeDocument!
}
type EmployeeDocumentVersionConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.EmployeeDocumentVersionConnection"
) {
edges: [EmployeeDocumentVersionEdge!]!
pageInfo: PageInfo!
}
type EmployeeDocumentVersionEdge
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.EmployeeDocumentVersionEdge"
) {
cursor: CursorKey!
node: EmployeeDocumentVersion!
}
type DocumentConnection
@@ -3762,6 +3796,9 @@ type Mutation {
bulkPublishDocumentVersions(
input: BulkPublishDocumentVersionsInput!
): BulkPublishDocumentVersionsPayload!
requestDocumentVersionApproval(
input: RequestDocumentVersionApprovalInput!
): RequestDocumentVersionApprovalPayload!
bulkDeleteDocuments(
input: BulkDeleteDocumentsInput!
): BulkDeleteDocumentsPayload!
@@ -3797,6 +3834,18 @@ type Mutation {
input: CancelSignatureRequestInput!
): CancelSignatureRequestPayload!
signDocument(input: SignDocumentInput!): SignDocumentPayload!
addDocumentVersionApprover(
input: AddDocumentVersionApproverInput!
): AddDocumentVersionApproverPayload!
removeDocumentVersionApprover(
input: RemoveDocumentVersionApproverInput!
): RemoveDocumentVersionApproverPayload!
approveDocumentVersion(
input: ApproveDocumentVersionInput!
): ApproveDocumentVersionPayload!
rejectDocumentVersion(
input: RejectDocumentVersionInput!
): RejectDocumentVersionPayload!
exportDocumentVersionPDF(
input: ExportDocumentVersionPDFInput!
@@ -4409,7 +4458,6 @@ input CreateDocumentInput {
organizationId: ID!
title: String!
content: String!
approverIds: [ID!]!
documentType: DocumentType!
classification: DocumentClassification!
trustCenterVisibility: TrustCenterVisibility
@@ -4419,7 +4467,6 @@ input UpdateDocumentInput {
id: ID!
title: String
content: String
approverIds: [ID!]
documentType: DocumentType
classification: DocumentClassification
trustCenterVisibility: TrustCenterVisibility
@@ -5324,6 +5371,14 @@ type DocumentVersion implements Node {
filter: DocumentVersionSignatureFilter
): DocumentVersionSignatureConnection! @goField(forceResolver: true)
approvalQuorums(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: DocumentVersionApprovalQuorumOrder
): DocumentVersionApprovalQuorumConnection! @goField(forceResolver: true)
signed: Boolean! @goField(forceResolver: true)
publishedAt: Datetime
@@ -5398,6 +5453,170 @@ type DocumentVersionSignature implements Node {
permission(action: String!): Boolean! @goField(forceResolver: true)
}
enum DocumentVersionApprovalDecisionState
@goModel(
model: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalDecisionState"
) {
PENDING
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalDecisionStatePending"
)
APPROVED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalDecisionStateApproved"
)
REJECTED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalDecisionStateRejected"
)
}
enum DocumentVersionApprovalDecisionOrderField
@goModel(
model: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalDecisionOrderField"
) {
CREATED_AT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalDecisionOrderFieldCreatedAt"
)
}
enum DocumentVersionApprovalQuorumStatus
@goModel(
model: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalQuorumStatus"
) {
PENDING
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalQuorumStatusPending"
)
APPROVED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalQuorumStatusApproved"
)
REJECTED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalQuorumStatusRejected"
)
}
enum DocumentVersionApprovalQuorumOrderField
@goModel(
model: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalQuorumOrderField"
) {
CREATED_AT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DocumentVersionApprovalQuorumOrderFieldCreatedAt"
)
}
input DocumentVersionApprovalQuorumOrder {
field: DocumentVersionApprovalQuorumOrderField!
direction: OrderDirection!
}
type DocumentVersionApprovalQuorumConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.DocumentVersionApprovalQuorumConnection"
) {
edges: [DocumentVersionApprovalQuorumEdge!]!
pageInfo: PageInfo!
totalCount: Int! @goField(forceResolver: true)
}
type DocumentVersionApprovalQuorumEdge {
cursor: CursorKey!
node: DocumentVersionApprovalQuorum!
}
type DocumentVersionApprovalQuorum implements Node {
id: ID!
documentVersion: DocumentVersion! @goField(forceResolver: true)
status: DocumentVersionApprovalQuorumStatus!
decisions(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: DocumentVersionApprovalDecisionOrder
filter: DocumentVersionApprovalDecisionFilter
): DocumentVersionApprovalDecisionConnection! @goField(forceResolver: true)
createdAt: Datetime!
updatedAt: Datetime!
permission(action: String!): Boolean! @goField(forceResolver: true)
}
input DocumentVersionApprovalDecisionFilter {
states: [DocumentVersionApprovalDecisionState!]
}
input DocumentVersionApprovalDecisionOrder {
field: DocumentVersionApprovalDecisionOrderField!
direction: OrderDirection!
}
type DocumentVersionApprovalDecisionConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.DocumentVersionApprovalDecisionConnection"
) {
edges: [DocumentVersionApprovalDecisionEdge!]!
pageInfo: PageInfo!
totalCount: Int! @goField(forceResolver: true)
}
type DocumentVersionApprovalDecisionEdge {
cursor: CursorKey!
node: DocumentVersionApprovalDecision!
}
type DocumentVersionApprovalDecision implements Node {
id: ID!
quorum: DocumentVersionApprovalQuorum! @goField(forceResolver: true)
documentVersion: DocumentVersion! @goField(forceResolver: true)
approver: Profile! @goField(forceResolver: true)
state: DocumentVersionApprovalDecisionState!
comment: String
decidedAt: Datetime
createdAt: Datetime!
updatedAt: Datetime!
permission(action: String!): Boolean! @goField(forceResolver: true)
}
input ApproveDocumentVersionInput {
documentVersionId: ID!
comment: String
}
type ApproveDocumentVersionPayload {
approvalDecision: DocumentVersionApprovalDecision!
}
input RejectDocumentVersionInput {
documentVersionId: ID!
comment: String
}
type RejectDocumentVersionPayload {
approvalDecision: DocumentVersionApprovalDecision!
}
input AddDocumentVersionApproverInput {
documentVersionId: ID!
approverId: ID!
}
type AddDocumentVersionApproverPayload {
approvalDecisionEdge: DocumentVersionApprovalDecisionEdge!
}
input RemoveDocumentVersionApproverInput {
approvalDecisionId: ID!
}
type RemoveDocumentVersionApproverPayload {
deletedApprovalDecisionId: ID!
documentVersion: DocumentVersion!
}
input RequestSignatureInput {
documentVersionId: ID!
signatoryId: ID!
@@ -5416,16 +5635,6 @@ type BulkRequestSignaturesPayload {
documentVersionSignatureEdges: [DocumentVersionSignatureEdge!]!
}
input BulkPublishDocumentVersionsInput {
documentIds: [ID!]!
changelog: String!
}
type BulkPublishDocumentVersionsPayload {
documentVersionEdges: [DocumentVersionEdge!]!
documentEdges: [DocumentEdge!]!
}
input BulkDeleteDocumentsInput {
documentIds: [ID!]!
}
@@ -5461,14 +5670,34 @@ type BulkExportDocumentsPayload {
exportJobId: ID!
}
input RequestDocumentVersionApprovalInput {
documentId: ID!
approverIds: [ID!]!
changelog: String
}
type RequestDocumentVersionApprovalPayload {
approvalQuorum: DocumentVersionApprovalQuorum!
}
input PublishDocumentVersionInput {
documentId: ID!
changelog: String
}
type PublishDocumentVersionPayload {
documentVersion: DocumentVersion!
document: Document!
documentVersion: DocumentVersion!
}
input BulkPublishDocumentVersionsInput {
documentIds: [ID!]!
changelog: String!
}
type BulkPublishDocumentVersionsPayload {
documentVersions: [DocumentVersion!]!
documents: [Document!]!
}
type CreateDraftDocumentVersionPayload {

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -1685,16 +1685,7 @@ func (r *Resolver) ListControlDocumentsTool(ctx context.Context, req *mcp.CallTo
return nil, types.ListControlDocumentsOutput{}, fmt.Errorf("failed to list control documents: %w", err)
}
approverIDsMap := make(map[gid.GID][]gid.GID)
for _, d := range docPage.Data {
approverPage, err := prb.Documents.ListApprovers(ctx, d.ID, allApproversCursor())
if err != nil {
return nil, types.ListControlDocumentsOutput{}, fmt.Errorf("failed to list document approvers: %w", err)
}
approverIDsMap[d.ID] = profileIDs(approverPage)
}
return nil, types.NewListControlDocumentsOutput(docPage, approverIDsMap), nil
return nil, types.NewListControlDocumentsOutput(docPage), nil
}
func (r *Resolver) ListControlAuditsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListControlAuditsInput) (*mcp.CallToolResult, types.ListControlAuditsOutput, error) {
@@ -2059,16 +2050,7 @@ func (r *Resolver) ListDocumentsTool(ctx context.Context, req *mcp.CallToolReque
panic(fmt.Errorf("cannot list organization documents: %w", err))
}
approverIDsMap := make(map[gid.GID][]gid.GID)
for _, d := range docPage.Data {
approverPage, err := prb.Documents.ListApprovers(ctx, d.ID, allApproversCursor())
if err != nil {
panic(fmt.Errorf("cannot list document approvers: %w", err))
}
approverIDsMap[d.ID] = profileIDs(approverPage)
}
return nil, types.NewListDocumentsOutput(docPage, approverIDsMap), nil
return nil, types.NewListDocumentsOutput(docPage), nil
}
func (r *Resolver) GetDocumentTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetDocumentInput) (*mcp.CallToolResult, types.GetDocumentOutput, error) {
@@ -2081,13 +2063,8 @@ func (r *Resolver) GetDocumentTool(ctx context.Context, req *mcp.CallToolRequest
panic(fmt.Errorf("cannot get document: %w", err))
}
approverPage, err := prb.Documents.ListApprovers(ctx, input.ID, allApproversCursor())
if err != nil {
panic(fmt.Errorf("cannot list document approvers: %w", err))
}
return nil, types.GetDocumentOutput{
Document: types.NewDocument(document, profileIDs(approverPage)),
Document: types.NewDocument(document),
}, nil
}
@@ -2107,7 +2084,6 @@ func (r *Resolver) AddDocumentTool(ctx context.Context, req *mcp.CallToolRequest
OrganizationID: input.OrganizationID,
Title: input.Title,
Content: input.Content,
ApproverIDs: input.ApproverIds,
Classification: input.Classification,
DocumentType: input.DocumentType,
TrustCenterVisibility: trustCenterVisibility,
@@ -2117,7 +2093,7 @@ func (r *Resolver) AddDocumentTool(ctx context.Context, req *mcp.CallToolRequest
panic(fmt.Errorf("cannot create document: %w", err))
}
return nil, types.NewAddDocumentOutput(document, documentVersion, input.ApproverIds, input.ApproverIds), nil
return nil, types.NewAddDocumentOutput(document, documentVersion), nil
}
func (r *Resolver) UpdateDocumentTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UpdateDocumentInput) (*mcp.CallToolResult, types.UpdateDocumentOutput, error) {
@@ -2130,7 +2106,6 @@ func (r *Resolver) UpdateDocumentTool(ctx context.Context, req *mcp.CallToolRequ
probo.UpdateDocumentRequest{
DocumentID: input.ID,
Title: input.Title,
ApproverIDs: input.ApproverIds,
Classification: input.Classification,
DocumentType: input.DocumentType,
TrustCenterVisibility: input.TrustCenterVisibility,
@@ -2140,13 +2115,8 @@ func (r *Resolver) UpdateDocumentTool(ctx context.Context, req *mcp.CallToolRequ
panic(fmt.Errorf("cannot update document: %w", err))
}
approverPage, err := svc.Documents.ListApprovers(ctx, input.ID, allApproversCursor())
if err != nil {
panic(fmt.Errorf("cannot list document approvers: %w", err))
}
return nil, types.UpdateDocumentOutput{
Document: types.NewDocument(document, profileIDs(approverPage)),
Document: types.NewDocument(document),
}, nil
}
@@ -2172,16 +2142,7 @@ func (r *Resolver) ListDocumentVersionsTool(ctx context.Context, req *mcp.CallTo
panic(fmt.Errorf("cannot list document versions: %w", err))
}
approverIDsMap := make(map[gid.GID][]gid.GID)
for _, v := range versionPage.Data {
approverPage, err := svc.Documents.ListVersionApprovers(ctx, v.ID, allApproversCursor())
if err != nil {
panic(fmt.Errorf("cannot list document version approvers: %w", err))
}
approverIDsMap[v.ID] = profileIDs(approverPage)
}
return nil, types.NewListDocumentVersionsOutput(versionPage, approverIDsMap), nil
return nil, types.NewListDocumentVersionsOutput(versionPage), nil
}
func (r *Resolver) GetDocumentVersionTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetDocumentVersionInput) (*mcp.CallToolResult, types.GetDocumentVersionOutput, error) {
@@ -2194,13 +2155,8 @@ func (r *Resolver) GetDocumentVersionTool(ctx context.Context, req *mcp.CallTool
panic(fmt.Errorf("cannot get document version: %w", err))
}
approverPage, err := svc.Documents.ListVersionApprovers(ctx, input.ID, allApproversCursor())
if err != nil {
panic(fmt.Errorf("cannot list document version approvers: %w", err))
}
return nil, types.GetDocumentVersionOutput{
DocumentVersion: types.NewDocumentVersion(version, profileIDs(approverPage)),
DocumentVersion: types.NewDocumentVersion(version),
}, nil
}
@@ -2214,13 +2170,8 @@ func (r *Resolver) CreateDraftDocumentVersionTool(ctx context.Context, req *mcp.
panic(fmt.Errorf("cannot create draft document version: %w", err))
}
approverPage, err := svc.Documents.ListVersionApprovers(ctx, draftVersion.ID, allApproversCursor())
if err != nil {
panic(fmt.Errorf("cannot list document version approvers: %w", err))
}
return nil, types.CreateDraftDocumentVersionOutput{
DocumentVersion: types.NewDocumentVersion(draftVersion, profileIDs(approverPage)),
DocumentVersion: types.NewDocumentVersion(draftVersion),
}, nil
}
@@ -2240,13 +2191,8 @@ func (r *Resolver) UpdateDocumentVersionTool(ctx context.Context, req *mcp.CallT
panic(fmt.Errorf("cannot update document version: %w", err))
}
versionApproverPage, err := svc.Documents.ListVersionApprovers(ctx, documentVersion.ID, allApproversCursor())
if err != nil {
panic(fmt.Errorf("cannot list document version approvers: %w", err))
}
return nil, types.UpdateDocumentVersionOutput{
DocumentVersion: types.NewDocumentVersion(documentVersion, profileIDs(versionApproverPage)),
DocumentVersion: types.NewDocumentVersion(documentVersion),
}, nil
}
@@ -2262,19 +2208,9 @@ func (r *Resolver) PublishDocumentVersionTool(ctx context.Context, req *mcp.Call
panic(fmt.Errorf("cannot publish document version: %w", err))
}
docApproverPage, err := svc.Documents.ListApprovers(ctx, document.ID, allApproversCursor())
if err != nil {
panic(fmt.Errorf("cannot list document approvers: %w", err))
}
versionApproverPage, err := svc.Documents.ListVersionApprovers(ctx, documentVersion.ID, allApproversCursor())
if err != nil {
panic(fmt.Errorf("cannot list document version approvers: %w", err))
}
return nil, types.PublishDocumentVersionOutput{
Document: types.NewDocument(document, profileIDs(docApproverPage)),
DocumentVersion: types.NewDocumentVersion(documentVersion, profileIDs(versionApproverPage)),
Document: types.NewDocument(document),
DocumentVersion: types.NewDocumentVersion(documentVersion),
}, nil
}
@@ -3243,13 +3179,8 @@ func (r *Resolver) ArchiveDocumentTool(ctx context.Context, req *mcp.CallToolReq
return nil, types.ArchiveDocumentOutput{}, fmt.Errorf("cannot archive document: %w", err)
}
approverPage, err := svc.Documents.ListApprovers(ctx, input.ID, allApproversCursor())
if err != nil {
return nil, types.ArchiveDocumentOutput{}, fmt.Errorf("cannot list document approvers: %w", err)
}
return nil, types.ArchiveDocumentOutput{
Document: types.NewDocument(document, profileIDs(approverPage)),
Document: types.NewDocument(document),
}, nil
}
@@ -3263,13 +3194,8 @@ func (r *Resolver) UnarchiveDocumentTool(ctx context.Context, req *mcp.CallToolR
return nil, types.UnarchiveDocumentOutput{}, fmt.Errorf("cannot unarchive document: %w", err)
}
approverPage, err := svc.Documents.ListApprovers(ctx, input.ID, allApproversCursor())
if err != nil {
return nil, types.UnarchiveDocumentOutput{}, fmt.Errorf("cannot list document approvers: %w", err)
}
return nil, types.UnarchiveDocumentOutput{
Document: types.NewDocument(document, profileIDs(approverPage)),
Document: types.NewDocument(document),
}, nil
}
@@ -3374,3 +3300,27 @@ func (r *Resolver) GetAuditLogEntryTool(ctx context.Context, req *mcp.CallToolRe
AuditLogEntry: types.NewAuditLogEntry(entry),
}, nil
}
func (r *Resolver) RequestDocumentVersionApprovalTool(ctx context.Context, req *mcp.CallToolRequest, input *types.RequestDocumentVersionApprovalInput) (*mcp.CallToolResult, types.RequestDocumentVersionApprovalOutput, error) {
r.MustAuthorize(ctx, input.DocumentID, probo.ActionDocumentVersionRequestApproval)
svc := r.ProboService(ctx, input.DocumentID)
quorum, err := svc.DocumentApprovals.RequestApproval(ctx, probo.RequestApprovalRequest{
DocumentID: input.DocumentID,
ApproverIDs: input.ApproverIds,
Changelog: input.Changelog,
})
if err != nil {
panic(fmt.Errorf("cannot request document version approval: %w", err))
}
documentVersion, err := svc.Documents.GetVersion(ctx, quorum.VersionID)
if err != nil {
panic(fmt.Errorf("cannot get document version: %w", err))
}
return nil, types.RequestDocumentVersionApprovalOutput{
DocumentVersion: types.NewDocumentVersion(documentVersion),
}, nil
}

View File

@@ -5184,7 +5184,6 @@ components:
required:
- id
- organization_id
- approver_ids
- title
- document_type
- classification
@@ -5199,11 +5198,6 @@ components:
organization_id:
$ref: "#/components/schemas/GID"
description: Organization ID
approver_ids:
type: array
items:
$ref: "#/components/schemas/GID"
description: Approver IDs
title:
type: string
description: Document title
@@ -5246,7 +5240,6 @@ components:
- organization_id
- document_id
- title
- approver_ids
- version_number
- classification
- content
@@ -5267,11 +5260,6 @@ components:
title:
type: string
description: Document version title
approver_ids:
type: array
items:
$ref: "#/components/schemas/GID"
description: Approver IDs
version_number:
type: integer
description: Version number
@@ -5418,7 +5406,6 @@ components:
- organization_id
- title
- content
- approver_ids
- classification
- document_type
properties:
@@ -5431,11 +5418,6 @@ components:
content:
type: string
description: Document content
approver_ids:
type: array
items:
$ref: "#/components/schemas/GID"
description: Approver IDs
classification:
$ref: "#/components/schemas/DocumentClassification"
description: Document classification
@@ -5468,11 +5450,6 @@ components:
title:
type: string
description: Document title
approver_ids:
type: array
items:
$ref: "#/components/schemas/GID"
description: Approver IDs
classification:
$ref: "#/components/schemas/DocumentClassification"
description: Document classification
@@ -5639,7 +5616,7 @@ components:
description: Document ID
changelog:
type: string
description: Changelog
description: Changelog for this version
PublishDocumentVersionOutput:
type: object
@@ -5652,6 +5629,32 @@ components:
document_version:
$ref: "#/components/schemas/DocumentVersion"
RequestDocumentVersionApprovalInput:
type: object
required:
- document_id
- approver_ids
properties:
document_id:
$ref: "#/components/schemas/GID"
description: Document ID
approver_ids:
type: array
items:
$ref: "#/components/schemas/GID"
description: Approver profile IDs
changelog:
type: string
description: Changelog for this version
RequestDocumentVersionApprovalOutput:
type: object
required:
- document_version
properties:
document_version:
$ref: "#/components/schemas/DocumentVersion"
DeleteDocumentInput:
type: object
required:
@@ -7476,6 +7479,14 @@ tools:
$ref: "#/components/schemas/PublishDocumentVersionInput"
outputSchema:
$ref: "#/components/schemas/PublishDocumentVersionOutput"
- name: requestDocumentVersionApproval
description: Request approval for a document version
hints:
readonly: false
inputSchema:
$ref: "#/components/schemas/RequestDocumentVersionApprovalInput"
outputSchema:
$ref: "#/components/schemas/RequestDocumentVersionApprovalOutput"
- name: deleteDocument
description: Delete a document
hints:

View File

@@ -16,15 +16,13 @@ package types
import (
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
func NewDocument(d *coredata.Document, approverIDs []gid.GID) *Document {
func NewDocument(d *coredata.Document) *Document {
return &Document{
ID: d.ID,
OrganizationID: d.OrganizationID,
ApproverIds: approverIDs,
Title: d.Title,
DocumentType: d.DocumentType,
Classification: d.Classification,
@@ -37,10 +35,10 @@ func NewDocument(d *coredata.Document, approverIDs []gid.GID) *Document {
}
}
func NewListControlDocumentsOutput(documentPage *page.Page[*coredata.Document, coredata.DocumentOrderField], approverIDsMap map[gid.GID][]gid.GID) ListControlDocumentsOutput {
func NewListControlDocumentsOutput(documentPage *page.Page[*coredata.Document, coredata.DocumentOrderField]) ListControlDocumentsOutput {
documents := make([]*Document, 0, len(documentPage.Data))
for _, d := range documentPage.Data {
documents = append(documents, NewDocument(d, approverIDsMap[d.ID]))
documents = append(documents, NewDocument(d))
}
var nextCursor *page.CursorKey
@@ -55,10 +53,10 @@ func NewListControlDocumentsOutput(documentPage *page.Page[*coredata.Document, c
}
}
func NewListDocumentsOutput(documentPage *page.Page[*coredata.Document, coredata.DocumentOrderField], approverIDsMap map[gid.GID][]gid.GID) ListDocumentsOutput {
func NewListDocumentsOutput(documentPage *page.Page[*coredata.Document, coredata.DocumentOrderField]) ListDocumentsOutput {
documents := make([]*Document, 0, len(documentPage.Data))
for _, d := range documentPage.Data {
documents = append(documents, NewDocument(d, approverIDsMap[d.ID]))
documents = append(documents, NewDocument(d))
}
var nextCursor *page.CursorKey
@@ -73,20 +71,19 @@ func NewListDocumentsOutput(documentPage *page.Page[*coredata.Document, coredata
}
}
func NewAddDocumentOutput(doc *coredata.Document, docVersion *coredata.DocumentVersion, docApproverIDs []gid.GID, versionApproverIDs []gid.GID) AddDocumentOutput {
func NewAddDocumentOutput(doc *coredata.Document, docVersion *coredata.DocumentVersion) AddDocumentOutput {
return AddDocumentOutput{
Document: NewDocument(doc, docApproverIDs),
DocumentVersion: NewDocumentVersion(docVersion, versionApproverIDs),
Document: NewDocument(doc),
DocumentVersion: NewDocumentVersion(docVersion),
}
}
func NewDocumentVersion(dv *coredata.DocumentVersion, approverIDs []gid.GID) *DocumentVersion {
func NewDocumentVersion(dv *coredata.DocumentVersion) *DocumentVersion {
return &DocumentVersion{
ID: dv.ID,
OrganizationID: dv.OrganizationID,
DocumentID: dv.DocumentID,
Title: dv.Title,
ApproverIds: approverIDs,
VersionNumber: dv.VersionNumber,
Classification: dv.Classification,
Content: dv.Content,
@@ -98,10 +95,10 @@ func NewDocumentVersion(dv *coredata.DocumentVersion, approverIDs []gid.GID) *Do
}
}
func NewListDocumentVersionsOutput(versionPage *page.Page[*coredata.DocumentVersion, coredata.DocumentVersionOrderField], approverIDsMap map[gid.GID][]gid.GID) ListDocumentVersionsOutput {
func NewListDocumentVersionsOutput(versionPage *page.Page[*coredata.DocumentVersion, coredata.DocumentVersionOrderField]) ListDocumentVersionsOutput {
versions := make([]*DocumentVersion, 0, len(versionPage.Data))
for _, v := range versionPage.Data {
versions = append(versions, NewDocumentVersion(v, approverIDsMap[v.ID]))
versions = append(versions, NewDocumentVersion(v))
}
var nextCursor *page.CursorKey

View File

@@ -19,6 +19,7 @@ import (
"fmt"
"io"
"errors"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/docgen"
@@ -160,19 +161,50 @@ func (s *DocumentService) exportPDFData(
return fmt.Errorf("cannot load latest published document version: %w", err)
}
// Load approvers
docApprovers := &coredata.DocumentApprovers{}
if err := docApprovers.LoadByDocumentID(ctx, conn, s.svc.scope, documentID); err != nil {
return fmt.Errorf("cannot load document approvers: %w", err)
}
lastQuorum := &coredata.DocumentVersionApprovalQuorum{}
if err := lastQuorum.LoadLastByDocumentVersionID(ctx, conn, s.svc.scope, version.ID); err != nil {
if !errors.Is(err, coredata.ErrResourceNotFound) {
return fmt.Errorf("cannot load last approval quorum: %w", err)
}
} else if lastQuorum.Status == coredata.DocumentVersionApprovalQuorumStatusApproved {
approvedDecisions := &coredata.DocumentVersionApprovalDecisions{}
approvedFilter := coredata.NewDocumentVersionApprovalDecisionFilter(
coredata.DocumentVersionApprovalDecisionStates{coredata.DocumentVersionApprovalDecisionStateApproved},
)
if err := approvedDecisions.LoadByQuorumID(
ctx,
conn,
s.svc.scope,
lastQuorum.ID,
page.NewCursor(
100,
nil,
page.Head,
page.OrderBy[coredata.DocumentVersionApprovalDecisionOrderField]{
Field: coredata.DocumentVersionApprovalDecisionOrderFieldCreatedAt,
Direction: page.OrderDirectionAsc,
},
),
approvedFilter,
); err != nil {
return fmt.Errorf("cannot load approved decisions: %w", err)
}
profiles := coredata.MembershipProfiles{}
if err := profiles.LoadByIDs(ctx, conn, s.svc.scope, docApprovers.ApproverProfileIDs()); err != nil {
return fmt.Errorf("cannot load document approver profiles: %w", err)
}
approverProfileIDs := make([]gid.GID, 0, len(*approvedDecisions))
for _, d := range *approvedDecisions {
approverProfileIDs = append(approverProfileIDs, d.ApproverID)
}
for _, p := range profiles {
approverNames = append(approverNames, p.FullName)
if len(approverProfileIDs) > 0 {
profiles := coredata.MembershipProfiles{}
if err := profiles.LoadByIDs(ctx, conn, s.svc.scope, approverProfileIDs); err != nil {
return fmt.Errorf("cannot load approver profiles: %w", err)
}
for _, p := range profiles {
approverNames = append(approverNames, p.FullName)
}
}
}
if err := organization.LoadByID(ctx, conn, s.svc.scope, document.OrganizationID); err != nil {